Files
git.stella-ops.org/src/Api/StellaOps.Api/Controllers/EnvironmentsController.cs
2026-01-17 21:32:08 +02:00

543 lines
19 KiB
C#

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