543 lines
19 KiB
C#
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
|