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