// ----------------------------------------------------------------------------- // 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; /// /// Controller for environment management endpoints. /// [ApiController] [Route("v1/environments")] [Authorize] public class EnvironmentsController : ControllerBase { private readonly IEnvironmentService _environmentService; private readonly ILogger _logger; /// /// Initializes a new instance of the class. /// public EnvironmentsController( IEnvironmentService environmentService, ILogger logger) { _environmentService = environmentService; _logger = logger; } /// /// Lists all configured environments. /// /// Cancellation token. /// List of environments. [HttpGet] [ProducesResponseType(typeof(ListEnvironmentsResponse), StatusCodes.Status200OK)] public async Task ListEnvironments(CancellationToken ct) { _logger.LogDebug("Listing environments"); var environments = await _environmentService.ListEnvironmentsAsync(ct); return Ok(new ListEnvironmentsResponse { Environments = environments }); } /// /// Gets a specific environment by name. /// /// The environment name. /// Cancellation token. /// The environment details. [HttpGet("{environmentName}")] [ProducesResponseType(typeof(EnvironmentDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task 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); } /// /// Creates a new environment. /// /// The environment creation request. /// Cancellation token. /// The created environment. [HttpPost] [ProducesResponseType(typeof(EnvironmentDto), StatusCodes.Status201Created)] [ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)] public async Task 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 }); } } /// /// Updates an existing environment. /// /// The environment name. /// The environment update request. /// Cancellation token. /// The updated environment. [HttpPut("{environmentName}")] [ProducesResponseType(typeof(EnvironmentDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task 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 }); } } /// /// Deletes an environment. /// /// The environment name. /// Cancellation token. /// No content on success. [HttpDelete("{environmentName}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)] public async Task 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 }); } } /// /// Gets the health status of an environment. /// /// The environment name. /// Cancellation token. /// The environment health. [HttpGet("{environmentName}/health")] [ProducesResponseType(typeof(EnvironmentHealthDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task 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); } /// /// Gets the current deployments in an environment. /// /// The environment name. /// Cancellation token. /// The current deployments. [HttpGet("{environmentName}/deployments")] [ProducesResponseType(typeof(ListDeploymentsResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task 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 }); } /// /// Gets the promotion path for an environment. /// /// The environment name. /// Cancellation token. /// The promotion path. [HttpGet("{environmentName}/promotion-path")] [ProducesResponseType(typeof(PromotionPathDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task 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); } /// /// Locks an environment to prevent deployments. /// /// The environment name. /// The lock request. /// Cancellation token. /// The lock result. [HttpPost("{environmentName}/lock")] [ProducesResponseType(typeof(EnvironmentLockDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task 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 }); } } /// /// Unlocks an environment. /// /// The environment name. /// Cancellation token. /// No content on success. [HttpDelete("{environmentName}/lock")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task 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 /// /// Response for listing environments. /// public sealed record ListEnvironmentsResponse { public required IReadOnlyList Environments { get; init; } } /// /// Environment data transfer object. /// 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 Labels { get; init; } = ImmutableDictionary.Empty; public required DateTimeOffset CreatedAt { get; init; } } /// /// Request to create an environment. /// 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 Labels { get; init; } = ImmutableDictionary.Empty; } /// /// Request to update an environment. /// 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? Labels { get; init; } } /// /// Environment health DTO. /// 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 Components { get; init; } public required DateTimeOffset CheckedAt { get; init; } } /// /// Component health DTO. /// 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; } } /// /// Response for listing deployments. /// public sealed record ListDeploymentsResponse { public required IReadOnlyList Deployments { get; init; } } /// /// Deployment DTO. /// 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; } } /// /// Promotion path DTO. /// public sealed record PromotionPathDto { public required string CurrentEnvironment { get; init; } public required IReadOnlyList PrecedingEnvironments { get; init; } public required IReadOnlyList FollowingEnvironments { get; init; } public required IReadOnlyList Steps { get; init; } } /// /// Promotion step DTO. /// 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 RequiredGates { get; init; } } /// /// Request to lock an environment. /// public sealed record LockEnvironmentRequest { public required string Reason { get; init; } public DateTimeOffset? ExpiresAt { get; init; } } /// /// Environment lock DTO. /// 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 /// /// Interface for environment service. /// public interface IEnvironmentService { Task> ListEnvironmentsAsync(CancellationToken ct); Task GetEnvironmentAsync(string name, CancellationToken ct); Task CreateEnvironmentAsync(CreateEnvironmentRequest request, CancellationToken ct); Task UpdateEnvironmentAsync(string name, UpdateEnvironmentRequest request, CancellationToken ct); Task DeleteEnvironmentAsync(string name, CancellationToken ct); Task GetEnvironmentHealthAsync(string name, CancellationToken ct); Task?> GetDeploymentsAsync(string name, CancellationToken ct); Task GetPromotionPathAsync(string name, CancellationToken ct); Task LockEnvironmentAsync(string name, string reason, DateTimeOffset? expiresAt, CancellationToken ct); Task UnlockEnvironmentAsync(string name, CancellationToken ct); } #endregion #region Exceptions /// /// Exception thrown when an environment is not found. /// public class EnvironmentNotFoundException : Exception { public EnvironmentNotFoundException(string name) : base($"Environment '{name}' not found") { } } /// /// Exception thrown when an environment already exists. /// public class EnvironmentAlreadyExistsException : Exception { public EnvironmentAlreadyExistsException(string name) : base($"Environment '{name}' already exists") { } } /// /// Exception thrown when an environment is in use. /// public class EnvironmentInUseException : Exception { public EnvironmentInUseException(string name) : base($"Environment '{name}' is in use") { } } #endregion