release orchestration strengthening
This commit is contained in:
542
src/Api/StellaOps.Api/Controllers/EnvironmentsController.cs
Normal file
542
src/Api/StellaOps.Api/Controllers/EnvironmentsController.cs
Normal 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
|
||||
422
src/Api/StellaOps.Api/Controllers/GatesController.cs
Normal file
422
src/Api/StellaOps.Api/Controllers/GatesController.cs
Normal 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
|
||||
484
src/Api/StellaOps.Api/Controllers/ObservabilityController.cs
Normal file
484
src/Api/StellaOps.Api/Controllers/ObservabilityController.cs
Normal 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
|
||||
501
src/Api/StellaOps.Api/Controllers/ReleasesController.cs
Normal file
501
src/Api/StellaOps.Api/Controllers/ReleasesController.cs
Normal 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
|
||||
1061
src/Api/StellaOps.Api/Controllers/RemediationController.cs
Normal file
1061
src/Api/StellaOps.Api/Controllers/RemediationController.cs
Normal file
File diff suppressed because it is too large
Load Diff
1178
src/Api/StellaOps.Api/Controllers/WorkflowVisualizationController.cs
Normal file
1178
src/Api/StellaOps.Api/Controllers/WorkflowVisualizationController.cs
Normal file
File diff suppressed because it is too large
Load Diff
533
src/Api/StellaOps.Api/Hubs/RemediationHub.cs
Normal file
533
src/Api/StellaOps.Api/Hubs/RemediationHub.cs
Normal 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
|
||||
Reference in New Issue
Block a user