release orchestration strengthening

This commit is contained in:
master
2026-01-17 21:32:03 +02:00
parent 195dff2457
commit da27b9faa9
256 changed files with 94634 additions and 2269 deletions

View File

@@ -0,0 +1,542 @@
// -----------------------------------------------------------------------------
// EnvironmentsController.cs
// Sprint: SPRINT_20260117_041_ReleaseOrchestrator_observability
// Task: API-003 - Environment Management API Endpoints
// Description: API endpoints for environment configuration and health
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace StellaOps.Api.Controllers;
/// <summary>
/// Controller for environment management endpoints.
/// </summary>
[ApiController]
[Route("v1/environments")]
[Authorize]
public class EnvironmentsController : ControllerBase
{
private readonly IEnvironmentService _environmentService;
private readonly ILogger<EnvironmentsController> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="EnvironmentsController"/> class.
/// </summary>
public EnvironmentsController(
IEnvironmentService environmentService,
ILogger<EnvironmentsController> logger)
{
_environmentService = environmentService;
_logger = logger;
}
/// <summary>
/// Lists all configured environments.
/// </summary>
/// <param name="ct">Cancellation token.</param>
/// <returns>List of environments.</returns>
[HttpGet]
[ProducesResponseType(typeof(ListEnvironmentsResponse), StatusCodes.Status200OK)]
public async Task<IActionResult> ListEnvironments(CancellationToken ct)
{
_logger.LogDebug("Listing environments");
var environments = await _environmentService.ListEnvironmentsAsync(ct);
return Ok(new ListEnvironmentsResponse { Environments = environments });
}
/// <summary>
/// Gets a specific environment by name.
/// </summary>
/// <param name="environmentName">The environment name.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The environment details.</returns>
[HttpGet("{environmentName}")]
[ProducesResponseType(typeof(EnvironmentDto), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetEnvironment(
[FromRoute] string environmentName,
CancellationToken ct)
{
var environment = await _environmentService.GetEnvironmentAsync(environmentName, ct);
if (environment is null)
{
return NotFound(new ProblemDetails
{
Title = "Environment not found",
Detail = $"Environment '{environmentName}' does not exist",
Status = StatusCodes.Status404NotFound
});
}
return Ok(environment);
}
/// <summary>
/// Creates a new environment.
/// </summary>
/// <param name="request">The environment creation request.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The created environment.</returns>
[HttpPost]
[ProducesResponseType(typeof(EnvironmentDto), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
public async Task<IActionResult> CreateEnvironment(
[FromBody] CreateEnvironmentRequest request,
CancellationToken ct)
{
_logger.LogInformation("Creating environment {Name}", request.Name);
try
{
var environment = await _environmentService.CreateEnvironmentAsync(request, ct);
return CreatedAtAction(
nameof(GetEnvironment),
new { environmentName = environment.Name },
environment);
}
catch (EnvironmentAlreadyExistsException)
{
return Conflict(new ProblemDetails
{
Title = "Environment already exists",
Detail = $"Environment '{request.Name}' already exists",
Status = StatusCodes.Status409Conflict
});
}
}
/// <summary>
/// Updates an existing environment.
/// </summary>
/// <param name="environmentName">The environment name.</param>
/// <param name="request">The environment update request.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The updated environment.</returns>
[HttpPut("{environmentName}")]
[ProducesResponseType(typeof(EnvironmentDto), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> UpdateEnvironment(
[FromRoute] string environmentName,
[FromBody] UpdateEnvironmentRequest request,
CancellationToken ct)
{
_logger.LogInformation("Updating environment {Name}", environmentName);
try
{
var environment = await _environmentService.UpdateEnvironmentAsync(
environmentName, request, ct);
return Ok(environment);
}
catch (EnvironmentNotFoundException)
{
return NotFound(new ProblemDetails
{
Title = "Environment not found",
Detail = $"Environment '{environmentName}' does not exist",
Status = StatusCodes.Status404NotFound
});
}
}
/// <summary>
/// Deletes an environment.
/// </summary>
/// <param name="environmentName">The environment name.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>No content on success.</returns>
[HttpDelete("{environmentName}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
public async Task<IActionResult> DeleteEnvironment(
[FromRoute] string environmentName,
CancellationToken ct)
{
_logger.LogWarning("Deleting environment {Name}", environmentName);
try
{
await _environmentService.DeleteEnvironmentAsync(environmentName, ct);
return NoContent();
}
catch (EnvironmentNotFoundException)
{
return NotFound(new ProblemDetails
{
Title = "Environment not found",
Detail = $"Environment '{environmentName}' does not exist",
Status = StatusCodes.Status404NotFound
});
}
catch (EnvironmentInUseException)
{
return Conflict(new ProblemDetails
{
Title = "Environment in use",
Detail = $"Environment '{environmentName}' has active releases and cannot be deleted",
Status = StatusCodes.Status409Conflict
});
}
}
/// <summary>
/// Gets the health status of an environment.
/// </summary>
/// <param name="environmentName">The environment name.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The environment health.</returns>
[HttpGet("{environmentName}/health")]
[ProducesResponseType(typeof(EnvironmentHealthDto), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetEnvironmentHealth(
[FromRoute] string environmentName,
CancellationToken ct)
{
var health = await _environmentService.GetEnvironmentHealthAsync(environmentName, ct);
if (health is null)
{
return NotFound(new ProblemDetails
{
Title = "Environment not found",
Detail = $"Environment '{environmentName}' does not exist",
Status = StatusCodes.Status404NotFound
});
}
return Ok(health);
}
/// <summary>
/// Gets the current deployments in an environment.
/// </summary>
/// <param name="environmentName">The environment name.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The current deployments.</returns>
[HttpGet("{environmentName}/deployments")]
[ProducesResponseType(typeof(ListDeploymentsResponse), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetEnvironmentDeployments(
[FromRoute] string environmentName,
CancellationToken ct)
{
var deployments = await _environmentService.GetDeploymentsAsync(environmentName, ct);
if (deployments is null)
{
return NotFound(new ProblemDetails
{
Title = "Environment not found",
Detail = $"Environment '{environmentName}' does not exist",
Status = StatusCodes.Status404NotFound
});
}
return Ok(new ListDeploymentsResponse { Deployments = deployments });
}
/// <summary>
/// Gets the promotion path for an environment.
/// </summary>
/// <param name="environmentName">The environment name.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The promotion path.</returns>
[HttpGet("{environmentName}/promotion-path")]
[ProducesResponseType(typeof(PromotionPathDto), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetPromotionPath(
[FromRoute] string environmentName,
CancellationToken ct)
{
var path = await _environmentService.GetPromotionPathAsync(environmentName, ct);
if (path is null)
{
return NotFound(new ProblemDetails
{
Title = "Environment not found",
Detail = $"Environment '{environmentName}' does not exist",
Status = StatusCodes.Status404NotFound
});
}
return Ok(path);
}
/// <summary>
/// Locks an environment to prevent deployments.
/// </summary>
/// <param name="environmentName">The environment name.</param>
/// <param name="request">The lock request.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The lock result.</returns>
[HttpPost("{environmentName}/lock")]
[ProducesResponseType(typeof(EnvironmentLockDto), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> LockEnvironment(
[FromRoute] string environmentName,
[FromBody] LockEnvironmentRequest request,
CancellationToken ct)
{
_logger.LogWarning(
"Locking environment {Environment}, reason: {Reason}",
environmentName, request.Reason);
try
{
var lockResult = await _environmentService.LockEnvironmentAsync(
environmentName, request.Reason, request.ExpiresAt, ct);
return Ok(lockResult);
}
catch (EnvironmentNotFoundException)
{
return NotFound(new ProblemDetails
{
Title = "Environment not found",
Detail = $"Environment '{environmentName}' does not exist",
Status = StatusCodes.Status404NotFound
});
}
}
/// <summary>
/// Unlocks an environment.
/// </summary>
/// <param name="environmentName">The environment name.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>No content on success.</returns>
[HttpDelete("{environmentName}/lock")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> UnlockEnvironment(
[FromRoute] string environmentName,
CancellationToken ct)
{
_logger.LogInformation("Unlocking environment {Environment}", environmentName);
try
{
await _environmentService.UnlockEnvironmentAsync(environmentName, ct);
return NoContent();
}
catch (EnvironmentNotFoundException)
{
return NotFound(new ProblemDetails
{
Title = "Environment not found",
Detail = $"Environment '{environmentName}' does not exist",
Status = StatusCodes.Status404NotFound
});
}
}
}
#region Request/Response DTOs
/// <summary>
/// Response for listing environments.
/// </summary>
public sealed record ListEnvironmentsResponse
{
public required IReadOnlyList<EnvironmentDto> Environments { get; init; }
}
/// <summary>
/// Environment data transfer object.
/// </summary>
public sealed record EnvironmentDto
{
public required string Name { get; init; }
public required string DisplayName { get; init; }
public required int Order { get; init; }
public required bool IsProduction { get; init; }
public required bool IsLocked { get; init; }
public string? Description { get; init; }
public string? NextEnvironment { get; init; }
public string? PreviousEnvironment { get; init; }
public ImmutableDictionary<string, string> Labels { get; init; } =
ImmutableDictionary<string, string>.Empty;
public required DateTimeOffset CreatedAt { get; init; }
}
/// <summary>
/// Request to create an environment.
/// </summary>
public sealed record CreateEnvironmentRequest
{
public required string Name { get; init; }
public required string DisplayName { get; init; }
public int Order { get; init; } = 100;
public bool IsProduction { get; init; } = false;
public string? Description { get; init; }
public string? NextEnvironment { get; init; }
public ImmutableDictionary<string, string> Labels { get; init; } =
ImmutableDictionary<string, string>.Empty;
}
/// <summary>
/// Request to update an environment.
/// </summary>
public sealed record UpdateEnvironmentRequest
{
public string? DisplayName { get; init; }
public int? Order { get; init; }
public bool? IsProduction { get; init; }
public string? Description { get; init; }
public string? NextEnvironment { get; init; }
public ImmutableDictionary<string, string>? Labels { get; init; }
}
/// <summary>
/// Environment health DTO.
/// </summary>
public sealed record EnvironmentHealthDto
{
public required string Environment { get; init; }
public required string Status { get; init; }
public required int HealthyComponents { get; init; }
public required int TotalComponents { get; init; }
public double HealthPercentage => TotalComponents > 0
? (double)HealthyComponents / TotalComponents * 100
: 0;
public required IReadOnlyList<ComponentHealthDto> Components { get; init; }
public required DateTimeOffset CheckedAt { get; init; }
}
/// <summary>
/// Component health DTO.
/// </summary>
public sealed record ComponentHealthDto
{
public required string Name { get; init; }
public required string Status { get; init; }
public string? Version { get; init; }
public string? Message { get; init; }
public DateTimeOffset? LastHeartbeat { get; init; }
}
/// <summary>
/// Response for listing deployments.
/// </summary>
public sealed record ListDeploymentsResponse
{
public required IReadOnlyList<DeploymentDto> Deployments { get; init; }
}
/// <summary>
/// Deployment DTO.
/// </summary>
public sealed record DeploymentDto
{
public required Guid Id { get; init; }
public required string ArtifactDigest { get; init; }
public required string Version { get; init; }
public required string Status { get; init; }
public required DateTimeOffset DeployedAt { get; init; }
public string? DeployedBy { get; init; }
public Guid? ReleaseId { get; init; }
}
/// <summary>
/// Promotion path DTO.
/// </summary>
public sealed record PromotionPathDto
{
public required string CurrentEnvironment { get; init; }
public required IReadOnlyList<string> PrecedingEnvironments { get; init; }
public required IReadOnlyList<string> FollowingEnvironments { get; init; }
public required IReadOnlyList<PromotionStepDto> Steps { get; init; }
}
/// <summary>
/// Promotion step DTO.
/// </summary>
public sealed record PromotionStepDto
{
public required string FromEnvironment { get; init; }
public required string ToEnvironment { get; init; }
public required bool RequiresApproval { get; init; }
public required IReadOnlyList<string> RequiredGates { get; init; }
}
/// <summary>
/// Request to lock an environment.
/// </summary>
public sealed record LockEnvironmentRequest
{
public required string Reason { get; init; }
public DateTimeOffset? ExpiresAt { get; init; }
}
/// <summary>
/// Environment lock DTO.
/// </summary>
public sealed record EnvironmentLockDto
{
public required Guid LockId { get; init; }
public required string Environment { get; init; }
public required string LockedBy { get; init; }
public required string Reason { get; init; }
public required DateTimeOffset LockedAt { get; init; }
public DateTimeOffset? ExpiresAt { get; init; }
}
#endregion
#region Interfaces
/// <summary>
/// Interface for environment service.
/// </summary>
public interface IEnvironmentService
{
Task<IReadOnlyList<EnvironmentDto>> ListEnvironmentsAsync(CancellationToken ct);
Task<EnvironmentDto?> GetEnvironmentAsync(string name, CancellationToken ct);
Task<EnvironmentDto> CreateEnvironmentAsync(CreateEnvironmentRequest request, CancellationToken ct);
Task<EnvironmentDto> UpdateEnvironmentAsync(string name, UpdateEnvironmentRequest request, CancellationToken ct);
Task DeleteEnvironmentAsync(string name, CancellationToken ct);
Task<EnvironmentHealthDto?> GetEnvironmentHealthAsync(string name, CancellationToken ct);
Task<IReadOnlyList<DeploymentDto>?> GetDeploymentsAsync(string name, CancellationToken ct);
Task<PromotionPathDto?> GetPromotionPathAsync(string name, CancellationToken ct);
Task<EnvironmentLockDto> LockEnvironmentAsync(string name, string reason, DateTimeOffset? expiresAt, CancellationToken ct);
Task UnlockEnvironmentAsync(string name, CancellationToken ct);
}
#endregion
#region Exceptions
/// <summary>
/// Exception thrown when an environment is not found.
/// </summary>
public class EnvironmentNotFoundException : Exception
{
public EnvironmentNotFoundException(string name) : base($"Environment '{name}' not found") { }
}
/// <summary>
/// Exception thrown when an environment already exists.
/// </summary>
public class EnvironmentAlreadyExistsException : Exception
{
public EnvironmentAlreadyExistsException(string name) : base($"Environment '{name}' already exists") { }
}
/// <summary>
/// Exception thrown when an environment is in use.
/// </summary>
public class EnvironmentInUseException : Exception
{
public EnvironmentInUseException(string name) : base($"Environment '{name}' is in use") { }
}
#endregion

View File

@@ -0,0 +1,422 @@
// -----------------------------------------------------------------------------
// GatesController.cs
// Sprint: SPRINT_20260117_041_ReleaseOrchestrator_observability
// Task: API-002 - Gate Management API Endpoints
// Description: API endpoints for gate evaluation and management
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace StellaOps.Api.Controllers;
/// <summary>
/// Controller for gate management endpoints.
/// </summary>
[ApiController]
[Route("v1/gates")]
[Authorize]
public class GatesController : ControllerBase
{
private readonly IGateService _gateService;
private readonly IGateEvaluator _gateEvaluator;
private readonly ILogger<GatesController> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="GatesController"/> class.
/// </summary>
public GatesController(
IGateService gateService,
IGateEvaluator gateEvaluator,
ILogger<GatesController> logger)
{
_gateService = gateService;
_gateEvaluator = gateEvaluator;
_logger = logger;
}
/// <summary>
/// Lists all configured gates.
/// </summary>
/// <param name="environment">Filter by environment.</param>
/// <param name="gateType">Filter by gate type.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>List of gates.</returns>
[HttpGet]
[ProducesResponseType(typeof(ListGatesResponse), StatusCodes.Status200OK)]
public async Task<IActionResult> ListGates(
[FromQuery] string? environment,
[FromQuery] string? gateType,
CancellationToken ct)
{
_logger.LogDebug(
"Listing gates: environment={Environment}, type={GateType}",
environment, gateType);
var gates = await _gateService.ListGatesAsync(environment, gateType, ct);
return Ok(new ListGatesResponse { Gates = gates });
}
/// <summary>
/// Gets a specific gate by ID.
/// </summary>
/// <param name="gateId">The gate ID.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The gate details.</returns>
[HttpGet("{gateId:guid}")]
[ProducesResponseType(typeof(GateDto), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetGate(
[FromRoute] Guid gateId,
CancellationToken ct)
{
var gate = await _gateService.GetGateAsync(gateId, ct);
if (gate is null)
{
return NotFound(new ProblemDetails
{
Title = "Gate not found",
Detail = $"Gate {gateId} does not exist",
Status = StatusCodes.Status404NotFound
});
}
return Ok(gate);
}
/// <summary>
/// Creates a new gate.
/// </summary>
/// <param name="request">The gate creation request.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The created gate.</returns>
[HttpPost]
[ProducesResponseType(typeof(GateDto), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
public async Task<IActionResult> CreateGate(
[FromBody] CreateGateRequest request,
CancellationToken ct)
{
_logger.LogInformation(
"Creating gate {Name} of type {GateType}",
request.Name, request.GateType);
var gate = await _gateService.CreateGateAsync(request, ct);
return CreatedAtAction(
nameof(GetGate),
new { gateId = gate.Id },
gate);
}
/// <summary>
/// Updates an existing gate.
/// </summary>
/// <param name="gateId">The gate ID.</param>
/// <param name="request">The gate update request.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The updated gate.</returns>
[HttpPut("{gateId:guid}")]
[ProducesResponseType(typeof(GateDto), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> UpdateGate(
[FromRoute] Guid gateId,
[FromBody] UpdateGateRequest request,
CancellationToken ct)
{
_logger.LogInformation("Updating gate {GateId}", gateId);
try
{
var gate = await _gateService.UpdateGateAsync(gateId, request, ct);
return Ok(gate);
}
catch (GateNotFoundException)
{
return NotFound(new ProblemDetails
{
Title = "Gate not found",
Detail = $"Gate {gateId} does not exist",
Status = StatusCodes.Status404NotFound
});
}
}
/// <summary>
/// Deletes a gate.
/// </summary>
/// <param name="gateId">The gate ID.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>No content on success.</returns>
[HttpDelete("{gateId:guid}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeleteGate(
[FromRoute] Guid gateId,
CancellationToken ct)
{
_logger.LogWarning("Deleting gate {GateId}", gateId);
try
{
await _gateService.DeleteGateAsync(gateId, ct);
return NoContent();
}
catch (GateNotFoundException)
{
return NotFound(new ProblemDetails
{
Title = "Gate not found",
Detail = $"Gate {gateId} does not exist",
Status = StatusCodes.Status404NotFound
});
}
}
/// <summary>
/// Evaluates gates for a release.
/// </summary>
/// <param name="request">The evaluation request.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The evaluation results.</returns>
[HttpPost("evaluate")]
[ProducesResponseType(typeof(GateEvaluationResponse), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
public async Task<IActionResult> EvaluateGates(
[FromBody] EvaluateGatesRequest request,
CancellationToken ct)
{
_logger.LogInformation(
"Evaluating gates for release {ReleaseId} to {Environment}",
request.ReleaseId, request.TargetEnvironment);
var result = await _gateEvaluator.EvaluateAsync(
request.ReleaseId,
request.TargetEnvironment,
request.ArtifactDigest,
ct);
return Ok(result);
}
/// <summary>
/// Gets the evaluation history for a release.
/// </summary>
/// <param name="releaseId">The release ID.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The evaluation history.</returns>
[HttpGet("evaluations/{releaseId:guid}")]
[ProducesResponseType(typeof(GateEvaluationHistoryResponse), StatusCodes.Status200OK)]
public async Task<IActionResult> GetEvaluationHistory(
[FromRoute] Guid releaseId,
CancellationToken ct)
{
var history = await _gateService.GetEvaluationHistoryAsync(releaseId, ct);
return Ok(new GateEvaluationHistoryResponse
{
ReleaseId = releaseId,
Evaluations = history
});
}
/// <summary>
/// Overrides a gate evaluation (requires elevated permissions).
/// </summary>
/// <param name="gateId">The gate ID.</param>
/// <param name="request">The override request.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The override result.</returns>
[HttpPost("{gateId:guid}/override")]
[Authorize(Policy = "GateOverride")]
[ProducesResponseType(typeof(GateOverrideResult), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status403Forbidden)]
public async Task<IActionResult> OverrideGate(
[FromRoute] Guid gateId,
[FromBody] GateOverrideRequest request,
CancellationToken ct)
{
_logger.LogWarning(
"Overriding gate {GateId} for release {ReleaseId}, reason: {Reason}",
gateId, request.ReleaseId, request.Reason);
var result = await _gateService.OverrideGateAsync(
gateId,
request.ReleaseId,
request.Reason,
request.ExpiresAt,
ct);
return Ok(result);
}
}
#region Request/Response DTOs
/// <summary>
/// Response for listing gates.
/// </summary>
public sealed record ListGatesResponse
{
public required IReadOnlyList<GateDto> Gates { get; init; }
}
/// <summary>
/// Gate data transfer object.
/// </summary>
public sealed record GateDto
{
public required Guid Id { get; init; }
public required string Name { get; init; }
public required string GateType { get; init; }
public required string Environment { get; init; }
public required bool IsEnabled { get; init; }
public required bool IsBlocking { get; init; }
public int Order { get; init; }
public string? Description { get; init; }
public ImmutableDictionary<string, object> Configuration { get; init; } =
ImmutableDictionary<string, object>.Empty;
public required DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? UpdatedAt { get; init; }
}
/// <summary>
/// Request to create a gate.
/// </summary>
public sealed record CreateGateRequest
{
public required string Name { get; init; }
public required string GateType { get; init; }
public required string Environment { get; init; }
public bool IsBlocking { get; init; } = true;
public int Order { get; init; } = 100;
public string? Description { get; init; }
public ImmutableDictionary<string, object> Configuration { get; init; } =
ImmutableDictionary<string, object>.Empty;
}
/// <summary>
/// Request to update a gate.
/// </summary>
public sealed record UpdateGateRequest
{
public string? Name { get; init; }
public bool? IsEnabled { get; init; }
public bool? IsBlocking { get; init; }
public int? Order { get; init; }
public string? Description { get; init; }
public ImmutableDictionary<string, object>? Configuration { get; init; }
}
/// <summary>
/// Request to evaluate gates.
/// </summary>
public sealed record EvaluateGatesRequest
{
public required Guid ReleaseId { get; init; }
public required string TargetEnvironment { get; init; }
public required string ArtifactDigest { get; init; }
}
/// <summary>
/// Response for gate evaluation.
/// </summary>
public sealed record GateEvaluationResponse
{
public required Guid EvaluationId { get; init; }
public required bool AllPassed { get; init; }
public required IReadOnlyList<GateEvaluationResultDto> Results { get; init; }
public required DateTimeOffset EvaluatedAt { get; init; }
public TimeSpan Duration { get; init; }
}
/// <summary>
/// Result of a single gate evaluation.
/// </summary>
public sealed record GateEvaluationResultDto
{
public required Guid GateId { get; init; }
public required string GateName { get; init; }
public required string GateType { get; init; }
public required bool Passed { get; init; }
public required bool IsBlocking { get; init; }
public string? Message { get; init; }
public ImmutableDictionary<string, object> Details { get; init; } =
ImmutableDictionary<string, object>.Empty;
public TimeSpan Duration { get; init; }
}
/// <summary>
/// Response for gate evaluation history.
/// </summary>
public sealed record GateEvaluationHistoryResponse
{
public required Guid ReleaseId { get; init; }
public required IReadOnlyList<GateEvaluationResponse> Evaluations { get; init; }
}
/// <summary>
/// Request to override a gate.
/// </summary>
public sealed record GateOverrideRequest
{
public required Guid ReleaseId { get; init; }
public required string Reason { get; init; }
public DateTimeOffset? ExpiresAt { get; init; }
}
/// <summary>
/// Result of gate override.
/// </summary>
public sealed record GateOverrideResult
{
public required Guid OverrideId { get; init; }
public required Guid GateId { get; init; }
public required Guid ReleaseId { get; init; }
public required string OverriddenBy { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? ExpiresAt { get; init; }
}
#endregion
#region Interfaces
/// <summary>
/// Interface for gate service.
/// </summary>
public interface IGateService
{
Task<IReadOnlyList<GateDto>> ListGatesAsync(string? environment, string? gateType, CancellationToken ct);
Task<GateDto?> GetGateAsync(Guid gateId, CancellationToken ct);
Task<GateDto> CreateGateAsync(CreateGateRequest request, CancellationToken ct);
Task<GateDto> UpdateGateAsync(Guid gateId, UpdateGateRequest request, CancellationToken ct);
Task DeleteGateAsync(Guid gateId, CancellationToken ct);
Task<IReadOnlyList<GateEvaluationResponse>> GetEvaluationHistoryAsync(Guid releaseId, CancellationToken ct);
Task<GateOverrideResult> OverrideGateAsync(Guid gateId, Guid releaseId, string reason, DateTimeOffset? expiresAt, CancellationToken ct);
}
/// <summary>
/// Interface for gate evaluator.
/// </summary>
public interface IGateEvaluator
{
Task<GateEvaluationResponse> EvaluateAsync(Guid releaseId, string targetEnvironment, string artifactDigest, CancellationToken ct);
}
#endregion
#region Exceptions
/// <summary>
/// Exception thrown when a gate is not found.
/// </summary>
public class GateNotFoundException : Exception
{
public GateNotFoundException(Guid gateId) : base($"Gate {gateId} not found") { }
}
#endregion

View File

@@ -0,0 +1,484 @@
// -----------------------------------------------------------------------------
// ObservabilityController.cs
// Sprint: SPRINT_20260117_041_ReleaseOrchestrator_observability
// Task: API-004 - Observability API Endpoints
// Description: API endpoints for metrics, traces, and health monitoring
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace StellaOps.Api.Controllers;
/// <summary>
/// Controller for observability and monitoring endpoints.
/// </summary>
[ApiController]
[Route("v1/observability")]
[Authorize]
public class ObservabilityController : ControllerBase
{
private readonly IObservabilityService _observabilityService;
private readonly IHealthService _healthService;
private readonly ILogger<ObservabilityController> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="ObservabilityController"/> class.
/// </summary>
public ObservabilityController(
IObservabilityService observabilityService,
IHealthService healthService,
ILogger<ObservabilityController> logger)
{
_observabilityService = observabilityService;
_healthService = healthService;
_logger = logger;
}
/// <summary>
/// Gets system health status.
/// </summary>
/// <param name="ct">Cancellation token.</param>
/// <returns>The system health.</returns>
[HttpGet("health")]
[AllowAnonymous]
[ProducesResponseType(typeof(SystemHealthResponse), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(SystemHealthResponse), StatusCodes.Status503ServiceUnavailable)]
public async Task<IActionResult> GetSystemHealth(CancellationToken ct)
{
var health = await _healthService.GetSystemHealthAsync(ct);
var statusCode = health.Status == "Healthy"
? StatusCodes.Status200OK
: StatusCodes.Status503ServiceUnavailable;
return StatusCode(statusCode, health);
}
/// <summary>
/// Gets liveness probe status.
/// </summary>
/// <returns>OK if alive.</returns>
[HttpGet("health/live")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)]
public IActionResult GetLiveness()
{
return Ok(new { status = "alive", timestamp = DateTimeOffset.UtcNow });
}
/// <summary>
/// Gets readiness probe status.
/// </summary>
/// <param name="ct">Cancellation token.</param>
/// <returns>OK if ready to serve traffic.</returns>
[HttpGet("health/ready")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
public async Task<IActionResult> GetReadiness(CancellationToken ct)
{
var ready = await _healthService.IsReadyAsync(ct);
if (ready)
{
return Ok(new { status = "ready", timestamp = DateTimeOffset.UtcNow });
}
return StatusCode(StatusCodes.Status503ServiceUnavailable,
new { status = "not_ready", timestamp = DateTimeOffset.UtcNow });
}
/// <summary>
/// Gets metrics in Prometheus format.
/// </summary>
/// <returns>Prometheus-formatted metrics.</returns>
[HttpGet("metrics")]
[AllowAnonymous]
[Produces("text/plain")]
[ProducesResponseType(typeof(string), StatusCodes.Status200OK)]
public async Task<IActionResult> GetMetrics(CancellationToken ct)
{
var metrics = await _observabilityService.GetPrometheusMetricsAsync(ct);
return Content(metrics, "text/plain; version=0.0.4; charset=utf-8");
}
/// <summary>
/// Gets custom metrics for a specific domain.
/// </summary>
/// <param name="domain">The metrics domain (releases, gates, health).</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Domain metrics.</returns>
[HttpGet("metrics/{domain}")]
[ProducesResponseType(typeof(DomainMetricsResponse), StatusCodes.Status200OK)]
public async Task<IActionResult> GetDomainMetrics(
[FromRoute] string domain,
CancellationToken ct)
{
var metrics = await _observabilityService.GetDomainMetricsAsync(domain, ct);
return Ok(metrics);
}
/// <summary>
/// Gets a trace by ID.
/// </summary>
/// <param name="traceId">The trace ID.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The trace details.</returns>
[HttpGet("traces/{traceId}")]
[ProducesResponseType(typeof(TraceDto), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetTrace(
[FromRoute] string traceId,
CancellationToken ct)
{
var trace = await _observabilityService.GetTraceAsync(traceId, ct);
if (trace is null)
{
return NotFound(new ProblemDetails
{
Title = "Trace not found",
Detail = $"Trace {traceId} does not exist",
Status = StatusCodes.Status404NotFound
});
}
return Ok(trace);
}
/// <summary>
/// Searches traces.
/// </summary>
/// <param name="request">The search request.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Matching traces.</returns>
[HttpPost("traces/search")]
[ProducesResponseType(typeof(TraceSearchResponse), StatusCodes.Status200OK)]
public async Task<IActionResult> SearchTraces(
[FromBody] TraceSearchRequest request,
CancellationToken ct)
{
var results = await _observabilityService.SearchTracesAsync(request, ct);
return Ok(results);
}
/// <summary>
/// Gets logs with optional filtering.
/// </summary>
/// <param name="level">Minimum log level.</param>
/// <param name="correlationId">Filter by correlation ID.</param>
/// <param name="startTime">Start time filter.</param>
/// <param name="endTime">End time filter.</param>
/// <param name="limit">Maximum results (default 100).</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Matching log entries.</returns>
[HttpGet("logs")]
[ProducesResponseType(typeof(LogSearchResponse), StatusCodes.Status200OK)]
public async Task<IActionResult> GetLogs(
[FromQuery] string? level,
[FromQuery] string? correlationId,
[FromQuery] DateTimeOffset? startTime,
[FromQuery] DateTimeOffset? endTime,
[FromQuery] int limit = 100,
CancellationToken ct = default)
{
var request = new LogSearchRequest
{
Level = level,
CorrelationId = correlationId,
StartTime = startTime,
EndTime = endTime,
Limit = Math.Clamp(limit, 1, 1000)
};
var results = await _observabilityService.SearchLogsAsync(request, ct);
return Ok(results);
}
/// <summary>
/// Gets observability statistics.
/// </summary>
/// <param name="ct">Cancellation token.</param>
/// <returns>Observability stats.</returns>
[HttpGet("stats")]
[ProducesResponseType(typeof(ObservabilityStatsResponse), StatusCodes.Status200OK)]
public async Task<IActionResult> GetStats(CancellationToken ct)
{
var stats = await _observabilityService.GetStatsAsync(ct);
return Ok(stats);
}
/// <summary>
/// Gets release metrics summary.
/// </summary>
/// <param name="environment">Filter by environment.</param>
/// <param name="period">Time period (1h, 24h, 7d, 30d).</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Release metrics summary.</returns>
[HttpGet("releases/metrics")]
[ProducesResponseType(typeof(ReleaseMetricsSummary), StatusCodes.Status200OK)]
public async Task<IActionResult> GetReleaseMetrics(
[FromQuery] string? environment,
[FromQuery] string period = "24h",
CancellationToken ct = default)
{
var metrics = await _observabilityService.GetReleaseMetricsAsync(environment, period, ct);
return Ok(metrics);
}
/// <summary>
/// Gets SLA status.
/// </summary>
/// <param name="ct">Cancellation token.</param>
/// <returns>SLA status.</returns>
[HttpGet("sla")]
[ProducesResponseType(typeof(SlaStatusResponse), StatusCodes.Status200OK)]
public async Task<IActionResult> GetSlaStatus(CancellationToken ct)
{
var status = await _observabilityService.GetSlaStatusAsync(ct);
return Ok(status);
}
}
#region Request/Response DTOs
/// <summary>
/// System health response.
/// </summary>
public sealed record SystemHealthResponse
{
public required string Status { get; init; }
public required string Version { get; init; }
public required DateTimeOffset Timestamp { get; init; }
public required TimeSpan Uptime { get; init; }
public required IReadOnlyList<HealthCheckResult> Checks { get; init; }
}
/// <summary>
/// Health check result.
/// </summary>
public sealed record HealthCheckResult
{
public required string Name { get; init; }
public required string Status { get; init; }
public string? Description { get; init; }
public TimeSpan Duration { get; init; }
public ImmutableDictionary<string, object> Data { get; init; } =
ImmutableDictionary<string, object>.Empty;
}
/// <summary>
/// Domain metrics response.
/// </summary>
public sealed record DomainMetricsResponse
{
public required string Domain { get; init; }
public required IReadOnlyList<MetricDto> Metrics { get; init; }
public required DateTimeOffset GeneratedAt { get; init; }
}
/// <summary>
/// Metric DTO.
/// </summary>
public sealed record MetricDto
{
public required string Name { get; init; }
public required string Type { get; init; }
public required double Value { get; init; }
public string? Unit { get; init; }
public ImmutableDictionary<string, string> Labels { get; init; } =
ImmutableDictionary<string, string>.Empty;
}
/// <summary>
/// Trace DTO.
/// </summary>
public sealed record TraceDto
{
public required string TraceId { get; init; }
public required string RootOperation { get; init; }
public required DateTimeOffset StartTime { get; init; }
public required TimeSpan Duration { get; init; }
public required int SpanCount { get; init; }
public required int ServiceCount { get; init; }
public required bool HasErrors { get; init; }
public required IReadOnlyList<SpanDto> Spans { get; init; }
}
/// <summary>
/// Span DTO.
/// </summary>
public sealed record SpanDto
{
public required string SpanId { get; init; }
public string? ParentSpanId { get; init; }
public required string OperationName { get; init; }
public required string ServiceName { get; init; }
public required DateTimeOffset StartTime { get; init; }
public required TimeSpan Duration { get; init; }
public required string Status { get; init; }
public ImmutableDictionary<string, string> Attributes { get; init; } =
ImmutableDictionary<string, string>.Empty;
}
/// <summary>
/// Trace search request.
/// </summary>
public sealed record TraceSearchRequest
{
public string? ServiceName { get; init; }
public string? OperationName { get; init; }
public DateTimeOffset? StartTime { get; init; }
public DateTimeOffset? EndTime { get; init; }
public TimeSpan? MinDuration { get; init; }
public bool? HasErrors { get; init; }
public ImmutableDictionary<string, string> Tags { get; init; } =
ImmutableDictionary<string, string>.Empty;
public int Limit { get; init; } = 20;
}
/// <summary>
/// Trace search response.
/// </summary>
public sealed record TraceSearchResponse
{
public required IReadOnlyList<TraceDto> Traces { get; init; }
public required int TotalCount { get; init; }
}
/// <summary>
/// Log search request.
/// </summary>
public sealed record LogSearchRequest
{
public string? Level { get; init; }
public string? CorrelationId { get; init; }
public string? TraceId { get; init; }
public string? Message { get; init; }
public DateTimeOffset? StartTime { get; init; }
public DateTimeOffset? EndTime { get; init; }
public int Limit { get; init; } = 100;
}
/// <summary>
/// Log search response.
/// </summary>
public sealed record LogSearchResponse
{
public required IReadOnlyList<LogEntryDto> Entries { get; init; }
public required int TotalCount { get; init; }
}
/// <summary>
/// Log entry DTO.
/// </summary>
public sealed record LogEntryDto
{
public required DateTimeOffset Timestamp { get; init; }
public required string Level { get; init; }
public required string Message { get; init; }
public string? CorrelationId { get; init; }
public string? TraceId { get; init; }
public string? Source { get; init; }
public ImmutableDictionary<string, object> Properties { get; init; } =
ImmutableDictionary<string, object>.Empty;
}
/// <summary>
/// Observability stats response.
/// </summary>
public sealed record ObservabilityStatsResponse
{
public required int MetricsBuffered { get; init; }
public required int TracesBuffered { get; init; }
public required int LogsBuffered { get; init; }
public required long DroppedMetrics { get; init; }
public required long DroppedTraces { get; init; }
public required long DroppedLogs { get; init; }
public required int RegisteredMetrics { get; init; }
public required DateTimeOffset GeneratedAt { get; init; }
}
/// <summary>
/// Release metrics summary.
/// </summary>
public sealed record ReleaseMetricsSummary
{
public required int TotalReleases { get; init; }
public required int SuccessfulReleases { get; init; }
public required int FailedReleases { get; init; }
public required int RollbackCount { get; init; }
public required double SuccessRate { get; init; }
public required TimeSpan AverageReleaseTime { get; init; }
public required TimeSpan P95ReleaseTime { get; init; }
public required string Period { get; init; }
public required IReadOnlyList<EnvironmentReleaseMetrics> ByEnvironment { get; init; }
}
/// <summary>
/// Release metrics by environment.
/// </summary>
public sealed record EnvironmentReleaseMetrics
{
public required string Environment { get; init; }
public required int TotalReleases { get; init; }
public required int SuccessfulReleases { get; init; }
public required double SuccessRate { get; init; }
public required TimeSpan AverageReleaseTime { get; init; }
}
/// <summary>
/// SLA status response.
/// </summary>
public sealed record SlaStatusResponse
{
public required double CurrentSuccessRate { get; init; }
public required double TargetSuccessRate { get; init; }
public required double ErrorBudgetRemaining { get; init; }
public required int SlaBreaches { get; init; }
public required string Period { get; init; }
public required IReadOnlyList<SlaMetric> Metrics { get; init; }
}
/// <summary>
/// SLA metric.
/// </summary>
public sealed record SlaMetric
{
public required string Name { get; init; }
public required double CurrentValue { get; init; }
public required double TargetValue { get; init; }
public required bool IsMet { get; init; }
}
#endregion
#region Interfaces
/// <summary>
/// Interface for observability service.
/// </summary>
public interface IObservabilityService
{
Task<string> GetPrometheusMetricsAsync(CancellationToken ct);
Task<DomainMetricsResponse> GetDomainMetricsAsync(string domain, CancellationToken ct);
Task<TraceDto?> GetTraceAsync(string traceId, CancellationToken ct);
Task<TraceSearchResponse> SearchTracesAsync(TraceSearchRequest request, CancellationToken ct);
Task<LogSearchResponse> SearchLogsAsync(LogSearchRequest request, CancellationToken ct);
Task<ObservabilityStatsResponse> GetStatsAsync(CancellationToken ct);
Task<ReleaseMetricsSummary> GetReleaseMetricsAsync(string? environment, string period, CancellationToken ct);
Task<SlaStatusResponse> GetSlaStatusAsync(CancellationToken ct);
}
/// <summary>
/// Interface for health service.
/// </summary>
public interface IHealthService
{
Task<SystemHealthResponse> GetSystemHealthAsync(CancellationToken ct);
Task<bool> IsReadyAsync(CancellationToken ct);
}
#endregion

View File

@@ -0,0 +1,501 @@
// -----------------------------------------------------------------------------
// ReleasesController.cs
// Sprint: SPRINT_20260117_041_ReleaseOrchestrator_observability
// Task: API-001 - Release Management API Endpoints
// Description: API endpoints for release management operations
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace StellaOps.Api.Controllers;
/// <summary>
/// Controller for release management endpoints.
/// </summary>
[ApiController]
[Route("v1/releases")]
[Authorize]
public class ReleasesController : ControllerBase
{
private readonly IReleaseService _releaseService;
private readonly IReleaseStateStore _stateStore;
private readonly ILogger<ReleasesController> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="ReleasesController"/> class.
/// </summary>
public ReleasesController(
IReleaseService releaseService,
IReleaseStateStore stateStore,
ILogger<ReleasesController> logger)
{
_releaseService = releaseService;
_stateStore = stateStore;
_logger = logger;
}
/// <summary>
/// Lists all releases with optional filtering.
/// </summary>
/// <param name="environment">Filter by environment.</param>
/// <param name="status">Filter by status.</param>
/// <param name="pageSize">Page size (default 20).</param>
/// <param name="pageToken">Page token for pagination.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>List of releases.</returns>
[HttpGet]
[ProducesResponseType(typeof(ListReleasesResponse), StatusCodes.Status200OK)]
public async Task<IActionResult> ListReleases(
[FromQuery] string? environment,
[FromQuery] string? status,
[FromQuery] int pageSize = 20,
[FromQuery] string? pageToken = null,
CancellationToken ct = default)
{
_logger.LogDebug(
"Listing releases: environment={Environment}, status={Status}",
environment, status);
var filter = new ReleaseFilter
{
Environment = environment,
Status = status,
PageSize = Math.Clamp(pageSize, 1, 100),
PageToken = pageToken
};
var result = await _releaseService.ListReleasesAsync(filter, ct);
return Ok(new ListReleasesResponse
{
Releases = result.Releases,
NextPageToken = result.NextPageToken,
TotalCount = result.TotalCount
});
}
/// <summary>
/// Gets a specific release by ID.
/// </summary>
/// <param name="releaseId">The release ID.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The release details.</returns>
[HttpGet("{releaseId:guid}")]
[ProducesResponseType(typeof(ReleaseDto), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetRelease(
[FromRoute] Guid releaseId,
CancellationToken ct)
{
_logger.LogDebug("Getting release {ReleaseId}", releaseId);
var release = await _releaseService.GetReleaseAsync(releaseId, ct);
if (release is null)
{
return NotFound(new ProblemDetails
{
Title = "Release not found",
Detail = $"Release {releaseId} does not exist",
Status = StatusCodes.Status404NotFound
});
}
return Ok(release);
}
/// <summary>
/// Creates a new release.
/// </summary>
/// <param name="request">The release creation request.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The created release.</returns>
[HttpPost]
[ProducesResponseType(typeof(ReleaseDto), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
public async Task<IActionResult> CreateRelease(
[FromBody] CreateReleaseRequest request,
CancellationToken ct)
{
_logger.LogInformation(
"Creating release for artifact {ArtifactDigest} to {Environment}",
request.ArtifactDigest, request.TargetEnvironment);
var release = await _releaseService.CreateReleaseAsync(request, ct);
return CreatedAtAction(
nameof(GetRelease),
new { releaseId = release.Id },
release);
}
/// <summary>
/// Promotes a release to the next environment.
/// </summary>
/// <param name="releaseId">The release ID.</param>
/// <param name="request">The promotion request.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The updated release.</returns>
[HttpPost("{releaseId:guid}/promote")]
[ProducesResponseType(typeof(ReleaseDto), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
public async Task<IActionResult> PromoteRelease(
[FromRoute] Guid releaseId,
[FromBody] PromoteReleaseRequest request,
CancellationToken ct)
{
_logger.LogInformation(
"Promoting release {ReleaseId} to {Environment}",
releaseId, request.TargetEnvironment);
try
{
var release = await _releaseService.PromoteReleaseAsync(
releaseId,
request.TargetEnvironment,
request.ApprovalId,
ct);
return Ok(release);
}
catch (ReleaseNotFoundException)
{
return NotFound(new ProblemDetails
{
Title = "Release not found",
Detail = $"Release {releaseId} does not exist",
Status = StatusCodes.Status404NotFound
});
}
catch (ReleaseStateConflictException ex)
{
return Conflict(new ProblemDetails
{
Title = "Promotion conflict",
Detail = ex.Message,
Status = StatusCodes.Status409Conflict
});
}
}
/// <summary>
/// Rolls back a release.
/// </summary>
/// <param name="releaseId">The release ID.</param>
/// <param name="request">The rollback request.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The rollback result.</returns>
[HttpPost("{releaseId:guid}/rollback")]
[ProducesResponseType(typeof(RollbackResult), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> RollbackRelease(
[FromRoute] Guid releaseId,
[FromBody] RollbackReleaseRequest request,
CancellationToken ct)
{
_logger.LogWarning(
"Rolling back release {ReleaseId}, reason: {Reason}",
releaseId, request.Reason);
try
{
var result = await _releaseService.RollbackReleaseAsync(
releaseId,
request.Reason,
request.TargetVersion,
ct);
return Ok(result);
}
catch (ReleaseNotFoundException)
{
return NotFound(new ProblemDetails
{
Title = "Release not found",
Detail = $"Release {releaseId} does not exist",
Status = StatusCodes.Status404NotFound
});
}
}
/// <summary>
/// Cancels a pending release.
/// </summary>
/// <param name="releaseId">The release ID.</param>
/// <param name="request">The cancellation request.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>No content on success.</returns>
[HttpPost("{releaseId:guid}/cancel")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
public async Task<IActionResult> CancelRelease(
[FromRoute] Guid releaseId,
[FromBody] CancelReleaseRequest request,
CancellationToken ct)
{
_logger.LogWarning(
"Cancelling release {ReleaseId}, reason: {Reason}",
releaseId, request.Reason);
try
{
await _releaseService.CancelReleaseAsync(releaseId, request.Reason, ct);
return NoContent();
}
catch (ReleaseNotFoundException)
{
return NotFound(new ProblemDetails
{
Title = "Release not found",
Detail = $"Release {releaseId} does not exist",
Status = StatusCodes.Status404NotFound
});
}
catch (ReleaseStateConflictException ex)
{
return Conflict(new ProblemDetails
{
Title = "Cannot cancel",
Detail = ex.Message,
Status = StatusCodes.Status409Conflict
});
}
}
/// <summary>
/// Gets the state machine state for a release.
/// </summary>
/// <param name="releaseId">The release ID.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The release state.</returns>
[HttpGet("{releaseId:guid}/state")]
[ProducesResponseType(typeof(ReleaseStateDto), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetReleaseState(
[FromRoute] Guid releaseId,
CancellationToken ct)
{
var state = await _stateStore.GetStateAsync(releaseId, ct);
if (state is null)
{
return NotFound(new ProblemDetails
{
Title = "Release not found",
Detail = $"Release {releaseId} does not exist",
Status = StatusCodes.Status404NotFound
});
}
return Ok(state);
}
/// <summary>
/// Gets the history of state transitions for a release.
/// </summary>
/// <param name="releaseId">The release ID.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The release history.</returns>
[HttpGet("{releaseId:guid}/history")]
[ProducesResponseType(typeof(ReleaseHistoryResponse), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetReleaseHistory(
[FromRoute] Guid releaseId,
CancellationToken ct)
{
var history = await _releaseService.GetReleaseHistoryAsync(releaseId, ct);
if (history is null)
{
return NotFound(new ProblemDetails
{
Title = "Release not found",
Detail = $"Release {releaseId} does not exist",
Status = StatusCodes.Status404NotFound
});
}
return Ok(new ReleaseHistoryResponse
{
ReleaseId = releaseId,
Events = history
});
}
}
#region Request/Response DTOs
/// <summary>
/// Filter for listing releases.
/// </summary>
public sealed record ReleaseFilter
{
public string? Environment { get; init; }
public string? Status { get; init; }
public int PageSize { get; init; } = 20;
public string? PageToken { get; init; }
}
/// <summary>
/// Response for listing releases.
/// </summary>
public sealed record ListReleasesResponse
{
public required IReadOnlyList<ReleaseDto> Releases { get; init; }
public string? NextPageToken { get; init; }
public int TotalCount { get; init; }
}
/// <summary>
/// Release data transfer object.
/// </summary>
public sealed record ReleaseDto
{
public required Guid Id { get; init; }
public required string ArtifactDigest { get; init; }
public required string Version { get; init; }
public required string Environment { get; init; }
public required string Status { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? CompletedAt { get; init; }
public string? CreatedBy { get; init; }
public ImmutableDictionary<string, string> Metadata { get; init; } =
ImmutableDictionary<string, string>.Empty;
}
/// <summary>
/// Request to create a release.
/// </summary>
public sealed record CreateReleaseRequest
{
public required string ArtifactDigest { get; init; }
public required string Version { get; init; }
public required string TargetEnvironment { get; init; }
public ImmutableDictionary<string, string> Metadata { get; init; } =
ImmutableDictionary<string, string>.Empty;
}
/// <summary>
/// Request to promote a release.
/// </summary>
public sealed record PromoteReleaseRequest
{
public required string TargetEnvironment { get; init; }
public Guid? ApprovalId { get; init; }
}
/// <summary>
/// Request to rollback a release.
/// </summary>
public sealed record RollbackReleaseRequest
{
public required string Reason { get; init; }
public string? TargetVersion { get; init; }
}
/// <summary>
/// Request to cancel a release.
/// </summary>
public sealed record CancelReleaseRequest
{
public required string Reason { get; init; }
}
/// <summary>
/// Result of a rollback operation.
/// </summary>
public sealed record RollbackResult
{
public required Guid RollbackId { get; init; }
public required string PreviousVersion { get; init; }
public required string RolledBackToVersion { get; init; }
public required DateTimeOffset CompletedAt { get; init; }
}
/// <summary>
/// Release state DTO.
/// </summary>
public sealed record ReleaseStateDto
{
public required Guid ReleaseId { get; init; }
public required string CurrentState { get; init; }
public required IReadOnlyList<string> AvailableTransitions { get; init; }
public DateTimeOffset? LastTransitionAt { get; init; }
}
/// <summary>
/// Release history response.
/// </summary>
public sealed record ReleaseHistoryResponse
{
public required Guid ReleaseId { get; init; }
public required IReadOnlyList<ReleaseHistoryEvent> Events { get; init; }
}
/// <summary>
/// A historical event in a release lifecycle.
/// </summary>
public sealed record ReleaseHistoryEvent
{
public required Guid EventId { get; init; }
public required string EventType { get; init; }
public required string FromState { get; init; }
public required string ToState { get; init; }
public required DateTimeOffset Timestamp { get; init; }
public string? Actor { get; init; }
public string? Details { get; init; }
}
#endregion
#region Interfaces (for DI)
/// <summary>
/// Interface for release service.
/// </summary>
public interface IReleaseService
{
Task<(IReadOnlyList<ReleaseDto> Releases, string? NextPageToken, int TotalCount)> ListReleasesAsync(
ReleaseFilter filter, CancellationToken ct);
Task<ReleaseDto?> GetReleaseAsync(Guid releaseId, CancellationToken ct);
Task<ReleaseDto> CreateReleaseAsync(CreateReleaseRequest request, CancellationToken ct);
Task<ReleaseDto> PromoteReleaseAsync(Guid releaseId, string targetEnvironment, Guid? approvalId, CancellationToken ct);
Task<RollbackResult> RollbackReleaseAsync(Guid releaseId, string reason, string? targetVersion, CancellationToken ct);
Task CancelReleaseAsync(Guid releaseId, string reason, CancellationToken ct);
Task<IReadOnlyList<ReleaseHistoryEvent>?> GetReleaseHistoryAsync(Guid releaseId, CancellationToken ct);
}
/// <summary>
/// Interface for release state store.
/// </summary>
public interface IReleaseStateStore
{
Task<ReleaseStateDto?> GetStateAsync(Guid releaseId, CancellationToken ct);
}
#endregion
#region Exceptions
/// <summary>
/// Exception thrown when a release is not found.
/// </summary>
public class ReleaseNotFoundException : Exception
{
public ReleaseNotFoundException(Guid releaseId)
: base($"Release {releaseId} not found") { }
}
/// <summary>
/// Exception thrown when a release state conflict occurs.
/// </summary>
public class ReleaseStateConflictException : Exception
{
public ReleaseStateConflictException(string message) : base(message) { }
}
#endregion

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,533 @@
// -----------------------------------------------------------------------------
// RemediationHub.cs
// Sprint: SPRINT_20260117_031_ReleaseOrchestrator_drift_remediation
// Task: TASK-031-08 - WebSocket Events for Real-Time Remediation Updates
// Description: SignalR hub for broadcasting remediation progress events
// -----------------------------------------------------------------------------
using System.Collections.Concurrent;
using System.Collections.Immutable;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
namespace StellaOps.Api.Hubs;
/// <summary>
/// SignalR hub for real-time remediation updates.
/// </summary>
[Authorize]
public class RemediationHub : Hub<IRemediationHubClient>
{
private static readonly ConcurrentDictionary<string, HashSet<string>> _planSubscriptions = new();
private static readonly ConcurrentDictionary<string, HashSet<string>> _environmentSubscriptions = new();
private readonly ILogger<RemediationHub> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="RemediationHub"/> class.
/// </summary>
public RemediationHub(ILogger<RemediationHub> logger)
{
_logger = logger;
}
/// <summary>
/// Called when a client connects.
/// </summary>
public override async Task OnConnectedAsync()
{
_logger.LogDebug(
"Client {ConnectionId} connected to RemediationHub",
Context.ConnectionId);
await base.OnConnectedAsync();
}
/// <summary>
/// Called when a client disconnects.
/// </summary>
public override async Task OnDisconnectedAsync(Exception? exception)
{
var connectionId = Context.ConnectionId;
// Clean up plan subscriptions
foreach (var planId in _planSubscriptions.Keys)
{
if (_planSubscriptions.TryGetValue(planId, out var connections))
{
connections.Remove(connectionId);
}
}
// Clean up environment subscriptions
foreach (var environment in _environmentSubscriptions.Keys)
{
if (_environmentSubscriptions.TryGetValue(environment, out var connections))
{
connections.Remove(connectionId);
}
}
_logger.LogDebug(
"Client {ConnectionId} disconnected from RemediationHub",
connectionId);
await base.OnDisconnectedAsync(exception);
}
/// <summary>
/// Subscribes to updates for a specific remediation plan.
/// </summary>
/// <param name="planId">The plan ID to subscribe to.</param>
public async Task SubscribeToPlan(string planId)
{
var connectionId = Context.ConnectionId;
var connections = _planSubscriptions.GetOrAdd(planId, _ => new HashSet<string>());
lock (connections)
{
connections.Add(connectionId);
}
await Groups.AddToGroupAsync(connectionId, $"plan:{planId}");
_logger.LogDebug(
"Client {ConnectionId} subscribed to plan {PlanId}",
connectionId, planId);
await Clients.Caller.OnSubscribed(new SubscriptionConfirmation
{
Type = "plan",
Id = planId,
Timestamp = DateTimeOffset.UtcNow
});
}
/// <summary>
/// Unsubscribes from updates for a specific remediation plan.
/// </summary>
/// <param name="planId">The plan ID to unsubscribe from.</param>
public async Task UnsubscribeFromPlan(string planId)
{
var connectionId = Context.ConnectionId;
if (_planSubscriptions.TryGetValue(planId, out var connections))
{
lock (connections)
{
connections.Remove(connectionId);
}
}
await Groups.RemoveFromGroupAsync(connectionId, $"plan:{planId}");
_logger.LogDebug(
"Client {ConnectionId} unsubscribed from plan {PlanId}",
connectionId, planId);
}
/// <summary>
/// Subscribes to updates for all plans in an environment.
/// </summary>
/// <param name="environment">The environment to subscribe to.</param>
public async Task SubscribeToEnvironment(string environment)
{
var connectionId = Context.ConnectionId;
var connections = _environmentSubscriptions.GetOrAdd(environment, _ => new HashSet<string>());
lock (connections)
{
connections.Add(connectionId);
}
await Groups.AddToGroupAsync(connectionId, $"env:{environment}");
_logger.LogDebug(
"Client {ConnectionId} subscribed to environment {Environment}",
connectionId, environment);
await Clients.Caller.OnSubscribed(new SubscriptionConfirmation
{
Type = "environment",
Id = environment,
Timestamp = DateTimeOffset.UtcNow
});
}
/// <summary>
/// Unsubscribes from updates for an environment.
/// </summary>
/// <param name="environment">The environment to unsubscribe from.</param>
public async Task UnsubscribeFromEnvironment(string environment)
{
var connectionId = Context.ConnectionId;
if (_environmentSubscriptions.TryGetValue(environment, out var connections))
{
lock (connections)
{
connections.Remove(connectionId);
}
}
await Groups.RemoveFromGroupAsync(connectionId, $"env:{environment}");
_logger.LogDebug(
"Client {ConnectionId} unsubscribed from environment {Environment}",
connectionId, environment);
}
}
/// <summary>
/// Client interface for RemediationHub.
/// </summary>
public interface IRemediationHubClient
{
/// <summary>Called when subscription is confirmed.</summary>
Task OnSubscribed(SubscriptionConfirmation confirmation);
/// <summary>Called when a plan is created.</summary>
Task OnPlanCreated(PlanCreatedEvent evt);
/// <summary>Called when a plan starts execution.</summary>
Task OnPlanStarted(PlanStartedEvent evt);
/// <summary>Called when plan progress updates.</summary>
Task OnPlanProgress(PlanProgressEvent evt);
/// <summary>Called when a plan completes.</summary>
Task OnPlanCompleted(PlanCompletedEvent evt);
/// <summary>Called when a plan fails.</summary>
Task OnPlanFailed(PlanFailedEvent evt);
/// <summary>Called when a plan is paused.</summary>
Task OnPlanPaused(PlanPausedEvent evt);
/// <summary>Called when a plan is resumed.</summary>
Task OnPlanResumed(PlanResumedEvent evt);
/// <summary>Called when a plan is cancelled.</summary>
Task OnPlanCancelled(PlanCancelledEvent evt);
/// <summary>Called when a batch starts.</summary>
Task OnBatchStarted(BatchStartedEvent evt);
/// <summary>Called when a batch completes.</summary>
Task OnBatchCompleted(BatchCompletedEvent evt);
/// <summary>Called when a target remediation starts.</summary>
Task OnTargetStarted(TargetStartedEvent evt);
/// <summary>Called when a target remediation completes.</summary>
Task OnTargetCompleted(TargetCompletedEvent evt);
/// <summary>Called when a target remediation fails.</summary>
Task OnTargetFailed(TargetFailedEvent evt);
/// <summary>Called when a target is skipped.</summary>
Task OnTargetSkipped(TargetSkippedEvent evt);
}
/// <summary>
/// Service for broadcasting remediation events.
/// </summary>
public interface IRemediationEventBroadcaster
{
Task BroadcastPlanCreatedAsync(PlanCreatedEvent evt, CancellationToken ct = default);
Task BroadcastPlanStartedAsync(PlanStartedEvent evt, CancellationToken ct = default);
Task BroadcastPlanProgressAsync(PlanProgressEvent evt, CancellationToken ct = default);
Task BroadcastPlanCompletedAsync(PlanCompletedEvent evt, CancellationToken ct = default);
Task BroadcastPlanFailedAsync(PlanFailedEvent evt, CancellationToken ct = default);
Task BroadcastPlanPausedAsync(PlanPausedEvent evt, CancellationToken ct = default);
Task BroadcastPlanResumedAsync(PlanResumedEvent evt, CancellationToken ct = default);
Task BroadcastPlanCancelledAsync(PlanCancelledEvent evt, CancellationToken ct = default);
Task BroadcastBatchStartedAsync(BatchStartedEvent evt, CancellationToken ct = default);
Task BroadcastBatchCompletedAsync(BatchCompletedEvent evt, CancellationToken ct = default);
Task BroadcastTargetStartedAsync(TargetStartedEvent evt, CancellationToken ct = default);
Task BroadcastTargetCompletedAsync(TargetCompletedEvent evt, CancellationToken ct = default);
Task BroadcastTargetFailedAsync(TargetFailedEvent evt, CancellationToken ct = default);
Task BroadcastTargetSkippedAsync(TargetSkippedEvent evt, CancellationToken ct = default);
}
/// <summary>
/// Implementation of remediation event broadcaster.
/// </summary>
public sealed class RemediationEventBroadcaster : IRemediationEventBroadcaster
{
private readonly IHubContext<RemediationHub, IRemediationHubClient> _hubContext;
private readonly ILogger<RemediationEventBroadcaster> _logger;
public RemediationEventBroadcaster(
IHubContext<RemediationHub, IRemediationHubClient> hubContext,
ILogger<RemediationEventBroadcaster> logger)
{
_hubContext = hubContext;
_logger = logger;
}
public async Task BroadcastPlanCreatedAsync(PlanCreatedEvent evt, CancellationToken ct = default)
{
_logger.LogDebug("Broadcasting plan.created for {PlanId}", evt.PlanId);
await _hubContext.Clients.Group($"env:{evt.Environment}").OnPlanCreated(evt);
await _hubContext.Clients.Group($"plan:{evt.PlanId}").OnPlanCreated(evt);
}
public async Task BroadcastPlanStartedAsync(PlanStartedEvent evt, CancellationToken ct = default)
{
_logger.LogDebug("Broadcasting plan.started for {PlanId}", evt.PlanId);
await _hubContext.Clients.Group($"env:{evt.Environment}").OnPlanStarted(evt);
await _hubContext.Clients.Group($"plan:{evt.PlanId}").OnPlanStarted(evt);
}
public async Task BroadcastPlanProgressAsync(PlanProgressEvent evt, CancellationToken ct = default)
{
_logger.LogDebug("Broadcasting plan.progress for {PlanId}", evt.PlanId);
await _hubContext.Clients.Group($"plan:{evt.PlanId}").OnPlanProgress(evt);
}
public async Task BroadcastPlanCompletedAsync(PlanCompletedEvent evt, CancellationToken ct = default)
{
_logger.LogDebug("Broadcasting plan.completed for {PlanId}", evt.PlanId);
await _hubContext.Clients.Group($"env:{evt.Environment}").OnPlanCompleted(evt);
await _hubContext.Clients.Group($"plan:{evt.PlanId}").OnPlanCompleted(evt);
}
public async Task BroadcastPlanFailedAsync(PlanFailedEvent evt, CancellationToken ct = default)
{
_logger.LogDebug("Broadcasting plan.failed for {PlanId}", evt.PlanId);
await _hubContext.Clients.Group($"env:{evt.Environment}").OnPlanFailed(evt);
await _hubContext.Clients.Group($"plan:{evt.PlanId}").OnPlanFailed(evt);
}
public async Task BroadcastPlanPausedAsync(PlanPausedEvent evt, CancellationToken ct = default)
{
_logger.LogDebug("Broadcasting plan.paused for {PlanId}", evt.PlanId);
await _hubContext.Clients.Group($"plan:{evt.PlanId}").OnPlanPaused(evt);
}
public async Task BroadcastPlanResumedAsync(PlanResumedEvent evt, CancellationToken ct = default)
{
_logger.LogDebug("Broadcasting plan.resumed for {PlanId}", evt.PlanId);
await _hubContext.Clients.Group($"plan:{evt.PlanId}").OnPlanResumed(evt);
}
public async Task BroadcastPlanCancelledAsync(PlanCancelledEvent evt, CancellationToken ct = default)
{
_logger.LogDebug("Broadcasting plan.cancelled for {PlanId}", evt.PlanId);
await _hubContext.Clients.Group($"env:{evt.Environment}").OnPlanCancelled(evt);
await _hubContext.Clients.Group($"plan:{evt.PlanId}").OnPlanCancelled(evt);
}
public async Task BroadcastBatchStartedAsync(BatchStartedEvent evt, CancellationToken ct = default)
{
_logger.LogDebug("Broadcasting batch.started for plan {PlanId} batch {BatchNumber}", evt.PlanId, evt.BatchNumber);
await _hubContext.Clients.Group($"plan:{evt.PlanId}").OnBatchStarted(evt);
}
public async Task BroadcastBatchCompletedAsync(BatchCompletedEvent evt, CancellationToken ct = default)
{
_logger.LogDebug("Broadcasting batch.completed for plan {PlanId} batch {BatchNumber}", evt.PlanId, evt.BatchNumber);
await _hubContext.Clients.Group($"plan:{evt.PlanId}").OnBatchCompleted(evt);
}
public async Task BroadcastTargetStartedAsync(TargetStartedEvent evt, CancellationToken ct = default)
{
_logger.LogDebug("Broadcasting target.started for {TargetId} in plan {PlanId}", evt.TargetId, evt.PlanId);
await _hubContext.Clients.Group($"plan:{evt.PlanId}").OnTargetStarted(evt);
}
public async Task BroadcastTargetCompletedAsync(TargetCompletedEvent evt, CancellationToken ct = default)
{
_logger.LogDebug("Broadcasting target.completed for {TargetId} in plan {PlanId}", evt.TargetId, evt.PlanId);
await _hubContext.Clients.Group($"plan:{evt.PlanId}").OnTargetCompleted(evt);
}
public async Task BroadcastTargetFailedAsync(TargetFailedEvent evt, CancellationToken ct = default)
{
_logger.LogDebug("Broadcasting target.failed for {TargetId} in plan {PlanId}", evt.TargetId, evt.PlanId);
await _hubContext.Clients.Group($"plan:{evt.PlanId}").OnTargetFailed(evt);
}
public async Task BroadcastTargetSkippedAsync(TargetSkippedEvent evt, CancellationToken ct = default)
{
_logger.LogDebug("Broadcasting target.skipped for {TargetId} in plan {PlanId}", evt.TargetId, evt.PlanId);
await _hubContext.Clients.Group($"plan:{evt.PlanId}").OnTargetSkipped(evt);
}
}
#region Event Models
/// <summary>
/// Subscription confirmation.
/// </summary>
public sealed record SubscriptionConfirmation
{
public required string Type { get; init; }
public required string Id { get; init; }
public required DateTimeOffset Timestamp { get; init; }
}
/// <summary>
/// Base event for remediation events.
/// </summary>
public abstract record RemediationEventBase
{
public required Guid PlanId { get; init; }
public required string Environment { get; init; }
public required DateTimeOffset Timestamp { get; init; }
}
/// <summary>
/// Event when a plan is created.
/// </summary>
public sealed record PlanCreatedEvent : RemediationEventBase
{
public required Guid PolicyId { get; init; }
public required int TotalTargets { get; init; }
public required int TotalBatches { get; init; }
public string? CreatedBy { get; init; }
}
/// <summary>
/// Event when a plan starts execution.
/// </summary>
public sealed record PlanStartedEvent : RemediationEventBase
{
public required int TotalTargets { get; init; }
public required TimeSpan EstimatedDuration { get; init; }
}
/// <summary>
/// Event for plan progress updates.
/// </summary>
public sealed record PlanProgressEvent : RemediationEventBase
{
public required int CompletedTargets { get; init; }
public required int FailedTargets { get; init; }
public required int SkippedTargets { get; init; }
public required int TotalTargets { get; init; }
public required double ProgressPercentage { get; init; }
public required int CurrentBatch { get; init; }
public required int TotalBatches { get; init; }
}
/// <summary>
/// Event when a plan completes successfully.
/// </summary>
public sealed record PlanCompletedEvent : RemediationEventBase
{
public required int SuccessfulTargets { get; init; }
public required int FailedTargets { get; init; }
public required int SkippedTargets { get; init; }
public required TimeSpan Duration { get; init; }
}
/// <summary>
/// Event when a plan fails.
/// </summary>
public sealed record PlanFailedEvent : RemediationEventBase
{
public required string Reason { get; init; }
public required int CompletedTargets { get; init; }
public required int FailedTargets { get; init; }
public string? ErrorDetails { get; init; }
}
/// <summary>
/// Event when a plan is paused.
/// </summary>
public sealed record PlanPausedEvent : RemediationEventBase
{
public required int CompletedTargets { get; init; }
public required int RemainingTargets { get; init; }
public string? PausedBy { get; init; }
}
/// <summary>
/// Event when a plan is resumed.
/// </summary>
public sealed record PlanResumedEvent : RemediationEventBase
{
public required int RemainingTargets { get; init; }
public string? ResumedBy { get; init; }
}
/// <summary>
/// Event when a plan is cancelled.
/// </summary>
public sealed record PlanCancelledEvent : RemediationEventBase
{
public required string Reason { get; init; }
public required int CompletedTargets { get; init; }
public required int CancelledTargets { get; init; }
public string? CancelledBy { get; init; }
}
/// <summary>
/// Event when a batch starts.
/// </summary>
public sealed record BatchStartedEvent : RemediationEventBase
{
public required int BatchNumber { get; init; }
public required int TargetCount { get; init; }
}
/// <summary>
/// Event when a batch completes.
/// </summary>
public sealed record BatchCompletedEvent : RemediationEventBase
{
public required int BatchNumber { get; init; }
public required int SuccessfulTargets { get; init; }
public required int FailedTargets { get; init; }
public required TimeSpan Duration { get; init; }
}
/// <summary>
/// Event when a target remediation starts.
/// </summary>
public sealed record TargetStartedEvent : RemediationEventBase
{
public required string TargetId { get; init; }
public required string TargetType { get; init; }
public required string Action { get; init; }
public required int BatchNumber { get; init; }
}
/// <summary>
/// Event when a target remediation completes.
/// </summary>
public sealed record TargetCompletedEvent : RemediationEventBase
{
public required string TargetId { get; init; }
public required string TargetType { get; init; }
public required string Action { get; init; }
public required TimeSpan Duration { get; init; }
public ImmutableDictionary<string, string> Details { get; init; } =
ImmutableDictionary<string, string>.Empty;
}
/// <summary>
/// Event when a target remediation fails.
/// </summary>
public sealed record TargetFailedEvent : RemediationEventBase
{
public required string TargetId { get; init; }
public required string TargetType { get; init; }
public required string Action { get; init; }
public required string ErrorMessage { get; init; }
public string? ErrorCode { get; init; }
public bool IsRetryable { get; init; }
}
/// <summary>
/// Event when a target is skipped.
/// </summary>
public sealed record TargetSkippedEvent : RemediationEventBase
{
public required string TargetId { get; init; }
public required string TargetType { get; init; }
public required string Reason { get; init; }
}
#endregion