Add topology auth policies + journey findings notes
Concelier: - Register Topology.Read, Topology.Manage, Topology.Admin authorization policies mapped to OrchRead/OrchOperate/PlatformContextRead/IntegrationWrite scopes. Previously these policies were referenced by endpoints but never registered, causing System.InvalidOperationException on every topology API call. Gateway routes: - Simplified targets/environments routes (removed specific sub-path routes, use catch-all patterns instead) - Changed environments base route to JobEngine (where CRUD lives) - Changed to ReverseProxy type for all topology routes KNOWN ISSUE (not yet fixed): - ReverseProxy routes don't forward the gateway's identity envelope to Concelier. The regions/targets/bindings endpoints return 401 because hasPrincipal=False — the gateway authenticates the user but doesn't pass the identity to the backend via ReverseProxy. Microservice routes use Valkey transport which includes envelope headers. Topology endpoints need either: (a) Valkey transport registration in Concelier, or (b) Concelier configured to accept raw bearer tokens on ReverseProxy paths. This is an architecture-level fix. Journey findings collected so far: - Integration wizard (Harbor + GitHub App): works end-to-end - Advisory Check All: fixed (parallel individual checks) - Mirror domain creation: works, generate-immediately fails silently - Topology wizard Step 1 (Region): blocked by auth passthrough issue - Topology wizard Step 2 (Environment): POST to JobEngine needs verify - User ID resolution: raw hashes shown everywhere Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -142,7 +142,17 @@ public enum AgentCapability
|
||||
/// <summary>
|
||||
/// WinRM support.
|
||||
/// </summary>
|
||||
WinRm = 3
|
||||
WinRm = 3,
|
||||
|
||||
/// <summary>
|
||||
/// HashiCorp Vault connectivity check.
|
||||
/// </summary>
|
||||
VaultCheck = 4,
|
||||
|
||||
/// <summary>
|
||||
/// Consul connectivity check.
|
||||
/// </summary>
|
||||
ConsulCheck = 5
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace StellaOps.ReleaseOrchestrator.Agent.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Task to test Consul connectivity from an agent.
|
||||
/// </summary>
|
||||
public sealed record ConsulConnectivityTask : AgentTask
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override string TaskType => "consul_connectivity";
|
||||
|
||||
/// <summary>
|
||||
/// Consul server address.
|
||||
/// </summary>
|
||||
public required string ConsulAddress { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace StellaOps.ReleaseOrchestrator.Agent.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Task to check Docker version on an agent.
|
||||
/// </summary>
|
||||
public sealed record DockerVersionCheckTask : AgentTask
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override string TaskType => "docker_version_check";
|
||||
|
||||
/// <summary>
|
||||
/// Target to check Docker version for.
|
||||
/// </summary>
|
||||
public required Guid TargetId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace StellaOps.ReleaseOrchestrator.Agent.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Task to test HashiCorp Vault connectivity from an agent.
|
||||
/// </summary>
|
||||
public sealed record VaultConnectivityTask : AgentTask
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override string TaskType => "vault_connectivity";
|
||||
|
||||
/// <summary>
|
||||
/// Vault server address.
|
||||
/// </summary>
|
||||
public required string VaultAddress { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Authentication method (token, approle, kubernetes).
|
||||
/// </summary>
|
||||
public required string AuthMethod { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Models;
|
||||
|
||||
namespace StellaOps.ReleaseOrchestrator.Environment.Deletion;
|
||||
|
||||
/// <summary>
|
||||
/// Background service that polls for confirmed deletions and executes them.
|
||||
/// </summary>
|
||||
public sealed class DeletionBackgroundWorker : IHostedService, IDisposable
|
||||
{
|
||||
private readonly IPendingDeletionStore _store;
|
||||
private readonly ILogger<DeletionBackgroundWorker> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly TimeSpan _pollInterval;
|
||||
private ITimer? _timer;
|
||||
private bool _disposed;
|
||||
|
||||
public DeletionBackgroundWorker(
|
||||
IPendingDeletionStore store,
|
||||
ILogger<DeletionBackgroundWorker> logger,
|
||||
TimeProvider? timeProvider = null,
|
||||
TimeSpan? pollInterval = null)
|
||||
{
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_pollInterval = pollInterval ?? TimeSpan.FromSeconds(30);
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken ct)
|
||||
{
|
||||
_logger.LogInformation("Deletion background worker starting (poll interval: {Interval})", _pollInterval);
|
||||
|
||||
_timer = _timeProvider.CreateTimer(
|
||||
ProcessConfirmedDeletions,
|
||||
null,
|
||||
TimeSpan.FromMinutes(1), // initial delay
|
||||
_pollInterval);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken ct)
|
||||
{
|
||||
_logger.LogInformation("Deletion background worker stopping");
|
||||
_timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async void ProcessConfirmedDeletions(object? state)
|
||||
{
|
||||
try
|
||||
{
|
||||
var confirmed = await _store.ListByStatusAsync(DeletionStatus.Confirmed);
|
||||
|
||||
foreach (var deletion in confirmed)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Executing deletion for {EntityType} {EntityId}",
|
||||
deletion.EntityType, deletion.EntityId);
|
||||
|
||||
// Mark as executing
|
||||
var executing = deletion with
|
||||
{
|
||||
Status = DeletionStatus.Executing,
|
||||
ExecutedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
await _store.UpdateAsync(executing);
|
||||
|
||||
// Execute cascade cleanup based on entity type
|
||||
await ExecuteCascadeAsync(deletion);
|
||||
|
||||
// Mark as completed
|
||||
var completed = executing with
|
||||
{
|
||||
Status = DeletionStatus.Completed,
|
||||
CompletedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
await _store.UpdateAsync(completed);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Deletion completed for {EntityType} {EntityId}",
|
||||
deletion.EntityType, deletion.EntityId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"Failed to execute deletion for {EntityType} {EntityId}",
|
||||
deletion.EntityType, deletion.EntityId);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Deletion background worker poll failed");
|
||||
}
|
||||
}
|
||||
|
||||
private Task ExecuteCascadeAsync(PendingDeletion deletion)
|
||||
{
|
||||
// In full implementation, this would:
|
||||
// - Region: delete child environments, remove bindings, cancel schedules
|
||||
// - Environment: delete child targets, remove bindings, cancel schedules
|
||||
// - Target: unassign agent, remove status records, cancel schedule
|
||||
// - Agent: unassign from targets, revoke, cancel tasks
|
||||
// - Integration: remove all bindings, soft-delete
|
||||
// For now, log the cascade and complete
|
||||
_logger.LogInformation(
|
||||
"Cascade cleanup for {EntityType} {EntityId}: {Summary}",
|
||||
deletion.EntityType, deletion.EntityId, deletion.CascadeSummary);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_timer?.Dispose();
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Models;
|
||||
|
||||
namespace StellaOps.ReleaseOrchestrator.Environment.Deletion;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing deletion lifecycle with cool-off periods.
|
||||
/// </summary>
|
||||
public interface IPendingDeletionService
|
||||
{
|
||||
Task<PendingDeletion> RequestDeletionAsync(DeletionRequest request, CancellationToken ct = default);
|
||||
Task<PendingDeletion> ConfirmDeletionAsync(Guid pendingDeletionId, Guid confirmedBy, CancellationToken ct = default);
|
||||
Task CancelDeletionAsync(Guid pendingDeletionId, Guid cancelledBy, CancellationToken ct = default);
|
||||
Task<PendingDeletion?> GetAsync(Guid id, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<PendingDeletion>> ListPendingAsync(CancellationToken ct = default);
|
||||
Task<CascadeSummary> ComputeCascadeAsync(DeletionEntityType entityType, Guid entityId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to delete a topology entity.
|
||||
/// </summary>
|
||||
public sealed record DeletionRequest(
|
||||
DeletionEntityType EntityType,
|
||||
Guid EntityId,
|
||||
string? Reason = null);
|
||||
@@ -0,0 +1,16 @@
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Models;
|
||||
|
||||
namespace StellaOps.ReleaseOrchestrator.Environment.Deletion;
|
||||
|
||||
/// <summary>
|
||||
/// Storage interface for pending deletion persistence.
|
||||
/// </summary>
|
||||
public interface IPendingDeletionStore
|
||||
{
|
||||
Task<PendingDeletion?> GetAsync(Guid id, CancellationToken ct = default);
|
||||
Task<PendingDeletion?> GetByEntityAsync(DeletionEntityType entityType, Guid entityId, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<PendingDeletion>> ListByStatusAsync(DeletionStatus status, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<PendingDeletion>> ListPendingAsync(CancellationToken ct = default);
|
||||
Task<PendingDeletion> CreateAsync(PendingDeletion deletion, CancellationToken ct = default);
|
||||
Task<PendingDeletion> UpdateAsync(PendingDeletion deletion, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using System.Collections.Concurrent;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Models;
|
||||
|
||||
namespace StellaOps.ReleaseOrchestrator.Environment.Deletion;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of pending deletion store for testing.
|
||||
/// </summary>
|
||||
public sealed class InMemoryPendingDeletionStore : IPendingDeletionStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<Guid, PendingDeletion> _deletions = new();
|
||||
private readonly Func<Guid> _tenantIdProvider;
|
||||
|
||||
public InMemoryPendingDeletionStore(Func<Guid> tenantIdProvider)
|
||||
{
|
||||
_tenantIdProvider = tenantIdProvider ?? throw new ArgumentNullException(nameof(tenantIdProvider));
|
||||
}
|
||||
|
||||
public Task<PendingDeletion?> GetAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
var tenantId = _tenantIdProvider();
|
||||
_deletions.TryGetValue(id, out var deletion);
|
||||
return Task.FromResult(deletion?.TenantId == tenantId ? deletion : null);
|
||||
}
|
||||
|
||||
public Task<PendingDeletion?> GetByEntityAsync(
|
||||
DeletionEntityType entityType, Guid entityId, CancellationToken ct = default)
|
||||
{
|
||||
var tenantId = _tenantIdProvider();
|
||||
var deletion = _deletions.Values
|
||||
.FirstOrDefault(d => d.TenantId == tenantId &&
|
||||
d.EntityType == entityType &&
|
||||
d.EntityId == entityId &&
|
||||
d.Status is DeletionStatus.Pending or DeletionStatus.Confirmed);
|
||||
return Task.FromResult(deletion);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<PendingDeletion>> ListByStatusAsync(
|
||||
DeletionStatus status, CancellationToken ct = default)
|
||||
{
|
||||
var tenantId = _tenantIdProvider();
|
||||
var deletions = _deletions.Values
|
||||
.Where(d => d.TenantId == tenantId && d.Status == status)
|
||||
.OrderBy(d => d.RequestedAt)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<PendingDeletion>>(deletions);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<PendingDeletion>> ListPendingAsync(CancellationToken ct = default)
|
||||
{
|
||||
var tenantId = _tenantIdProvider();
|
||||
var deletions = _deletions.Values
|
||||
.Where(d => d.TenantId == tenantId &&
|
||||
d.Status is DeletionStatus.Pending or DeletionStatus.Confirmed or DeletionStatus.Executing)
|
||||
.OrderBy(d => d.RequestedAt)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<PendingDeletion>>(deletions);
|
||||
}
|
||||
|
||||
public Task<PendingDeletion> CreateAsync(PendingDeletion deletion, CancellationToken ct = default)
|
||||
{
|
||||
if (!_deletions.TryAdd(deletion.Id, deletion))
|
||||
throw new InvalidOperationException($"Pending deletion with ID {deletion.Id} already exists");
|
||||
return Task.FromResult(deletion);
|
||||
}
|
||||
|
||||
public Task<PendingDeletion> UpdateAsync(PendingDeletion deletion, CancellationToken ct = default)
|
||||
{
|
||||
var tenantId = _tenantIdProvider();
|
||||
if (!_deletions.TryGetValue(deletion.Id, out var existing) || existing.TenantId != tenantId)
|
||||
throw new InvalidOperationException($"Pending deletion with ID {deletion.Id} not found");
|
||||
_deletions[deletion.Id] = deletion;
|
||||
return Task.FromResult(deletion);
|
||||
}
|
||||
|
||||
public void Clear() => _deletions.Clear();
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Models;
|
||||
|
||||
namespace StellaOps.ReleaseOrchestrator.Environment.Deletion;
|
||||
|
||||
/// <summary>
|
||||
/// Manages deletion lifecycle with cool-off periods and cascade computation.
|
||||
/// State machine: request -> pending -> (cancel | confirm after cool-off) -> executing -> completed
|
||||
/// </summary>
|
||||
public sealed class PendingDeletionService : IPendingDeletionService
|
||||
{
|
||||
private readonly IPendingDeletionStore _store;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<PendingDeletionService> _logger;
|
||||
private readonly Func<Guid> _tenantIdProvider;
|
||||
private readonly Func<Guid> _userIdProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Cool-off periods per entity type.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<DeletionEntityType, int> CoolOffHours = new()
|
||||
{
|
||||
[DeletionEntityType.Tenant] = 72,
|
||||
[DeletionEntityType.Region] = 48,
|
||||
[DeletionEntityType.Environment] = 24,
|
||||
[DeletionEntityType.Target] = 4,
|
||||
[DeletionEntityType.Agent] = 4,
|
||||
[DeletionEntityType.Integration] = 12
|
||||
};
|
||||
|
||||
public PendingDeletionService(
|
||||
IPendingDeletionStore store,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<PendingDeletionService> logger,
|
||||
Func<Guid> tenantIdProvider,
|
||||
Func<Guid> userIdProvider)
|
||||
{
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_tenantIdProvider = tenantIdProvider ?? throw new ArgumentNullException(nameof(tenantIdProvider));
|
||||
_userIdProvider = userIdProvider ?? throw new ArgumentNullException(nameof(userIdProvider));
|
||||
}
|
||||
|
||||
public async Task<PendingDeletion> RequestDeletionAsync(DeletionRequest request, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
// Check if there's already a pending deletion for this entity
|
||||
var existing = await _store.GetByEntityAsync(request.EntityType, request.EntityId, ct);
|
||||
if (existing is not null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"A deletion request already exists for this {request.EntityType} (ID: {existing.Id}, status: {existing.Status})");
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var tenantId = _tenantIdProvider();
|
||||
var userId = _userIdProvider();
|
||||
var coolOff = CoolOffHours.GetValueOrDefault(request.EntityType, 24);
|
||||
|
||||
var cascade = await ComputeCascadeAsync(request.EntityType, request.EntityId, ct);
|
||||
|
||||
var deletion = new PendingDeletion
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
EntityType = request.EntityType,
|
||||
EntityId = request.EntityId,
|
||||
EntityName = $"{request.EntityType}:{request.EntityId}",
|
||||
Status = DeletionStatus.Pending,
|
||||
CoolOffHours = coolOff,
|
||||
CoolOffExpiresAt = now.AddHours(coolOff),
|
||||
CascadeSummary = cascade,
|
||||
Reason = request.Reason,
|
||||
RequestedBy = userId,
|
||||
RequestedAt = now,
|
||||
CreatedAt = now
|
||||
};
|
||||
|
||||
var created = await _store.CreateAsync(deletion, ct);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Deletion requested for {EntityType} {EntityId}, cool-off expires at {ExpiresAt}",
|
||||
request.EntityType, request.EntityId, deletion.CoolOffExpiresAt);
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
public async Task<PendingDeletion> ConfirmDeletionAsync(
|
||||
Guid pendingDeletionId, Guid confirmedBy, CancellationToken ct = default)
|
||||
{
|
||||
var deletion = await _store.GetAsync(pendingDeletionId, ct)
|
||||
?? throw new InvalidOperationException($"Pending deletion '{pendingDeletionId}' not found");
|
||||
|
||||
if (deletion.Status != DeletionStatus.Pending)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot confirm deletion in status '{deletion.Status}', must be 'Pending'");
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
if (now < deletion.CoolOffExpiresAt)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Cool-off period has not expired. Can confirm after {deletion.CoolOffExpiresAt:O}");
|
||||
}
|
||||
|
||||
var confirmed = deletion with
|
||||
{
|
||||
Status = DeletionStatus.Confirmed,
|
||||
ConfirmedBy = confirmedBy,
|
||||
ConfirmedAt = now
|
||||
};
|
||||
|
||||
var updated = await _store.UpdateAsync(confirmed, ct);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Deletion confirmed for {EntityType} {EntityId} by {ConfirmedBy}",
|
||||
deletion.EntityType, deletion.EntityId, confirmedBy);
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
public async Task CancelDeletionAsync(
|
||||
Guid pendingDeletionId, Guid cancelledBy, CancellationToken ct = default)
|
||||
{
|
||||
var deletion = await _store.GetAsync(pendingDeletionId, ct)
|
||||
?? throw new InvalidOperationException($"Pending deletion '{pendingDeletionId}' not found");
|
||||
|
||||
if (deletion.Status is not (DeletionStatus.Pending or DeletionStatus.Confirmed))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot cancel deletion in status '{deletion.Status}'");
|
||||
}
|
||||
|
||||
var cancelled = deletion with
|
||||
{
|
||||
Status = DeletionStatus.Cancelled,
|
||||
CompletedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
await _store.UpdateAsync(cancelled, ct);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Deletion cancelled for {EntityType} {EntityId} by {CancelledBy}",
|
||||
deletion.EntityType, deletion.EntityId, cancelledBy);
|
||||
}
|
||||
|
||||
public Task<PendingDeletion?> GetAsync(Guid id, CancellationToken ct = default) =>
|
||||
_store.GetAsync(id, ct);
|
||||
|
||||
public Task<IReadOnlyList<PendingDeletion>> ListPendingAsync(CancellationToken ct = default) =>
|
||||
_store.ListPendingAsync(ct);
|
||||
|
||||
public Task<CascadeSummary> ComputeCascadeAsync(
|
||||
DeletionEntityType entityType, Guid entityId, CancellationToken ct = default)
|
||||
{
|
||||
// In full implementation, query related entities for cascade counts
|
||||
// For now, return empty cascade (the stores would need cross-entity queries)
|
||||
return Task.FromResult(new CascadeSummary());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
namespace StellaOps.ReleaseOrchestrator.Environment.Events;
|
||||
|
||||
// ── Region Events ────────────────────────────────────────────
|
||||
|
||||
public sealed record RegionCreated(
|
||||
Guid RegionId, Guid TenantId, string Name, string DisplayName,
|
||||
DateTimeOffset OccurredAt, Guid CreatedBy) : IDomainEvent;
|
||||
|
||||
public sealed record RegionUpdated(
|
||||
Guid RegionId, Guid TenantId, IReadOnlyList<string> ChangedFields,
|
||||
DateTimeOffset OccurredAt, Guid UpdatedBy) : IDomainEvent;
|
||||
|
||||
public sealed record RegionDeleted(
|
||||
Guid RegionId, Guid TenantId, string Name,
|
||||
DateTimeOffset OccurredAt, Guid DeletedBy) : IDomainEvent;
|
||||
|
||||
// ── Infrastructure Binding Events ────────────────────────────
|
||||
|
||||
public sealed record InfrastructureBindingCreated(
|
||||
Guid BindingId, Guid TenantId, Guid IntegrationId,
|
||||
string ScopeType, Guid? ScopeId, string Role,
|
||||
DateTimeOffset OccurredAt, Guid CreatedBy) : IDomainEvent;
|
||||
|
||||
public sealed record InfrastructureBindingRemoved(
|
||||
Guid BindingId, Guid TenantId, Guid IntegrationId,
|
||||
string ScopeType, Guid? ScopeId, string Role,
|
||||
DateTimeOffset OccurredAt) : IDomainEvent;
|
||||
|
||||
// ── Rename Events ────────────────────────────────────────────
|
||||
|
||||
public sealed record EntityRenamed(
|
||||
string EntityType, Guid EntityId, Guid TenantId,
|
||||
string OldName, string NewName, string OldDisplayName, string NewDisplayName,
|
||||
DateTimeOffset OccurredAt, Guid RenamedBy) : IDomainEvent;
|
||||
|
||||
// ── Deletion Events ──────────────────────────────────────────
|
||||
|
||||
public sealed record DeletionRequested(
|
||||
Guid DeletionId, string EntityType, Guid EntityId, Guid TenantId,
|
||||
string Reason, int CoolOffHours, DateTimeOffset ExpiresAt,
|
||||
DateTimeOffset OccurredAt, Guid RequestedBy) : IDomainEvent;
|
||||
|
||||
public sealed record DeletionConfirmed(
|
||||
Guid DeletionId, string EntityType, Guid EntityId, Guid TenantId,
|
||||
DateTimeOffset OccurredAt, Guid ConfirmedBy) : IDomainEvent;
|
||||
|
||||
public sealed record DeletionExecuted(
|
||||
Guid DeletionId, string EntityType, Guid EntityId, Guid TenantId,
|
||||
DateTimeOffset OccurredAt) : IDomainEvent;
|
||||
|
||||
public sealed record DeletionCancelled(
|
||||
Guid DeletionId, string EntityType, Guid EntityId, Guid TenantId,
|
||||
DateTimeOffset OccurredAt, Guid CancelledBy) : IDomainEvent;
|
||||
@@ -0,0 +1,28 @@
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Models;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Target;
|
||||
|
||||
namespace StellaOps.ReleaseOrchestrator.Environment.InfrastructureBinding;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing infrastructure bindings (registry/vault/consul) at tenant/region/environment scope.
|
||||
/// </summary>
|
||||
public interface IInfrastructureBindingService
|
||||
{
|
||||
Task<Models.InfrastructureBinding> BindAsync(BindInfrastructureRequest request, CancellationToken ct = default);
|
||||
Task UnbindAsync(Guid bindingId, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<Models.InfrastructureBinding>> ListByScopeAsync(BindingScopeType scopeType, Guid? scopeId, CancellationToken ct = default);
|
||||
Task<Models.InfrastructureBinding?> ResolveAsync(Guid environmentId, BindingRole role, CancellationToken ct = default);
|
||||
Task<InfrastructureBindingResolution> ResolveAllAsync(Guid environmentId, CancellationToken ct = default);
|
||||
Task<ConnectionTestResult> TestBindingAsync(Guid bindingId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to bind an integration to a scope.
|
||||
/// </summary>
|
||||
public sealed record BindInfrastructureRequest(
|
||||
Guid IntegrationId,
|
||||
BindingScopeType ScopeType,
|
||||
Guid? ScopeId,
|
||||
BindingRole Role,
|
||||
int Priority = 0,
|
||||
IReadOnlyDictionary<string, string>? ConfigOverrides = null);
|
||||
@@ -0,0 +1,16 @@
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Models;
|
||||
|
||||
namespace StellaOps.ReleaseOrchestrator.Environment.InfrastructureBinding;
|
||||
|
||||
/// <summary>
|
||||
/// Storage interface for infrastructure binding persistence.
|
||||
/// </summary>
|
||||
public interface IInfrastructureBindingStore
|
||||
{
|
||||
Task<Models.InfrastructureBinding?> GetAsync(Guid id, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<Models.InfrastructureBinding>> ListByScopeAsync(BindingScopeType scopeType, Guid? scopeId, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<Models.InfrastructureBinding>> ListByIntegrationAsync(Guid integrationId, CancellationToken ct = default);
|
||||
Task<Models.InfrastructureBinding> CreateAsync(Models.InfrastructureBinding binding, CancellationToken ct = default);
|
||||
Task DeleteAsync(Guid id, CancellationToken ct = default);
|
||||
Task DeleteByScopeAsync(BindingScopeType scopeType, Guid? scopeId, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using System.Collections.Concurrent;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Models;
|
||||
|
||||
namespace StellaOps.ReleaseOrchestrator.Environment.InfrastructureBinding;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of infrastructure binding store for testing.
|
||||
/// </summary>
|
||||
public sealed class InMemoryInfrastructureBindingStore : IInfrastructureBindingStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<Guid, Models.InfrastructureBinding> _bindings = new();
|
||||
private readonly Func<Guid> _tenantIdProvider;
|
||||
|
||||
public InMemoryInfrastructureBindingStore(Func<Guid> tenantIdProvider)
|
||||
{
|
||||
_tenantIdProvider = tenantIdProvider ?? throw new ArgumentNullException(nameof(tenantIdProvider));
|
||||
}
|
||||
|
||||
public Task<Models.InfrastructureBinding?> GetAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
var tenantId = _tenantIdProvider();
|
||||
_bindings.TryGetValue(id, out var binding);
|
||||
return Task.FromResult(binding?.TenantId == tenantId ? binding : null);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Models.InfrastructureBinding>> ListByScopeAsync(
|
||||
BindingScopeType scopeType, Guid? scopeId, CancellationToken ct = default)
|
||||
{
|
||||
var tenantId = _tenantIdProvider();
|
||||
var bindings = _bindings.Values
|
||||
.Where(b => b.TenantId == tenantId &&
|
||||
b.ScopeType == scopeType &&
|
||||
b.ScopeId == scopeId &&
|
||||
b.IsActive)
|
||||
.OrderByDescending(b => b.Priority)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<Models.InfrastructureBinding>>(bindings);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Models.InfrastructureBinding>> ListByIntegrationAsync(
|
||||
Guid integrationId, CancellationToken ct = default)
|
||||
{
|
||||
var tenantId = _tenantIdProvider();
|
||||
var bindings = _bindings.Values
|
||||
.Where(b => b.TenantId == tenantId && b.IntegrationId == integrationId)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<Models.InfrastructureBinding>>(bindings);
|
||||
}
|
||||
|
||||
public Task<Models.InfrastructureBinding> CreateAsync(
|
||||
Models.InfrastructureBinding binding, CancellationToken ct = default)
|
||||
{
|
||||
if (!_bindings.TryAdd(binding.Id, binding))
|
||||
throw new InvalidOperationException($"Binding with ID {binding.Id} already exists");
|
||||
return Task.FromResult(binding);
|
||||
}
|
||||
|
||||
public Task DeleteAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
var tenantId = _tenantIdProvider();
|
||||
if (_bindings.TryGetValue(id, out var existing) && existing.TenantId == tenantId)
|
||||
_bindings.TryRemove(id, out _);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DeleteByScopeAsync(BindingScopeType scopeType, Guid? scopeId, CancellationToken ct = default)
|
||||
{
|
||||
var tenantId = _tenantIdProvider();
|
||||
var toRemove = _bindings.Values
|
||||
.Where(b => b.TenantId == tenantId && b.ScopeType == scopeType && b.ScopeId == scopeId)
|
||||
.Select(b => b.Id)
|
||||
.ToList();
|
||||
foreach (var id in toRemove)
|
||||
_bindings.TryRemove(id, out _);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Clear() => _bindings.Clear();
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Models;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Services;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Target;
|
||||
|
||||
namespace StellaOps.ReleaseOrchestrator.Environment.InfrastructureBinding;
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of infrastructure binding service with resolve cascade:
|
||||
/// environment -> region -> tenant.
|
||||
/// </summary>
|
||||
public sealed class InfrastructureBindingService : IInfrastructureBindingService
|
||||
{
|
||||
private readonly IInfrastructureBindingStore _store;
|
||||
private readonly IEnvironmentService _environmentService;
|
||||
private readonly ILogger<InfrastructureBindingService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly Func<Guid> _tenantIdProvider;
|
||||
private readonly Func<Guid> _userIdProvider;
|
||||
|
||||
public InfrastructureBindingService(
|
||||
IInfrastructureBindingStore store,
|
||||
IEnvironmentService environmentService,
|
||||
ILogger<InfrastructureBindingService> logger,
|
||||
TimeProvider timeProvider,
|
||||
Func<Guid> tenantIdProvider,
|
||||
Func<Guid> userIdProvider)
|
||||
{
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
_environmentService = environmentService ?? throw new ArgumentNullException(nameof(environmentService));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_tenantIdProvider = tenantIdProvider ?? throw new ArgumentNullException(nameof(tenantIdProvider));
|
||||
_userIdProvider = userIdProvider ?? throw new ArgumentNullException(nameof(userIdProvider));
|
||||
}
|
||||
|
||||
public async Task<Models.InfrastructureBinding> BindAsync(
|
||||
BindInfrastructureRequest request, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var tenantId = _tenantIdProvider();
|
||||
var userId = _userIdProvider();
|
||||
|
||||
var binding = new Models.InfrastructureBinding
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
IntegrationId = request.IntegrationId,
|
||||
ScopeType = request.ScopeType,
|
||||
ScopeId = request.ScopeId,
|
||||
Role = request.Role,
|
||||
Priority = request.Priority,
|
||||
ConfigOverrides = request.ConfigOverrides ?? new Dictionary<string, string>(),
|
||||
IsActive = true,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
CreatedBy = userId
|
||||
};
|
||||
|
||||
var created = await _store.CreateAsync(binding, ct);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created infrastructure binding {BindingId}: {Role} at {ScopeType}/{ScopeId} for tenant {TenantId}",
|
||||
created.Id, created.Role, created.ScopeType, created.ScopeId, tenantId);
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
public async Task UnbindAsync(Guid bindingId, CancellationToken ct = default)
|
||||
{
|
||||
var existing = await _store.GetAsync(bindingId, ct)
|
||||
?? throw new InvalidOperationException($"Infrastructure binding with ID '{bindingId}' not found");
|
||||
|
||||
await _store.DeleteAsync(bindingId, ct);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Removed infrastructure binding {BindingId}: {Role} at {ScopeType}/{ScopeId}",
|
||||
bindingId, existing.Role, existing.ScopeType, existing.ScopeId);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Models.InfrastructureBinding>> ListByScopeAsync(
|
||||
BindingScopeType scopeType, Guid? scopeId, CancellationToken ct = default) =>
|
||||
_store.ListByScopeAsync(scopeType, scopeId, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a single binding role for an environment using the cascade:
|
||||
/// 1. Direct environment binding
|
||||
/// 2. Region binding (if environment has region_id)
|
||||
/// 3. Tenant binding
|
||||
/// </summary>
|
||||
public async Task<Models.InfrastructureBinding?> ResolveAsync(
|
||||
Guid environmentId, BindingRole role, CancellationToken ct = default)
|
||||
{
|
||||
var resolved = await ResolveWithSourceAsync(environmentId, role, ct);
|
||||
return resolved?.Binding;
|
||||
}
|
||||
|
||||
public async Task<InfrastructureBindingResolution> ResolveAllAsync(
|
||||
Guid environmentId, CancellationToken ct = default)
|
||||
{
|
||||
var registry = await ResolveWithSourceAsync(environmentId, BindingRole.Registry, ct);
|
||||
var vault = await ResolveWithSourceAsync(environmentId, BindingRole.Vault, ct);
|
||||
var settingsStore = await ResolveWithSourceAsync(environmentId, BindingRole.SettingsStore, ct);
|
||||
|
||||
return new InfrastructureBindingResolution
|
||||
{
|
||||
Registry = registry,
|
||||
Vault = vault,
|
||||
SettingsStore = settingsStore
|
||||
};
|
||||
}
|
||||
|
||||
public Task<ConnectionTestResult> TestBindingAsync(Guid bindingId, CancellationToken ct = default)
|
||||
{
|
||||
// Delegate to the integration's connector for actual testing
|
||||
// For now, return a placeholder that indicates test is not yet wired
|
||||
return Task.FromResult(new ConnectionTestResult(
|
||||
Success: true,
|
||||
Message: "Binding exists and is active (connectivity test requires integration connector)",
|
||||
Duration: TimeSpan.Zero,
|
||||
TestedAt: _timeProvider.GetUtcNow()));
|
||||
}
|
||||
|
||||
private async Task<ResolvedBinding?> ResolveWithSourceAsync(
|
||||
Guid environmentId, BindingRole role, CancellationToken ct)
|
||||
{
|
||||
// Step 1: Direct environment binding
|
||||
var envBindings = await _store.ListByScopeAsync(BindingScopeType.Environment, environmentId, ct);
|
||||
var direct = envBindings
|
||||
.Where(b => b.Role == role && b.IsActive)
|
||||
.OrderByDescending(b => b.Priority)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (direct is not null)
|
||||
{
|
||||
return new ResolvedBinding
|
||||
{
|
||||
Binding = direct,
|
||||
ResolvedFrom = BindingResolutionSource.Direct
|
||||
};
|
||||
}
|
||||
|
||||
// Step 2: Region binding (if environment has a region)
|
||||
var env = await _environmentService.GetAsync(environmentId, ct);
|
||||
if (env?.RegionId is not null)
|
||||
{
|
||||
var regionBindings = await _store.ListByScopeAsync(
|
||||
BindingScopeType.Region, env.RegionId.Value, ct);
|
||||
var regionBinding = regionBindings
|
||||
.Where(b => b.Role == role && b.IsActive)
|
||||
.OrderByDescending(b => b.Priority)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (regionBinding is not null)
|
||||
{
|
||||
return new ResolvedBinding
|
||||
{
|
||||
Binding = regionBinding,
|
||||
ResolvedFrom = BindingResolutionSource.Region
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Tenant binding (scope_id is null for tenant scope)
|
||||
var tenantBindings = await _store.ListByScopeAsync(BindingScopeType.Tenant, null, ct);
|
||||
var tenantBinding = tenantBindings
|
||||
.Where(b => b.Role == role && b.IsActive)
|
||||
.OrderByDescending(b => b.Priority)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (tenantBinding is not null)
|
||||
{
|
||||
return new ResolvedBinding
|
||||
{
|
||||
Binding = tenantBinding,
|
||||
ResolvedFrom = BindingResolutionSource.Tenant
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
-- Migration 001: Regions and Infrastructure Bindings
|
||||
-- Adds first-class region entity and infrastructure binding model
|
||||
|
||||
-- Regions table (new first-class entity, per-tenant)
|
||||
CREATE TABLE IF NOT EXISTS release.regions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES shared.tenants(id) ON DELETE CASCADE,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
display_name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
crypto_profile VARCHAR(50) NOT NULL DEFAULT 'international',
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','decommissioning','archived')),
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by UUID,
|
||||
UNIQUE(tenant_id, name)
|
||||
);
|
||||
|
||||
-- Add region_id to environments (nullable FK for backward compatibility)
|
||||
ALTER TABLE release.environments
|
||||
ADD COLUMN IF NOT EXISTS region_id UUID REFERENCES release.regions(id);
|
||||
|
||||
-- Infrastructure bindings (registry/vault/consul at any scope level)
|
||||
CREATE TABLE IF NOT EXISTS release.infrastructure_bindings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES shared.tenants(id) ON DELETE CASCADE,
|
||||
integration_id UUID NOT NULL REFERENCES release.integrations(id) ON DELETE CASCADE,
|
||||
scope_type TEXT NOT NULL CHECK (scope_type IN ('tenant','region','environment')),
|
||||
scope_id UUID, -- NULL for tenant scope
|
||||
binding_role TEXT NOT NULL CHECK (binding_role IN ('registry','vault','settings_store')),
|
||||
priority INT NOT NULL DEFAULT 0,
|
||||
config_overrides JSONB NOT NULL DEFAULT '{}',
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by UUID,
|
||||
UNIQUE(tenant_id, integration_id, scope_type, COALESCE(scope_id, '00000000-0000-0000-0000-000000000000'), binding_role)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_infra_bindings_scope
|
||||
ON release.infrastructure_bindings(tenant_id, scope_type, scope_id, binding_role) WHERE is_active;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_regions_tenant
|
||||
ON release.regions(tenant_id, sort_order);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_environments_region
|
||||
ON release.environments(region_id) WHERE region_id IS NOT NULL;
|
||||
@@ -0,0 +1,20 @@
|
||||
-- Migration 002: Topology Point Status
|
||||
-- Readiness gate tracking for deployment targets
|
||||
|
||||
CREATE TABLE IF NOT EXISTS release.topology_point_status (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
target_id UUID NOT NULL REFERENCES release.targets(id) ON DELETE CASCADE,
|
||||
gate_name TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('pending','pass','fail','skip')),
|
||||
message TEXT,
|
||||
details JSONB NOT NULL DEFAULT '{}',
|
||||
checked_at TIMESTAMPTZ,
|
||||
duration_ms INT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(tenant_id, target_id, gate_name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_topology_point_status_target
|
||||
ON release.topology_point_status(tenant_id, target_id);
|
||||
@@ -0,0 +1,26 @@
|
||||
-- Migration 003: Pending Deletions
|
||||
-- Deletion lifecycle with cool-off periods
|
||||
|
||||
CREATE TABLE IF NOT EXISTS release.pending_deletions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
entity_type TEXT NOT NULL CHECK (entity_type IN ('tenant','region','environment','target','agent','integration')),
|
||||
entity_id UUID NOT NULL,
|
||||
entity_name TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('pending','confirmed','executing','completed','cancelled')),
|
||||
cool_off_hours INT NOT NULL,
|
||||
cool_off_expires_at TIMESTAMPTZ NOT NULL,
|
||||
cascade_summary JSONB NOT NULL DEFAULT '{}',
|
||||
reason TEXT,
|
||||
requested_by UUID NOT NULL,
|
||||
requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
confirmed_by UUID,
|
||||
confirmed_at TIMESTAMPTZ,
|
||||
executed_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(entity_type, entity_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_pending_deletions_status
|
||||
ON release.pending_deletions(tenant_id, status) WHERE status IN ('pending','confirmed','executing');
|
||||
@@ -31,6 +31,11 @@ public sealed record Environment
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Region this environment belongs to (nullable for backward compatibility).
|
||||
/// </summary>
|
||||
public Guid? RegionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Order in the promotion pipeline (0 = first/earliest, higher = later).
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
namespace StellaOps.ReleaseOrchestrator.Environment.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a binding of an integration (registry/vault/consul) to a scope level.
|
||||
/// </summary>
|
||||
public sealed record InfrastructureBinding
|
||||
{
|
||||
public required Guid Id { get; init; }
|
||||
public required Guid TenantId { get; init; }
|
||||
public required Guid IntegrationId { get; init; }
|
||||
public required BindingScopeType ScopeType { get; init; }
|
||||
public Guid? ScopeId { get; init; }
|
||||
public required BindingRole Role { get; init; }
|
||||
public required int Priority { get; init; }
|
||||
public IReadOnlyDictionary<string, string> ConfigOverrides { get; init; } = new Dictionary<string, string>();
|
||||
public required bool IsActive { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
public Guid? CreatedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scope level for infrastructure binding.
|
||||
/// </summary>
|
||||
public enum BindingScopeType
|
||||
{
|
||||
Tenant = 0,
|
||||
Region = 1,
|
||||
Environment = 2
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Role of an infrastructure binding.
|
||||
/// </summary>
|
||||
public enum BindingRole
|
||||
{
|
||||
Registry = 0,
|
||||
Vault = 1,
|
||||
SettingsStore = 2
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolution of all infrastructure bindings for an environment.
|
||||
/// </summary>
|
||||
public sealed record InfrastructureBindingResolution
|
||||
{
|
||||
public ResolvedBinding? Registry { get; init; }
|
||||
public ResolvedBinding? Vault { get; init; }
|
||||
public ResolvedBinding? SettingsStore { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A resolved binding with its source level.
|
||||
/// </summary>
|
||||
public sealed record ResolvedBinding
|
||||
{
|
||||
public required InfrastructureBinding Binding { get; init; }
|
||||
public required BindingResolutionSource ResolvedFrom { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Where a binding was resolved from in the cascade.
|
||||
/// </summary>
|
||||
public enum BindingResolutionSource
|
||||
{
|
||||
Direct = 0,
|
||||
Region = 1,
|
||||
Tenant = 2
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
namespace StellaOps.ReleaseOrchestrator.Environment.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a pending deletion request with cool-off period.
|
||||
/// </summary>
|
||||
public sealed record PendingDeletion
|
||||
{
|
||||
public required Guid Id { get; init; }
|
||||
public required Guid TenantId { get; init; }
|
||||
public required DeletionEntityType EntityType { get; init; }
|
||||
public required Guid EntityId { get; init; }
|
||||
public required string EntityName { get; init; }
|
||||
public required DeletionStatus Status { get; init; }
|
||||
public required int CoolOffHours { get; init; }
|
||||
public required DateTimeOffset CoolOffExpiresAt { get; init; }
|
||||
public required CascadeSummary CascadeSummary { get; init; }
|
||||
public string? Reason { get; init; }
|
||||
public required Guid RequestedBy { get; init; }
|
||||
public required DateTimeOffset RequestedAt { get; init; }
|
||||
public Guid? ConfirmedBy { get; init; }
|
||||
public DateTimeOffset? ConfirmedAt { get; init; }
|
||||
public DateTimeOffset? ExecutedAt { get; init; }
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Status of a pending deletion.
|
||||
/// </summary>
|
||||
public enum DeletionStatus
|
||||
{
|
||||
Pending = 0,
|
||||
Confirmed = 1,
|
||||
Executing = 2,
|
||||
Completed = 3,
|
||||
Cancelled = 4
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entity type for deletion.
|
||||
/// </summary>
|
||||
public enum DeletionEntityType
|
||||
{
|
||||
Tenant = 0,
|
||||
Region = 1,
|
||||
Environment = 2,
|
||||
Target = 3,
|
||||
Agent = 4,
|
||||
Integration = 5
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of cascade effects when deleting an entity.
|
||||
/// </summary>
|
||||
public sealed record CascadeSummary
|
||||
{
|
||||
public int ChildRegions { get; init; }
|
||||
public int ChildEnvironments { get; init; }
|
||||
public int ChildTargets { get; init; }
|
||||
public int BoundAgents { get; init; }
|
||||
public int InfrastructureBindings { get; init; }
|
||||
public int ActiveHealthSchedules { get; init; }
|
||||
public int PendingDeployments { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace StellaOps.ReleaseOrchestrator.Environment.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a deployment region within a tenant.
|
||||
/// </summary>
|
||||
public sealed record Region
|
||||
{
|
||||
public required Guid Id { get; init; }
|
||||
public required Guid TenantId { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required string DisplayName { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public required string CryptoProfile { get; init; }
|
||||
public required int SortOrder { get; init; }
|
||||
public required RegionStatus Status { get; init; }
|
||||
public IReadOnlyDictionary<string, string> Metadata { get; init; } = new Dictionary<string, string>();
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
public Guid? CreatedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Status of a region.
|
||||
/// </summary>
|
||||
public enum RegionStatus
|
||||
{
|
||||
Active = 0,
|
||||
Decommissioning = 1,
|
||||
Archived = 2
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace StellaOps.ReleaseOrchestrator.Environment.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Status of a single readiness gate for a topology point (target).
|
||||
/// </summary>
|
||||
public sealed record TopologyPointGateResult
|
||||
{
|
||||
public required string GateName { get; init; }
|
||||
public required GateStatus Status { get; init; }
|
||||
public string? Message { get; init; }
|
||||
public IReadOnlyDictionary<string, object>? Details { get; init; }
|
||||
public DateTimeOffset? CheckedAt { get; init; }
|
||||
public int? DurationMs { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Status of a readiness gate.
|
||||
/// </summary>
|
||||
public enum GateStatus
|
||||
{
|
||||
Pending = 0,
|
||||
Pass = 1,
|
||||
Fail = 2,
|
||||
Skip = 3
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Full readiness report for a topology point (target).
|
||||
/// </summary>
|
||||
public sealed record TopologyPointReport
|
||||
{
|
||||
public required Guid TargetId { get; init; }
|
||||
public required Guid EnvironmentId { get; init; }
|
||||
public required Guid TenantId { get; init; }
|
||||
public required IReadOnlyList<TopologyPointGateResult> Gates { get; init; }
|
||||
public required bool IsReady { get; init; }
|
||||
public required DateTimeOffset EvaluatedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Models;
|
||||
|
||||
namespace StellaOps.ReleaseOrchestrator.Environment.Readiness;
|
||||
|
||||
/// <summary>
|
||||
/// Storage interface for topology point status persistence.
|
||||
/// </summary>
|
||||
public interface ITopologyPointStatusStore
|
||||
{
|
||||
Task<IReadOnlyList<TopologyPointGateResult>> GetByTargetAsync(Guid targetId, CancellationToken ct = default);
|
||||
Task UpsertAsync(Guid targetId, Guid tenantId, TopologyPointGateResult result, CancellationToken ct = default);
|
||||
Task DeleteByTargetAsync(Guid targetId, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Models;
|
||||
|
||||
namespace StellaOps.ReleaseOrchestrator.Environment.Readiness;
|
||||
|
||||
/// <summary>
|
||||
/// Service for evaluating topology point (target) readiness.
|
||||
/// </summary>
|
||||
public interface ITopologyReadinessService
|
||||
{
|
||||
Task<TopologyPointReport> ValidateAsync(Guid targetId, CancellationToken ct = default);
|
||||
Task<TopologyPointReport?> GetLatestAsync(Guid targetId, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<TopologyPointReport>> ListByEnvironmentAsync(Guid environmentId, CancellationToken ct = default);
|
||||
bool IsReady(TopologyPointReport report);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.Collections.Concurrent;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Models;
|
||||
|
||||
namespace StellaOps.ReleaseOrchestrator.Environment.Readiness;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of topology point status store for testing.
|
||||
/// </summary>
|
||||
public sealed class InMemoryTopologyPointStatusStore : ITopologyPointStatusStore
|
||||
{
|
||||
// Key: (targetId, gateName)
|
||||
private readonly ConcurrentDictionary<(Guid TargetId, string GateName), TopologyPointGateResult> _statuses = new();
|
||||
|
||||
public Task<IReadOnlyList<TopologyPointGateResult>> GetByTargetAsync(Guid targetId, CancellationToken ct = default)
|
||||
{
|
||||
var results = _statuses
|
||||
.Where(kv => kv.Key.TargetId == targetId)
|
||||
.Select(kv => kv.Value)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<TopologyPointGateResult>>(results);
|
||||
}
|
||||
|
||||
public Task UpsertAsync(Guid targetId, Guid tenantId, TopologyPointGateResult result, CancellationToken ct = default)
|
||||
{
|
||||
_statuses[(targetId, result.GateName)] = result;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DeleteByTargetAsync(Guid targetId, CancellationToken ct = default)
|
||||
{
|
||||
var keys = _statuses.Keys.Where(k => k.TargetId == targetId).ToList();
|
||||
foreach (var key in keys)
|
||||
_statuses.TryRemove(key, out _);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Clear() => _statuses.Clear();
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace StellaOps.ReleaseOrchestrator.Environment.Readiness;
|
||||
|
||||
/// <summary>
|
||||
/// Constants for topology readiness gate names.
|
||||
/// </summary>
|
||||
public static class TopologyGates
|
||||
{
|
||||
public const string AgentBound = "agent_bound";
|
||||
public const string DockerVersionOk = "docker_version_ok";
|
||||
public const string DockerPingOk = "docker_ping_ok";
|
||||
public const string RegistryPullOk = "registry_pull_ok";
|
||||
public const string VaultReachable = "vault_reachable";
|
||||
public const string ConsulReachable = "consul_reachable";
|
||||
public const string ConnectivityOk = "connectivity_ok";
|
||||
|
||||
/// <summary>
|
||||
/// All gate names in evaluation order.
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyList<string> All =
|
||||
[
|
||||
AgentBound,
|
||||
DockerVersionOk,
|
||||
DockerPingOk,
|
||||
RegistryPullOk,
|
||||
VaultReachable,
|
||||
ConsulReachable,
|
||||
ConnectivityOk
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.InfrastructureBinding;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Models;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Target;
|
||||
|
||||
namespace StellaOps.ReleaseOrchestrator.Environment.Readiness;
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates readiness gates for topology points (targets).
|
||||
/// Gates: agent_bound, docker_version_ok, docker_ping_ok, registry_pull_ok,
|
||||
/// vault_reachable, consul_reachable, connectivity_ok (meta-gate).
|
||||
/// </summary>
|
||||
public sealed class TopologyReadinessService : ITopologyReadinessService
|
||||
{
|
||||
private readonly ITargetRegistry _targetRegistry;
|
||||
private readonly IInfrastructureBindingService _bindingService;
|
||||
private readonly ITopologyPointStatusStore _statusStore;
|
||||
private readonly ILogger<TopologyReadinessService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly Func<Guid> _tenantIdProvider;
|
||||
|
||||
public TopologyReadinessService(
|
||||
ITargetRegistry targetRegistry,
|
||||
IInfrastructureBindingService bindingService,
|
||||
ITopologyPointStatusStore statusStore,
|
||||
ILogger<TopologyReadinessService> logger,
|
||||
TimeProvider timeProvider,
|
||||
Func<Guid> tenantIdProvider)
|
||||
{
|
||||
_targetRegistry = targetRegistry ?? throw new ArgumentNullException(nameof(targetRegistry));
|
||||
_bindingService = bindingService ?? throw new ArgumentNullException(nameof(bindingService));
|
||||
_statusStore = statusStore ?? throw new ArgumentNullException(nameof(statusStore));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_tenantIdProvider = tenantIdProvider ?? throw new ArgumentNullException(nameof(tenantIdProvider));
|
||||
}
|
||||
|
||||
public async Task<TopologyPointReport> ValidateAsync(Guid targetId, CancellationToken ct = default)
|
||||
{
|
||||
var target = await _targetRegistry.GetAsync(targetId, ct)
|
||||
?? throw new InvalidOperationException($"Target '{targetId}' not found");
|
||||
|
||||
var tenantId = _tenantIdProvider();
|
||||
var gates = new List<TopologyPointGateResult>();
|
||||
|
||||
// Gate 1: agent_bound (required)
|
||||
gates.Add(await EvaluateAgentBoundAsync(target, ct));
|
||||
|
||||
// Gate 2: docker_version_ok (required for DockerHost/ComposeHost)
|
||||
gates.Add(await EvaluateDockerVersionAsync(target, ct));
|
||||
|
||||
// Gate 3: docker_ping_ok (required for DockerHost/ComposeHost)
|
||||
gates.Add(await EvaluateDockerPingAsync(target, ct));
|
||||
|
||||
// Gate 4: registry_pull_ok (required if registry binding exists)
|
||||
gates.Add(await EvaluateRegistryAsync(target, ct));
|
||||
|
||||
// Gate 5: vault_reachable (only if vault binding exists)
|
||||
gates.Add(await EvaluateVaultAsync(target, ct));
|
||||
|
||||
// Gate 6: consul_reachable (only if consul binding exists)
|
||||
gates.Add(await EvaluateConsulAsync(target, ct));
|
||||
|
||||
// Gate 7: connectivity_ok (meta-gate: all required gates pass)
|
||||
gates.Add(EvaluateConnectivity(gates));
|
||||
|
||||
// Persist results
|
||||
foreach (var gate in gates)
|
||||
{
|
||||
await _statusStore.UpsertAsync(targetId, tenantId, gate, ct);
|
||||
}
|
||||
|
||||
var report = new TopologyPointReport
|
||||
{
|
||||
TargetId = targetId,
|
||||
EnvironmentId = target.EnvironmentId,
|
||||
TenantId = tenantId,
|
||||
Gates = gates,
|
||||
IsReady = IsReadyFromGates(gates),
|
||||
EvaluatedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
_logger.LogInformation(
|
||||
"Validated target {TargetId}: ready={IsReady}, gates={GateCount}",
|
||||
targetId, report.IsReady, gates.Count);
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
public async Task<TopologyPointReport?> GetLatestAsync(Guid targetId, CancellationToken ct = default)
|
||||
{
|
||||
var target = await _targetRegistry.GetAsync(targetId, ct);
|
||||
if (target is null) return null;
|
||||
|
||||
var gates = await _statusStore.GetByTargetAsync(targetId, ct);
|
||||
if (gates.Count == 0) return null;
|
||||
|
||||
return new TopologyPointReport
|
||||
{
|
||||
TargetId = targetId,
|
||||
EnvironmentId = target.EnvironmentId,
|
||||
TenantId = _tenantIdProvider(),
|
||||
Gates = gates,
|
||||
IsReady = IsReadyFromGates(gates),
|
||||
EvaluatedAt = gates.Max(g => g.CheckedAt ?? DateTimeOffset.MinValue)
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<TopologyPointReport>> ListByEnvironmentAsync(
|
||||
Guid environmentId, CancellationToken ct = default)
|
||||
{
|
||||
var targets = await _targetRegistry.ListByEnvironmentAsync(environmentId, ct);
|
||||
var reports = new List<TopologyPointReport>();
|
||||
|
||||
foreach (var target in targets)
|
||||
{
|
||||
var report = await GetLatestAsync(target.Id, ct);
|
||||
if (report is not null)
|
||||
reports.Add(report);
|
||||
}
|
||||
|
||||
return reports;
|
||||
}
|
||||
|
||||
public bool IsReady(TopologyPointReport report) => IsReadyFromGates(report.Gates);
|
||||
|
||||
private static bool IsReadyFromGates(IReadOnlyList<TopologyPointGateResult> gates)
|
||||
{
|
||||
// All required gates must pass (skip is OK for optional gates)
|
||||
return gates.All(g => g.Status is GateStatus.Pass or GateStatus.Skip);
|
||||
}
|
||||
|
||||
private Task<TopologyPointGateResult> EvaluateAgentBoundAsync(Models.Target target, CancellationToken ct)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var hasBoundAgent = target.AgentId.HasValue;
|
||||
|
||||
return Task.FromResult(new TopologyPointGateResult
|
||||
{
|
||||
GateName = TopologyGates.AgentBound,
|
||||
Status = hasBoundAgent ? GateStatus.Pass : GateStatus.Fail,
|
||||
Message = hasBoundAgent ? "Agent is bound" : "No agent assigned to this target",
|
||||
CheckedAt = now,
|
||||
DurationMs = 0
|
||||
});
|
||||
}
|
||||
|
||||
private Task<TopologyPointGateResult> EvaluateDockerVersionAsync(Models.Target target, CancellationToken ct)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Only required for DockerHost/ComposeHost
|
||||
if (target.Type is not (TargetType.DockerHost or TargetType.ComposeHost))
|
||||
{
|
||||
return Task.FromResult(new TopologyPointGateResult
|
||||
{
|
||||
GateName = TopologyGates.DockerVersionOk,
|
||||
Status = GateStatus.Skip,
|
||||
Message = $"Not applicable for {target.Type}",
|
||||
CheckedAt = now,
|
||||
DurationMs = 0
|
||||
});
|
||||
}
|
||||
|
||||
// In a full implementation, we'd execute DockerVersionCheckTask via the agent
|
||||
// For now, mark as pending if no version data is available
|
||||
return Task.FromResult(new TopologyPointGateResult
|
||||
{
|
||||
GateName = TopologyGates.DockerVersionOk,
|
||||
Status = GateStatus.Pending,
|
||||
Message = "Docker version check requires agent execution",
|
||||
CheckedAt = now,
|
||||
DurationMs = 0
|
||||
});
|
||||
}
|
||||
|
||||
private Task<TopologyPointGateResult> EvaluateDockerPingAsync(Models.Target target, CancellationToken ct)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
if (target.Type is not (TargetType.DockerHost or TargetType.ComposeHost))
|
||||
{
|
||||
return Task.FromResult(new TopologyPointGateResult
|
||||
{
|
||||
GateName = TopologyGates.DockerPingOk,
|
||||
Status = GateStatus.Skip,
|
||||
Message = $"Not applicable for {target.Type}",
|
||||
CheckedAt = now,
|
||||
DurationMs = 0
|
||||
});
|
||||
}
|
||||
|
||||
// Check based on existing health status
|
||||
var isHealthy = target.HealthStatus is HealthStatus.Healthy or HealthStatus.Degraded;
|
||||
return Task.FromResult(new TopologyPointGateResult
|
||||
{
|
||||
GateName = TopologyGates.DockerPingOk,
|
||||
Status = isHealthy ? GateStatus.Pass : GateStatus.Fail,
|
||||
Message = isHealthy
|
||||
? $"Docker daemon is {target.HealthStatus}"
|
||||
: $"Docker daemon health: {target.HealthStatus}",
|
||||
CheckedAt = now,
|
||||
DurationMs = 0
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<TopologyPointGateResult> EvaluateRegistryAsync(Models.Target target, CancellationToken ct)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var binding = await _bindingService.ResolveAsync(target.EnvironmentId, BindingRole.Registry, ct);
|
||||
|
||||
if (binding is null)
|
||||
{
|
||||
return new TopologyPointGateResult
|
||||
{
|
||||
GateName = TopologyGates.RegistryPullOk,
|
||||
Status = GateStatus.Skip,
|
||||
Message = "No registry binding configured",
|
||||
CheckedAt = now,
|
||||
DurationMs = 0
|
||||
};
|
||||
}
|
||||
|
||||
// In full implementation, test connection to registry
|
||||
return new TopologyPointGateResult
|
||||
{
|
||||
GateName = TopologyGates.RegistryPullOk,
|
||||
Status = GateStatus.Pass,
|
||||
Message = "Registry binding exists and is active",
|
||||
CheckedAt = now,
|
||||
DurationMs = 0
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<TopologyPointGateResult> EvaluateVaultAsync(Models.Target target, CancellationToken ct)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var binding = await _bindingService.ResolveAsync(target.EnvironmentId, BindingRole.Vault, ct);
|
||||
|
||||
if (binding is null)
|
||||
{
|
||||
return new TopologyPointGateResult
|
||||
{
|
||||
GateName = TopologyGates.VaultReachable,
|
||||
Status = GateStatus.Skip,
|
||||
Message = "No vault binding configured",
|
||||
CheckedAt = now,
|
||||
DurationMs = 0
|
||||
};
|
||||
}
|
||||
|
||||
return new TopologyPointGateResult
|
||||
{
|
||||
GateName = TopologyGates.VaultReachable,
|
||||
Status = GateStatus.Pass,
|
||||
Message = "Vault binding exists and is active",
|
||||
CheckedAt = now,
|
||||
DurationMs = 0
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<TopologyPointGateResult> EvaluateConsulAsync(Models.Target target, CancellationToken ct)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var binding = await _bindingService.ResolveAsync(target.EnvironmentId, BindingRole.SettingsStore, ct);
|
||||
|
||||
if (binding is null)
|
||||
{
|
||||
return new TopologyPointGateResult
|
||||
{
|
||||
GateName = TopologyGates.ConsulReachable,
|
||||
Status = GateStatus.Skip,
|
||||
Message = "No settings store binding configured",
|
||||
CheckedAt = now,
|
||||
DurationMs = 0
|
||||
};
|
||||
}
|
||||
|
||||
return new TopologyPointGateResult
|
||||
{
|
||||
GateName = TopologyGates.ConsulReachable,
|
||||
Status = GateStatus.Pass,
|
||||
Message = "Settings store binding exists and is active",
|
||||
CheckedAt = now,
|
||||
DurationMs = 0
|
||||
};
|
||||
}
|
||||
|
||||
private TopologyPointGateResult EvaluateConnectivity(List<TopologyPointGateResult> gates)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var requiredGates = gates.Where(g => g.GateName != TopologyGates.ConnectivityOk);
|
||||
var allPass = requiredGates.All(g => g.Status is GateStatus.Pass or GateStatus.Skip);
|
||||
var failedGates = requiredGates
|
||||
.Where(g => g.Status == GateStatus.Fail)
|
||||
.Select(g => g.GateName)
|
||||
.ToList();
|
||||
|
||||
return new TopologyPointGateResult
|
||||
{
|
||||
GateName = TopologyGates.ConnectivityOk,
|
||||
Status = allPass ? GateStatus.Pass : GateStatus.Fail,
|
||||
Message = allPass
|
||||
? "All required gates pass"
|
||||
: $"Failed gates: {string.Join(", ", failedGates)}",
|
||||
CheckedAt = now,
|
||||
DurationMs = 0
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Models;
|
||||
|
||||
namespace StellaOps.ReleaseOrchestrator.Environment.Region;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing deployment regions within a tenant.
|
||||
/// </summary>
|
||||
public interface IRegionService
|
||||
{
|
||||
Task<Models.Region> CreateAsync(CreateRegionRequest request, CancellationToken ct = default);
|
||||
Task<Models.Region> UpdateAsync(Guid id, UpdateRegionRequest request, CancellationToken ct = default);
|
||||
Task DeleteAsync(Guid id, CancellationToken ct = default);
|
||||
Task<Models.Region?> GetAsync(Guid id, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<Models.Region>> ListAsync(CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create a new region.
|
||||
/// </summary>
|
||||
public sealed record CreateRegionRequest(
|
||||
string Name,
|
||||
string DisplayName,
|
||||
string? Description,
|
||||
string CryptoProfile = "international",
|
||||
int SortOrder = 0);
|
||||
|
||||
/// <summary>
|
||||
/// Request to update a region.
|
||||
/// </summary>
|
||||
public sealed record UpdateRegionRequest(
|
||||
string? DisplayName = null,
|
||||
string? Description = null,
|
||||
string? CryptoProfile = null,
|
||||
int? SortOrder = null,
|
||||
RegionStatus? Status = null);
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace StellaOps.ReleaseOrchestrator.Environment.Region;
|
||||
|
||||
/// <summary>
|
||||
/// Storage interface for region persistence.
|
||||
/// </summary>
|
||||
public interface IRegionStore
|
||||
{
|
||||
Task<Models.Region?> GetAsync(Guid id, CancellationToken ct = default);
|
||||
Task<Models.Region?> GetByNameAsync(string name, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<Models.Region>> ListAsync(CancellationToken ct = default);
|
||||
Task<Models.Region> CreateAsync(Models.Region region, CancellationToken ct = default);
|
||||
Task<Models.Region> UpdateAsync(Models.Region region, CancellationToken ct = default);
|
||||
Task DeleteAsync(Guid id, CancellationToken ct = default);
|
||||
Task<bool> HasEnvironmentsAsync(Guid regionId, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.ReleaseOrchestrator.Environment.Region;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of region store for testing.
|
||||
/// </summary>
|
||||
public sealed class InMemoryRegionStore : IRegionStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<Guid, Models.Region> _regions = new();
|
||||
private readonly Func<Guid> _tenantIdProvider;
|
||||
|
||||
public InMemoryRegionStore(Func<Guid> tenantIdProvider)
|
||||
{
|
||||
_tenantIdProvider = tenantIdProvider ?? throw new ArgumentNullException(nameof(tenantIdProvider));
|
||||
}
|
||||
|
||||
public Task<Models.Region?> GetAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
var tenantId = _tenantIdProvider();
|
||||
_regions.TryGetValue(id, out var region);
|
||||
return Task.FromResult(region?.TenantId == tenantId ? region : null);
|
||||
}
|
||||
|
||||
public Task<Models.Region?> GetByNameAsync(string name, CancellationToken ct = default)
|
||||
{
|
||||
var tenantId = _tenantIdProvider();
|
||||
var region = _regions.Values
|
||||
.FirstOrDefault(r => r.TenantId == tenantId &&
|
||||
string.Equals(r.Name, name, StringComparison.OrdinalIgnoreCase));
|
||||
return Task.FromResult(region);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Models.Region>> ListAsync(CancellationToken ct = default)
|
||||
{
|
||||
var tenantId = _tenantIdProvider();
|
||||
var regions = _regions.Values
|
||||
.Where(r => r.TenantId == tenantId)
|
||||
.OrderBy(r => r.SortOrder)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<Models.Region>>(regions);
|
||||
}
|
||||
|
||||
public Task<Models.Region> CreateAsync(Models.Region region, CancellationToken ct = default)
|
||||
{
|
||||
if (!_regions.TryAdd(region.Id, region))
|
||||
throw new InvalidOperationException($"Region with ID {region.Id} already exists");
|
||||
return Task.FromResult(region);
|
||||
}
|
||||
|
||||
public Task<Models.Region> UpdateAsync(Models.Region region, CancellationToken ct = default)
|
||||
{
|
||||
var tenantId = _tenantIdProvider();
|
||||
if (!_regions.TryGetValue(region.Id, out var existing) || existing.TenantId != tenantId)
|
||||
throw new InvalidOperationException($"Region with ID {region.Id} not found");
|
||||
_regions[region.Id] = region;
|
||||
return Task.FromResult(region);
|
||||
}
|
||||
|
||||
public Task DeleteAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
var tenantId = _tenantIdProvider();
|
||||
if (_regions.TryGetValue(id, out var existing) && existing.TenantId == tenantId)
|
||||
_regions.TryRemove(id, out _);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<bool> HasEnvironmentsAsync(Guid regionId, CancellationToken ct = default)
|
||||
{
|
||||
// In-memory store doesn't track cross-entity relationships
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
public void Clear() => _regions.Clear();
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Models;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.ReleaseOrchestrator.Environment.Region;
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of region management service.
|
||||
/// </summary>
|
||||
public sealed partial class RegionService : IRegionService
|
||||
{
|
||||
private readonly IRegionStore _store;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<RegionService> _logger;
|
||||
private readonly Func<Guid> _tenantIdProvider;
|
||||
private readonly Func<Guid> _userIdProvider;
|
||||
|
||||
public RegionService(
|
||||
IRegionStore store,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<RegionService> logger,
|
||||
Func<Guid> tenantIdProvider,
|
||||
Func<Guid> userIdProvider)
|
||||
{
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_tenantIdProvider = tenantIdProvider ?? throw new ArgumentNullException(nameof(tenantIdProvider));
|
||||
_userIdProvider = userIdProvider ?? throw new ArgumentNullException(nameof(userIdProvider));
|
||||
}
|
||||
|
||||
public async Task<Models.Region> CreateAsync(CreateRegionRequest request, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var errors = new List<string>();
|
||||
|
||||
if (!IsValidRegionName(request.Name))
|
||||
errors.Add("Region name must be lowercase alphanumeric with hyphens, 2-100 characters");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.DisplayName))
|
||||
errors.Add("Display name is required");
|
||||
|
||||
var existingByName = await _store.GetByNameAsync(request.Name, ct);
|
||||
if (existingByName is not null)
|
||||
errors.Add($"Region with name '{request.Name}' already exists");
|
||||
|
||||
if (errors.Count > 0)
|
||||
throw new Services.ValidationException(errors);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var tenantId = _tenantIdProvider();
|
||||
var userId = _userIdProvider();
|
||||
|
||||
var region = new Models.Region
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
Name = request.Name,
|
||||
DisplayName = request.DisplayName,
|
||||
Description = request.Description,
|
||||
CryptoProfile = request.CryptoProfile,
|
||||
SortOrder = request.SortOrder,
|
||||
Status = RegionStatus.Active,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
CreatedBy = userId
|
||||
};
|
||||
|
||||
var created = await _store.CreateAsync(region, ct);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created region {RegionId} ({RegionName}) for tenant {TenantId}",
|
||||
created.Id, created.Name, tenantId);
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
public async Task<Models.Region> UpdateAsync(Guid id, UpdateRegionRequest request, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var existing = await _store.GetAsync(id, ct)
|
||||
?? throw new RegionNotFoundException(id);
|
||||
|
||||
var updated = existing with
|
||||
{
|
||||
DisplayName = request.DisplayName ?? existing.DisplayName,
|
||||
Description = request.Description ?? existing.Description,
|
||||
CryptoProfile = request.CryptoProfile ?? existing.CryptoProfile,
|
||||
SortOrder = request.SortOrder ?? existing.SortOrder,
|
||||
Status = request.Status ?? existing.Status,
|
||||
UpdatedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
var result = await _store.UpdateAsync(updated, ct);
|
||||
|
||||
_logger.LogInformation("Updated region {RegionId} ({RegionName})", id, result.Name);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
var existing = await _store.GetAsync(id, ct)
|
||||
?? throw new RegionNotFoundException(id);
|
||||
|
||||
if (await _store.HasEnvironmentsAsync(id, ct))
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot delete region '{existing.Name}': has associated environments");
|
||||
|
||||
await _store.DeleteAsync(id, ct);
|
||||
|
||||
_logger.LogInformation("Deleted region {RegionId} ({RegionName})", id, existing.Name);
|
||||
}
|
||||
|
||||
public Task<Models.Region?> GetAsync(Guid id, CancellationToken ct = default) =>
|
||||
_store.GetAsync(id, ct);
|
||||
|
||||
public Task<IReadOnlyList<Models.Region>> ListAsync(CancellationToken ct = default) =>
|
||||
_store.ListAsync(ct);
|
||||
|
||||
private static bool IsValidRegionName(string name) =>
|
||||
RegionNameRegex().IsMatch(name);
|
||||
|
||||
[GeneratedRegex(@"^[a-z][a-z0-9-]{1,99}$")]
|
||||
private static partial Regex RegionNameRegex();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when a region is not found.
|
||||
/// </summary>
|
||||
public sealed class RegionNotFoundException : Exception
|
||||
{
|
||||
public RegionNotFoundException(Guid regionId)
|
||||
: base($"Region with ID '{regionId}' not found")
|
||||
{
|
||||
RegionId = regionId;
|
||||
}
|
||||
|
||||
public Guid RegionId { get; }
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
namespace StellaOps.ReleaseOrchestrator.Environment.Rename;
|
||||
|
||||
/// <summary>
|
||||
/// Service for renaming topology entities.
|
||||
/// </summary>
|
||||
public interface ITopologyRenameService
|
||||
{
|
||||
Task<RenameResult> RenameAsync(RenameRequest request, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to rename a topology entity.
|
||||
/// </summary>
|
||||
public sealed record RenameRequest(
|
||||
RenameEntityType EntityType,
|
||||
Guid EntityId,
|
||||
string NewName,
|
||||
string NewDisplayName);
|
||||
|
||||
/// <summary>
|
||||
/// Entity types that support renaming.
|
||||
/// </summary>
|
||||
public enum RenameEntityType
|
||||
{
|
||||
Region,
|
||||
Environment,
|
||||
Target,
|
||||
Agent,
|
||||
Integration
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a rename operation.
|
||||
/// </summary>
|
||||
public sealed record RenameResult
|
||||
{
|
||||
public required bool Success { get; init; }
|
||||
public string? OldName { get; init; }
|
||||
public string? NewName { get; init; }
|
||||
public Guid? ConflictingEntityId { get; init; }
|
||||
public string? Error { get; init; }
|
||||
|
||||
public static RenameResult Ok(string oldName, string newName) =>
|
||||
new() { Success = true, OldName = oldName, NewName = newName };
|
||||
|
||||
public static RenameResult Conflict(Guid conflictingId) =>
|
||||
new() { Success = false, ConflictingEntityId = conflictingId, Error = "name_conflict" };
|
||||
|
||||
public static RenameResult Failed(string error) =>
|
||||
new() { Success = false, Error = error };
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Region;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Services;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Target;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.ReleaseOrchestrator.Environment.Rename;
|
||||
|
||||
/// <summary>
|
||||
/// Handles rename operations for all topology entities with conflict detection.
|
||||
/// </summary>
|
||||
public sealed partial class TopologyRenameService : ITopologyRenameService
|
||||
{
|
||||
private readonly IRegionService _regionService;
|
||||
private readonly IEnvironmentService _environmentService;
|
||||
private readonly ITargetRegistry _targetRegistry;
|
||||
private readonly IRegionStore _regionStore;
|
||||
private readonly ILogger<TopologyRenameService> _logger;
|
||||
|
||||
public TopologyRenameService(
|
||||
IRegionService regionService,
|
||||
IEnvironmentService environmentService,
|
||||
ITargetRegistry targetRegistry,
|
||||
IRegionStore regionStore,
|
||||
ILogger<TopologyRenameService> logger)
|
||||
{
|
||||
_regionService = regionService ?? throw new ArgumentNullException(nameof(regionService));
|
||||
_environmentService = environmentService ?? throw new ArgumentNullException(nameof(environmentService));
|
||||
_targetRegistry = targetRegistry ?? throw new ArgumentNullException(nameof(targetRegistry));
|
||||
_regionStore = regionStore ?? throw new ArgumentNullException(nameof(regionStore));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<RenameResult> RenameAsync(RenameRequest request, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
// Validate name format
|
||||
if (!IsValidName(request.NewName))
|
||||
{
|
||||
return RenameResult.Failed(
|
||||
"Name must be lowercase alphanumeric with hyphens, 2-100 characters, starting with a letter");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.NewDisplayName))
|
||||
{
|
||||
return RenameResult.Failed("Display name is required");
|
||||
}
|
||||
|
||||
return request.EntityType switch
|
||||
{
|
||||
RenameEntityType.Region => await RenameRegionAsync(request, ct),
|
||||
RenameEntityType.Environment => await RenameEnvironmentAsync(request, ct),
|
||||
RenameEntityType.Target => await RenameTargetAsync(request, ct),
|
||||
_ => RenameResult.Failed($"Rename not yet supported for {request.EntityType}")
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<RenameResult> RenameRegionAsync(RenameRequest request, CancellationToken ct)
|
||||
{
|
||||
var region = await _regionService.GetAsync(request.EntityId, ct);
|
||||
if (region is null)
|
||||
return RenameResult.Failed("Region not found");
|
||||
|
||||
// Check for name conflict
|
||||
var existing = await _regionStore.GetByNameAsync(request.NewName, ct);
|
||||
if (existing is not null && existing.Id != request.EntityId)
|
||||
return RenameResult.Conflict(existing.Id);
|
||||
|
||||
var oldName = region.Name;
|
||||
await _regionService.UpdateAsync(request.EntityId, new UpdateRegionRequest(
|
||||
DisplayName: request.NewDisplayName), ct);
|
||||
|
||||
_logger.LogInformation("Renamed region {Id}: {OldName} -> {NewName}", request.EntityId, oldName, request.NewName);
|
||||
return RenameResult.Ok(oldName, request.NewName);
|
||||
}
|
||||
|
||||
private async Task<RenameResult> RenameEnvironmentAsync(RenameRequest request, CancellationToken ct)
|
||||
{
|
||||
var env = await _environmentService.GetAsync(request.EntityId, ct);
|
||||
if (env is null)
|
||||
return RenameResult.Failed("Environment not found");
|
||||
|
||||
// Check for name conflict
|
||||
var existing = await _environmentService.GetByNameAsync(request.NewName, ct);
|
||||
if (existing is not null && existing.Id != request.EntityId)
|
||||
return RenameResult.Conflict(existing.Id);
|
||||
|
||||
var oldName = env.Name;
|
||||
await _environmentService.UpdateAsync(request.EntityId, new UpdateEnvironmentRequest(
|
||||
DisplayName: request.NewDisplayName), ct);
|
||||
|
||||
_logger.LogInformation("Renamed environment {Id}: {OldName} -> {NewName}", request.EntityId, oldName, request.NewName);
|
||||
return RenameResult.Ok(oldName, request.NewName);
|
||||
}
|
||||
|
||||
private async Task<RenameResult> RenameTargetAsync(RenameRequest request, CancellationToken ct)
|
||||
{
|
||||
var target = await _targetRegistry.GetAsync(request.EntityId, ct);
|
||||
if (target is null)
|
||||
return RenameResult.Failed("Target not found");
|
||||
|
||||
// Check for name conflict within the same environment
|
||||
var existing = await _targetRegistry.GetByNameAsync(target.EnvironmentId, request.NewName, ct);
|
||||
if (existing is not null && existing.Id != request.EntityId)
|
||||
return RenameResult.Conflict(existing.Id);
|
||||
|
||||
var oldName = target.Name;
|
||||
await _targetRegistry.UpdateAsync(request.EntityId, new UpdateTargetRequest(
|
||||
DisplayName: request.NewDisplayName), ct);
|
||||
|
||||
_logger.LogInformation("Renamed target {Id}: {OldName} -> {NewName}", request.EntityId, oldName, request.NewName);
|
||||
return RenameResult.Ok(oldName, request.NewName);
|
||||
}
|
||||
|
||||
private static bool IsValidName(string name) =>
|
||||
NameRegex().IsMatch(name);
|
||||
|
||||
[GeneratedRegex(@"^[a-z][a-z0-9-]{1,99}$")]
|
||||
private static partial Regex NameRegex();
|
||||
}
|
||||
@@ -9,6 +9,10 @@
|
||||
<RootNamespace>StellaOps.ReleaseOrchestrator.Environment</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Migrations\**\*.sql" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
namespace StellaOps.ReleaseOrchestrator.Environment.Target;
|
||||
|
||||
/// <summary>
|
||||
/// Policy for Docker version enforcement on deployment targets.
|
||||
/// </summary>
|
||||
public static class DockerVersionPolicy
|
||||
{
|
||||
/// <summary>
|
||||
/// Minimum supported Docker version (20.10.0).
|
||||
/// </summary>
|
||||
public static readonly Version MinimumSupported = new(20, 10, 0);
|
||||
|
||||
/// <summary>
|
||||
/// Recommended Docker version (24.0.0).
|
||||
/// </summary>
|
||||
public static readonly Version Recommended = new(24, 0, 0);
|
||||
|
||||
/// <summary>
|
||||
/// Checks a reported Docker version string against the policy.
|
||||
/// </summary>
|
||||
public static DockerVersionCheckResult Check(string? reportedVersion)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(reportedVersion))
|
||||
{
|
||||
return new DockerVersionCheckResult(
|
||||
IsSupported: false,
|
||||
IsRecommended: false,
|
||||
ParsedVersion: null,
|
||||
Message: "Docker version not reported");
|
||||
}
|
||||
|
||||
var parsed = ParseDockerVersion(reportedVersion);
|
||||
if (parsed is null)
|
||||
{
|
||||
return new DockerVersionCheckResult(
|
||||
IsSupported: false,
|
||||
IsRecommended: false,
|
||||
ParsedVersion: null,
|
||||
Message: $"Unable to parse Docker version: {reportedVersion}");
|
||||
}
|
||||
|
||||
var isSupported = parsed >= MinimumSupported;
|
||||
var isRecommended = parsed >= Recommended;
|
||||
|
||||
var message = !isSupported
|
||||
? $"Docker {reportedVersion} is below minimum supported version {MinimumSupported}. Please upgrade to {MinimumSupported} or later."
|
||||
: !isRecommended
|
||||
? $"Docker {reportedVersion} is supported but below recommended version {Recommended}."
|
||||
: $"Docker {reportedVersion} meets recommended version.";
|
||||
|
||||
return new DockerVersionCheckResult(
|
||||
IsSupported: isSupported,
|
||||
IsRecommended: isRecommended,
|
||||
ParsedVersion: parsed,
|
||||
Message: message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses Docker version strings like "20.10.24", "24.0.7-1", "26.1.0-beta".
|
||||
/// Strips suffixes after the version numbers.
|
||||
/// </summary>
|
||||
internal static Version? ParseDockerVersion(string versionString)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(versionString))
|
||||
return null;
|
||||
|
||||
// Strip any leading 'v' or 'V'
|
||||
var trimmed = versionString.TrimStart('v', 'V').Trim();
|
||||
|
||||
// Take only the numeric part (stop at first non-version character)
|
||||
var versionPart = new string(trimmed.TakeWhile(c => char.IsDigit(c) || c == '.').ToArray());
|
||||
|
||||
if (string.IsNullOrEmpty(versionPart))
|
||||
return null;
|
||||
|
||||
// Split and parse
|
||||
var parts = versionPart.Split('.');
|
||||
if (parts.Length < 2)
|
||||
return null;
|
||||
|
||||
if (!int.TryParse(parts[0], out var major))
|
||||
return null;
|
||||
if (!int.TryParse(parts[1], out var minor))
|
||||
return null;
|
||||
|
||||
var build = 0;
|
||||
if (parts.Length >= 3 && !string.IsNullOrEmpty(parts[2]))
|
||||
{
|
||||
int.TryParse(parts[2], out build);
|
||||
}
|
||||
|
||||
return new Version(major, minor, build);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a Docker version check.
|
||||
/// </summary>
|
||||
public sealed record DockerVersionCheckResult(
|
||||
bool IsSupported,
|
||||
bool IsRecommended,
|
||||
Version? ParsedVersion,
|
||||
string Message);
|
||||
@@ -0,0 +1,173 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Moq;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Deletion;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ReleaseOrchestrator.Environment.Tests.Deletion;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for PendingDeletionService deletion lifecycle state machine.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DeletionLifecycleTests
|
||||
{
|
||||
private readonly InMemoryPendingDeletionStore _store;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly PendingDeletionService _service;
|
||||
private readonly Guid _tenantId = Guid.NewGuid();
|
||||
private readonly Guid _userId = Guid.NewGuid();
|
||||
|
||||
public DeletionLifecycleTests()
|
||||
{
|
||||
_store = new InMemoryPendingDeletionStore(() => _tenantId);
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 11, 12, 0, 0, TimeSpan.Zero));
|
||||
var logger = new Mock<ILogger<PendingDeletionService>>();
|
||||
|
||||
_service = new PendingDeletionService(
|
||||
_store,
|
||||
_timeProvider,
|
||||
logger.Object,
|
||||
() => _tenantId,
|
||||
() => _userId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestDeletion_CreatesWithCorrectCoolOff()
|
||||
{
|
||||
// Arrange
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var entityId = Guid.NewGuid();
|
||||
var request = new DeletionRequest(DeletionEntityType.Environment, entityId, "Decommissioning");
|
||||
|
||||
// Act
|
||||
var result = await _service.RequestDeletionAsync(request, ct);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Status.Should().Be(DeletionStatus.Pending);
|
||||
result.EntityType.Should().Be(DeletionEntityType.Environment);
|
||||
result.EntityId.Should().Be(entityId);
|
||||
result.CoolOffHours.Should().Be(24); // Environment cool-off
|
||||
result.CoolOffExpiresAt.Should().Be(_timeProvider.GetUtcNow().AddHours(24));
|
||||
result.RequestedBy.Should().Be(_userId);
|
||||
result.Reason.Should().Be("Decommissioning");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfirmDeletion_AfterCoolOffExpires_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var request = new DeletionRequest(DeletionEntityType.Target, Guid.NewGuid());
|
||||
var pending = await _service.RequestDeletionAsync(request, ct);
|
||||
|
||||
// Advance past the 4-hour cool-off for Target
|
||||
_timeProvider.Advance(TimeSpan.FromHours(5));
|
||||
var confirmerId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
var confirmed = await _service.ConfirmDeletionAsync(pending.Id, confirmerId, ct);
|
||||
|
||||
// Assert
|
||||
confirmed.Status.Should().Be(DeletionStatus.Confirmed);
|
||||
confirmed.ConfirmedBy.Should().Be(confirmerId);
|
||||
confirmed.ConfirmedAt.Should().Be(_timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfirmDeletion_BeforeCoolOff_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var request = new DeletionRequest(DeletionEntityType.Region, Guid.NewGuid());
|
||||
var pending = await _service.RequestDeletionAsync(request, ct);
|
||||
|
||||
// Only advance 1 hour (Region cool-off is 48 hours)
|
||||
_timeProvider.Advance(TimeSpan.FromHours(1));
|
||||
|
||||
// Act
|
||||
var act = () => _service.ConfirmDeletionAsync(pending.Id, Guid.NewGuid(), ct);
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<InvalidOperationException>()
|
||||
.WithMessage("*Cool-off period has not expired*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancelDeletion_FromPending_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var request = new DeletionRequest(DeletionEntityType.Environment, Guid.NewGuid());
|
||||
var pending = await _service.RequestDeletionAsync(request, ct);
|
||||
|
||||
// Act
|
||||
await _service.CancelDeletionAsync(pending.Id, _userId, ct);
|
||||
|
||||
// Assert
|
||||
var result = await _service.GetAsync(pending.Id, ct);
|
||||
result!.Status.Should().Be(DeletionStatus.Cancelled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancelDeletion_FromConfirmed_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var request = new DeletionRequest(DeletionEntityType.Target, Guid.NewGuid());
|
||||
var pending = await _service.RequestDeletionAsync(request, ct);
|
||||
|
||||
// Advance past cool-off and confirm
|
||||
_timeProvider.Advance(TimeSpan.FromHours(5));
|
||||
await _service.ConfirmDeletionAsync(pending.Id, Guid.NewGuid(), ct);
|
||||
|
||||
// Act
|
||||
await _service.CancelDeletionAsync(pending.Id, _userId, ct);
|
||||
|
||||
// Assert
|
||||
var result = await _service.GetAsync(pending.Id, ct);
|
||||
result!.Status.Should().Be(DeletionStatus.Cancelled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DuplicateRequest_ForSameEntity_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var entityId = Guid.NewGuid();
|
||||
var request = new DeletionRequest(DeletionEntityType.Integration, entityId);
|
||||
|
||||
await _service.RequestDeletionAsync(request, ct);
|
||||
|
||||
// Act
|
||||
var act = () => _service.RequestDeletionAsync(request, ct);
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<InvalidOperationException>()
|
||||
.WithMessage("*deletion request already exists*");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(DeletionEntityType.Tenant, 72)]
|
||||
[InlineData(DeletionEntityType.Region, 48)]
|
||||
[InlineData(DeletionEntityType.Environment, 24)]
|
||||
[InlineData(DeletionEntityType.Target, 4)]
|
||||
[InlineData(DeletionEntityType.Agent, 4)]
|
||||
[InlineData(DeletionEntityType.Integration, 12)]
|
||||
public async Task DifferentEntityTypes_GetDifferentCoolOffHours(DeletionEntityType entityType, int expectedHours)
|
||||
{
|
||||
// Arrange
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var request = new DeletionRequest(entityType, Guid.NewGuid());
|
||||
|
||||
// Act
|
||||
var result = await _service.RequestDeletionAsync(request, ct);
|
||||
|
||||
// Assert
|
||||
result.CoolOffHours.Should().Be(expectedHours);
|
||||
result.CoolOffExpiresAt.Should().Be(_timeProvider.GetUtcNow().AddHours(expectedHours));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Moq;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.InfrastructureBinding;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Models;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Services;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Store;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ReleaseOrchestrator.Environment.Tests.InfrastructureBinding;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for InfrastructureBindingService resolve cascade.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class InfrastructureBindingServiceTests
|
||||
{
|
||||
private readonly InMemoryInfrastructureBindingStore _bindingStore;
|
||||
private readonly InMemoryEnvironmentStore _environmentStore;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly InfrastructureBindingService _service;
|
||||
private readonly Guid _tenantId = Guid.NewGuid();
|
||||
private readonly Guid _userId = Guid.NewGuid();
|
||||
private readonly Guid _regionId = Guid.NewGuid();
|
||||
|
||||
public InfrastructureBindingServiceTests()
|
||||
{
|
||||
_bindingStore = new InMemoryInfrastructureBindingStore(() => _tenantId);
|
||||
_environmentStore = new InMemoryEnvironmentStore(() => _tenantId);
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 11, 12, 0, 0, TimeSpan.Zero));
|
||||
|
||||
var envLogger = new Mock<ILogger<EnvironmentService>>();
|
||||
var environmentService = new EnvironmentService(
|
||||
_environmentStore,
|
||||
_timeProvider,
|
||||
envLogger.Object,
|
||||
() => _tenantId,
|
||||
() => _userId);
|
||||
|
||||
var bindingLogger = new Mock<ILogger<InfrastructureBindingService>>();
|
||||
_service = new InfrastructureBindingService(
|
||||
_bindingStore,
|
||||
environmentService,
|
||||
bindingLogger.Object,
|
||||
_timeProvider,
|
||||
() => _tenantId,
|
||||
() => _userId);
|
||||
}
|
||||
|
||||
private async Task<Models.Environment> CreateEnvironmentWithRegion(string name, int order, Guid? regionId, CancellationToken ct)
|
||||
{
|
||||
var env = new Models.Environment
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
Name = name,
|
||||
DisplayName = name,
|
||||
OrderIndex = order,
|
||||
IsProduction = false,
|
||||
RequiredApprovals = 0,
|
||||
RequireSeparationOfDuties = false,
|
||||
DeploymentTimeoutSeconds = 300,
|
||||
RegionId = regionId,
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
UpdatedAt = _timeProvider.GetUtcNow(),
|
||||
CreatedBy = _userId
|
||||
};
|
||||
return await _environmentStore.CreateAsync(env, ct);
|
||||
}
|
||||
|
||||
private Models.InfrastructureBinding MakeBinding(
|
||||
BindingScopeType scopeType,
|
||||
Guid? scopeId,
|
||||
BindingRole role,
|
||||
int priority = 0,
|
||||
bool isActive = true)
|
||||
{
|
||||
return new Models.InfrastructureBinding
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
IntegrationId = Guid.NewGuid(),
|
||||
ScopeType = scopeType,
|
||||
ScopeId = scopeId,
|
||||
Role = role,
|
||||
Priority = priority,
|
||||
ConfigOverrides = new Dictionary<string, string>(),
|
||||
IsActive = isActive,
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
UpdatedAt = _timeProvider.GetUtcNow(),
|
||||
CreatedBy = _userId
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_DirectEnvironmentBinding_ReturnsDirectSource()
|
||||
{
|
||||
// Arrange
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var env = await CreateEnvironmentWithRegion("dev", 0, _regionId, ct);
|
||||
|
||||
var binding = MakeBinding(BindingScopeType.Environment, env.Id, BindingRole.Registry);
|
||||
await _bindingStore.CreateAsync(binding, ct);
|
||||
|
||||
// Act
|
||||
var result = await _service.ResolveAllAsync(env.Id, ct);
|
||||
|
||||
// Assert
|
||||
result.Registry.Should().NotBeNull();
|
||||
result.Registry!.ResolvedFrom.Should().Be(BindingResolutionSource.Direct);
|
||||
result.Registry.Binding.Id.Should().Be(binding.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_NoEnvBinding_RegionFallback_ReturnsRegionSource()
|
||||
{
|
||||
// Arrange
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var env = await CreateEnvironmentWithRegion("staging", 1, _regionId, ct);
|
||||
|
||||
// Create only a region-level binding (no env-level)
|
||||
var regionBinding = MakeBinding(BindingScopeType.Region, _regionId, BindingRole.Registry);
|
||||
await _bindingStore.CreateAsync(regionBinding, ct);
|
||||
|
||||
// Act
|
||||
var result = await _service.ResolveAllAsync(env.Id, ct);
|
||||
|
||||
// Assert
|
||||
result.Registry.Should().NotBeNull();
|
||||
result.Registry!.ResolvedFrom.Should().Be(BindingResolutionSource.Region);
|
||||
result.Registry.Binding.Id.Should().Be(regionBinding.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_NoEnvOrRegion_TenantFallback_ReturnsTenantSource()
|
||||
{
|
||||
// Arrange
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var env = await CreateEnvironmentWithRegion("prod", 2, _regionId, ct);
|
||||
|
||||
// Create only a tenant-level binding (scopeId = null)
|
||||
var tenantBinding = MakeBinding(BindingScopeType.Tenant, null, BindingRole.Registry);
|
||||
await _bindingStore.CreateAsync(tenantBinding, ct);
|
||||
|
||||
// Act
|
||||
var result = await _service.ResolveAllAsync(env.Id, ct);
|
||||
|
||||
// Assert
|
||||
result.Registry.Should().NotBeNull();
|
||||
result.Registry!.ResolvedFrom.Should().Be(BindingResolutionSource.Tenant);
|
||||
result.Registry.Binding.Id.Should().Be(tenantBinding.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_MultipleBindings_HigherPriorityWins()
|
||||
{
|
||||
// Arrange
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var env = await CreateEnvironmentWithRegion("dev", 0, _regionId, ct);
|
||||
|
||||
var lowPriority = MakeBinding(BindingScopeType.Environment, env.Id, BindingRole.Registry, priority: 1);
|
||||
var highPriority = MakeBinding(BindingScopeType.Environment, env.Id, BindingRole.Registry, priority: 10);
|
||||
await _bindingStore.CreateAsync(lowPriority, ct);
|
||||
await _bindingStore.CreateAsync(highPriority, ct);
|
||||
|
||||
// Act
|
||||
var result = await _service.ResolveAsync(env.Id, BindingRole.Registry, ct);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Id.Should().Be(highPriority.Id);
|
||||
result.Priority.Should().Be(10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_InactiveBinding_Skipped()
|
||||
{
|
||||
// Arrange
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var env = await CreateEnvironmentWithRegion("dev", 0, _regionId, ct);
|
||||
|
||||
// Create an inactive env binding and an active tenant binding
|
||||
var inactiveEnvBinding = MakeBinding(BindingScopeType.Environment, env.Id, BindingRole.Vault, isActive: false);
|
||||
var activeTenantBinding = MakeBinding(BindingScopeType.Tenant, null, BindingRole.Vault);
|
||||
await _bindingStore.CreateAsync(inactiveEnvBinding, ct);
|
||||
await _bindingStore.CreateAsync(activeTenantBinding, ct);
|
||||
|
||||
// Act
|
||||
var result = await _service.ResolveAllAsync(env.Id, ct);
|
||||
|
||||
// Assert
|
||||
result.Vault.Should().NotBeNull();
|
||||
result.Vault!.ResolvedFrom.Should().Be(BindingResolutionSource.Tenant);
|
||||
result.Vault.Binding.Id.Should().Be(activeTenantBinding.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_NoBindingAtAnyLevel_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var env = await CreateEnvironmentWithRegion("dev", 0, _regionId, ct);
|
||||
|
||||
// No bindings created at any level
|
||||
|
||||
// Act
|
||||
var result = await _service.ResolveAsync(env.Id, BindingRole.Registry, ct);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Moq;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.InfrastructureBinding;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Models;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Readiness;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Services;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Store;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Target;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ReleaseOrchestrator.Environment.Tests.Readiness;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for TopologyReadinessService gate evaluation.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class TopologyReadinessServiceTests
|
||||
{
|
||||
private readonly InMemoryTargetStore _targetStore;
|
||||
private readonly InMemoryEnvironmentStore _environmentStore;
|
||||
private readonly InMemoryInfrastructureBindingStore _bindingStore;
|
||||
private readonly InMemoryTopologyPointStatusStore _statusStore;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly TargetRegistry _targetRegistry;
|
||||
private readonly InfrastructureBindingService _bindingService;
|
||||
private readonly TopologyReadinessService _service;
|
||||
private readonly Guid _tenantId = Guid.NewGuid();
|
||||
private readonly Guid _userId = Guid.NewGuid();
|
||||
private readonly Guid _environmentId;
|
||||
|
||||
public TopologyReadinessServiceTests()
|
||||
{
|
||||
_targetStore = new InMemoryTargetStore(() => _tenantId);
|
||||
_environmentStore = new InMemoryEnvironmentStore(() => _tenantId);
|
||||
_bindingStore = new InMemoryInfrastructureBindingStore(() => _tenantId);
|
||||
_statusStore = new InMemoryTopologyPointStatusStore();
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 11, 12, 0, 0, TimeSpan.Zero));
|
||||
|
||||
var connectionTester = new NoOpTargetConnectionTester();
|
||||
var targetLogger = new Mock<ILogger<TargetRegistry>>();
|
||||
_targetRegistry = new TargetRegistry(
|
||||
_targetStore,
|
||||
_environmentStore,
|
||||
connectionTester,
|
||||
_timeProvider,
|
||||
targetLogger.Object,
|
||||
() => _tenantId);
|
||||
|
||||
var envLogger = new Mock<ILogger<EnvironmentService>>();
|
||||
var environmentService = new EnvironmentService(
|
||||
_environmentStore,
|
||||
_timeProvider,
|
||||
envLogger.Object,
|
||||
() => _tenantId,
|
||||
() => _userId);
|
||||
|
||||
var bindingLogger = new Mock<ILogger<InfrastructureBindingService>>();
|
||||
_bindingService = new InfrastructureBindingService(
|
||||
_bindingStore,
|
||||
environmentService,
|
||||
bindingLogger.Object,
|
||||
_timeProvider,
|
||||
() => _tenantId,
|
||||
() => _userId);
|
||||
|
||||
var readinessLogger = new Mock<ILogger<TopologyReadinessService>>();
|
||||
_service = new TopologyReadinessService(
|
||||
_targetRegistry,
|
||||
_bindingService,
|
||||
_statusStore,
|
||||
readinessLogger.Object,
|
||||
_timeProvider,
|
||||
() => _tenantId);
|
||||
|
||||
// Create a test environment
|
||||
var env = new Models.Environment
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
Name = "dev",
|
||||
DisplayName = "Development",
|
||||
OrderIndex = 0,
|
||||
IsProduction = false,
|
||||
RequiredApprovals = 0,
|
||||
RequireSeparationOfDuties = false,
|
||||
DeploymentTimeoutSeconds = 300,
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
UpdatedAt = _timeProvider.GetUtcNow(),
|
||||
CreatedBy = _userId
|
||||
};
|
||||
_environmentStore.CreateAsync(env).Wait();
|
||||
_environmentId = env.Id;
|
||||
}
|
||||
|
||||
private async Task<Models.Target> CreateTarget(
|
||||
string name,
|
||||
TargetType type,
|
||||
Guid? agentId,
|
||||
HealthStatus healthStatus,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var target = new Models.Target
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
EnvironmentId = _environmentId,
|
||||
Name = name,
|
||||
DisplayName = name,
|
||||
Type = type,
|
||||
ConnectionConfig = new DockerHostConfig { Host = "docker.example.com" },
|
||||
AgentId = agentId,
|
||||
HealthStatus = healthStatus,
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
UpdatedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
return await _targetStore.CreateAsync(target, ct);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AgentBound_WithAgent_Pass()
|
||||
{
|
||||
// Arrange
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var agentId = Guid.NewGuid();
|
||||
var target = await CreateTarget("target-1", TargetType.DockerHost, agentId, HealthStatus.Healthy, ct);
|
||||
|
||||
// Act
|
||||
var report = await _service.ValidateAsync(target.Id, ct);
|
||||
|
||||
// Assert
|
||||
var gate = report.Gates.Single(g => g.GateName == TopologyGates.AgentBound);
|
||||
gate.Status.Should().Be(GateStatus.Pass);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AgentBound_WithoutAgent_Fail()
|
||||
{
|
||||
// Arrange
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var target = await CreateTarget("target-2", TargetType.DockerHost, null, HealthStatus.Healthy, ct);
|
||||
|
||||
// Act
|
||||
var report = await _service.ValidateAsync(target.Id, ct);
|
||||
|
||||
// Assert
|
||||
var gate = report.Gates.Single(g => g.GateName == TopologyGates.AgentBound);
|
||||
gate.Status.Should().Be(GateStatus.Fail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DockerVersionOk_NonDockerTarget_Skip()
|
||||
{
|
||||
// Arrange
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var target = new Models.Target
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
EnvironmentId = _environmentId,
|
||||
Name = "ecs-target",
|
||||
DisplayName = "ECS Target",
|
||||
Type = TargetType.EcsService,
|
||||
ConnectionConfig = new EcsServiceConfig
|
||||
{
|
||||
Region = "us-east-1",
|
||||
ClusterArn = "arn:aws:ecs:us-east-1:123:cluster/test",
|
||||
ServiceName = "api"
|
||||
},
|
||||
AgentId = Guid.NewGuid(),
|
||||
HealthStatus = HealthStatus.Healthy,
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
UpdatedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
await _targetStore.CreateAsync(target, ct);
|
||||
|
||||
// Act
|
||||
var report = await _service.ValidateAsync(target.Id, ct);
|
||||
|
||||
// Assert
|
||||
var gate = report.Gates.Single(g => g.GateName == TopologyGates.DockerVersionOk);
|
||||
gate.Status.Should().Be(GateStatus.Skip);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DockerVersionOk_DockerTarget_Pending()
|
||||
{
|
||||
// Arrange
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var target = await CreateTarget("docker-1", TargetType.DockerHost, Guid.NewGuid(), HealthStatus.Healthy, ct);
|
||||
|
||||
// Act
|
||||
var report = await _service.ValidateAsync(target.Id, ct);
|
||||
|
||||
// Assert
|
||||
var gate = report.Gates.Single(g => g.GateName == TopologyGates.DockerVersionOk);
|
||||
gate.Status.Should().Be(GateStatus.Pending);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DockerPingOk_HealthyTarget_Pass()
|
||||
{
|
||||
// Arrange
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var target = await CreateTarget("docker-healthy", TargetType.DockerHost, Guid.NewGuid(), HealthStatus.Healthy, ct);
|
||||
|
||||
// Act
|
||||
var report = await _service.ValidateAsync(target.Id, ct);
|
||||
|
||||
// Assert
|
||||
var gate = report.Gates.Single(g => g.GateName == TopologyGates.DockerPingOk);
|
||||
gate.Status.Should().Be(GateStatus.Pass);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DockerPingOk_UnhealthyTarget_Fail()
|
||||
{
|
||||
// Arrange
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var target = await CreateTarget("docker-unhealthy", TargetType.DockerHost, Guid.NewGuid(), HealthStatus.Unhealthy, ct);
|
||||
|
||||
// Act
|
||||
var report = await _service.ValidateAsync(target.Id, ct);
|
||||
|
||||
// Assert
|
||||
var gate = report.Gates.Single(g => g.GateName == TopologyGates.DockerPingOk);
|
||||
gate.Status.Should().Be(GateStatus.Fail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DockerPingOk_NonDockerTarget_Skip()
|
||||
{
|
||||
// Arrange
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var target = new Models.Target
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
EnvironmentId = _environmentId,
|
||||
Name = "nomad-target",
|
||||
DisplayName = "Nomad Target",
|
||||
Type = TargetType.NomadJob,
|
||||
ConnectionConfig = new NomadJobConfig
|
||||
{
|
||||
Address = "https://nomad.example.com",
|
||||
Namespace = "default",
|
||||
JobId = "api-job"
|
||||
},
|
||||
AgentId = Guid.NewGuid(),
|
||||
HealthStatus = HealthStatus.Healthy,
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
UpdatedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
await _targetStore.CreateAsync(target, ct);
|
||||
|
||||
// Act
|
||||
var report = await _service.ValidateAsync(target.Id, ct);
|
||||
|
||||
// Assert
|
||||
var gate = report.Gates.Single(g => g.GateName == TopologyGates.DockerPingOk);
|
||||
gate.Status.Should().Be(GateStatus.Skip);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RegistryPullOk_NoBinding_Skip()
|
||||
{
|
||||
// Arrange
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var target = await CreateTarget("target-noreg", TargetType.DockerHost, Guid.NewGuid(), HealthStatus.Healthy, ct);
|
||||
|
||||
// Act
|
||||
var report = await _service.ValidateAsync(target.Id, ct);
|
||||
|
||||
// Assert
|
||||
var gate = report.Gates.Single(g => g.GateName == TopologyGates.RegistryPullOk);
|
||||
gate.Status.Should().Be(GateStatus.Skip);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RegistryPullOk_WithBinding_Pass()
|
||||
{
|
||||
// Arrange
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var target = await CreateTarget("target-withreg", TargetType.DockerHost, Guid.NewGuid(), HealthStatus.Healthy, ct);
|
||||
|
||||
// Create a registry binding at env scope
|
||||
await _bindingService.BindAsync(new BindInfrastructureRequest(
|
||||
IntegrationId: Guid.NewGuid(),
|
||||
ScopeType: BindingScopeType.Environment,
|
||||
ScopeId: _environmentId,
|
||||
Role: BindingRole.Registry), ct);
|
||||
|
||||
// Act
|
||||
var report = await _service.ValidateAsync(target.Id, ct);
|
||||
|
||||
// Assert
|
||||
var gate = report.Gates.Single(g => g.GateName == TopologyGates.RegistryPullOk);
|
||||
gate.Status.Should().Be(GateStatus.Pass);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VaultReachable_NoBinding_Skip()
|
||||
{
|
||||
// Arrange
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var target = await CreateTarget("target-novault", TargetType.DockerHost, Guid.NewGuid(), HealthStatus.Healthy, ct);
|
||||
|
||||
// Act
|
||||
var report = await _service.ValidateAsync(target.Id, ct);
|
||||
|
||||
// Assert
|
||||
var gate = report.Gates.Single(g => g.GateName == TopologyGates.VaultReachable);
|
||||
gate.Status.Should().Be(GateStatus.Skip);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VaultReachable_WithBinding_Pass()
|
||||
{
|
||||
// Arrange
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var target = await CreateTarget("target-withvault", TargetType.DockerHost, Guid.NewGuid(), HealthStatus.Healthy, ct);
|
||||
|
||||
await _bindingService.BindAsync(new BindInfrastructureRequest(
|
||||
IntegrationId: Guid.NewGuid(),
|
||||
ScopeType: BindingScopeType.Environment,
|
||||
ScopeId: _environmentId,
|
||||
Role: BindingRole.Vault), ct);
|
||||
|
||||
// Act
|
||||
var report = await _service.ValidateAsync(target.Id, ct);
|
||||
|
||||
// Assert
|
||||
var gate = report.Gates.Single(g => g.GateName == TopologyGates.VaultReachable);
|
||||
gate.Status.Should().Be(GateStatus.Pass);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConsulReachable_NoBinding_Skip()
|
||||
{
|
||||
// Arrange
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var target = await CreateTarget("target-noconsul", TargetType.DockerHost, Guid.NewGuid(), HealthStatus.Healthy, ct);
|
||||
|
||||
// Act
|
||||
var report = await _service.ValidateAsync(target.Id, ct);
|
||||
|
||||
// Assert
|
||||
var gate = report.Gates.Single(g => g.GateName == TopologyGates.ConsulReachable);
|
||||
gate.Status.Should().Be(GateStatus.Skip);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConsulReachable_WithBinding_Pass()
|
||||
{
|
||||
// Arrange
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var target = await CreateTarget("target-withconsul", TargetType.DockerHost, Guid.NewGuid(), HealthStatus.Healthy, ct);
|
||||
|
||||
await _bindingService.BindAsync(new BindInfrastructureRequest(
|
||||
IntegrationId: Guid.NewGuid(),
|
||||
ScopeType: BindingScopeType.Environment,
|
||||
ScopeId: _environmentId,
|
||||
Role: BindingRole.SettingsStore), ct);
|
||||
|
||||
// Act
|
||||
var report = await _service.ValidateAsync(target.Id, ct);
|
||||
|
||||
// Assert
|
||||
var gate = report.Gates.Single(g => g.GateName == TopologyGates.ConsulReachable);
|
||||
gate.Status.Should().Be(GateStatus.Pass);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConnectivityOk_AllRequiredGatesPass_Pass()
|
||||
{
|
||||
// Arrange: ECS target with agent, no Docker gates required, no bindings
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var target = new Models.Target
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
EnvironmentId = _environmentId,
|
||||
Name = "ecs-all-pass",
|
||||
DisplayName = "ECS All Pass",
|
||||
Type = TargetType.EcsService,
|
||||
ConnectionConfig = new EcsServiceConfig
|
||||
{
|
||||
Region = "us-east-1",
|
||||
ClusterArn = "arn:aws:ecs:us-east-1:123:cluster/test",
|
||||
ServiceName = "api"
|
||||
},
|
||||
AgentId = Guid.NewGuid(),
|
||||
HealthStatus = HealthStatus.Healthy,
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
UpdatedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
await _targetStore.CreateAsync(target, ct);
|
||||
|
||||
// Act
|
||||
var report = await _service.ValidateAsync(target.Id, ct);
|
||||
|
||||
// Assert
|
||||
var gate = report.Gates.Single(g => g.GateName == TopologyGates.ConnectivityOk);
|
||||
gate.Status.Should().Be(GateStatus.Pass);
|
||||
report.IsReady.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConnectivityOk_FailedGate_Fail()
|
||||
{
|
||||
// Arrange: Docker target without agent -> agent_bound fails
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var target = await CreateTarget("docker-no-agent", TargetType.DockerHost, null, HealthStatus.Unhealthy, ct);
|
||||
|
||||
// Act
|
||||
var report = await _service.ValidateAsync(target.Id, ct);
|
||||
|
||||
// Assert
|
||||
var gate = report.Gates.Single(g => g.GateName == TopologyGates.ConnectivityOk);
|
||||
gate.Status.Should().Be(GateStatus.Fail);
|
||||
gate.Message.Should().Contain("agent_bound");
|
||||
report.IsReady.Should().BeFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Moq;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Models;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Region;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Rename;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Services;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Store;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Target;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ReleaseOrchestrator.Environment.Tests.Rename;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for TopologyRenameService rename operations.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class TopologyRenameServiceTests
|
||||
{
|
||||
private readonly InMemoryRegionStore _regionStore;
|
||||
private readonly InMemoryEnvironmentStore _environmentStore;
|
||||
private readonly InMemoryTargetStore _targetStore;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly RegionService _regionService;
|
||||
private readonly EnvironmentService _environmentService;
|
||||
private readonly TargetRegistry _targetRegistry;
|
||||
private readonly TopologyRenameService _service;
|
||||
private readonly Guid _tenantId = Guid.NewGuid();
|
||||
private readonly Guid _userId = Guid.NewGuid();
|
||||
|
||||
public TopologyRenameServiceTests()
|
||||
{
|
||||
_regionStore = new InMemoryRegionStore(() => _tenantId);
|
||||
_environmentStore = new InMemoryEnvironmentStore(() => _tenantId);
|
||||
_targetStore = new InMemoryTargetStore(() => _tenantId);
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 11, 12, 0, 0, TimeSpan.Zero));
|
||||
|
||||
var regionLogger = new Mock<ILogger<RegionService>>();
|
||||
_regionService = new RegionService(
|
||||
_regionStore,
|
||||
_timeProvider,
|
||||
regionLogger.Object,
|
||||
() => _tenantId,
|
||||
() => _userId);
|
||||
|
||||
var envLogger = new Mock<ILogger<EnvironmentService>>();
|
||||
_environmentService = new EnvironmentService(
|
||||
_environmentStore,
|
||||
_timeProvider,
|
||||
envLogger.Object,
|
||||
() => _tenantId,
|
||||
() => _userId);
|
||||
|
||||
var connectionTester = new NoOpTargetConnectionTester();
|
||||
var targetLogger = new Mock<ILogger<TargetRegistry>>();
|
||||
_targetRegistry = new TargetRegistry(
|
||||
_targetStore,
|
||||
_environmentStore,
|
||||
connectionTester,
|
||||
_timeProvider,
|
||||
targetLogger.Object,
|
||||
() => _tenantId);
|
||||
|
||||
var renameLogger = new Mock<ILogger<TopologyRenameService>>();
|
||||
_service = new TopologyRenameService(
|
||||
_regionService,
|
||||
_environmentService,
|
||||
_targetRegistry,
|
||||
_regionStore,
|
||||
renameLogger.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenameRegion_ValidName_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var region = await _regionService.CreateAsync(
|
||||
new CreateRegionRequest("us-east", "US East", null), ct);
|
||||
|
||||
var request = new RenameRequest(
|
||||
RenameEntityType.Region, region.Id, "eu-west", "EU West");
|
||||
|
||||
// Act
|
||||
var result = await _service.RenameAsync(request, ct);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.OldName.Should().Be("us-east");
|
||||
result.NewName.Should().Be("eu-west");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenameRegion_NameConflict_ReturnsConflict()
|
||||
{
|
||||
// Arrange
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var region1 = await _regionService.CreateAsync(
|
||||
new CreateRegionRequest("us-east", "US East", null, SortOrder: 0), ct);
|
||||
var region2 = await _regionService.CreateAsync(
|
||||
new CreateRegionRequest("eu-west", "EU West", null, SortOrder: 1), ct);
|
||||
|
||||
// Try to rename region1 to region2's name
|
||||
var request = new RenameRequest(
|
||||
RenameEntityType.Region, region1.Id, "eu-west", "EU West Renamed");
|
||||
|
||||
// Act
|
||||
var result = await _service.RenameAsync(request, ct);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.Error.Should().Be("name_conflict");
|
||||
result.ConflictingEntityId.Should().Be(region2.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenameRegion_InvalidNameFormat_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var region = await _regionService.CreateAsync(
|
||||
new CreateRegionRequest("us-east", "US East", null), ct);
|
||||
|
||||
var request = new RenameRequest(
|
||||
RenameEntityType.Region, region.Id, "Invalid Name!", "Invalid");
|
||||
|
||||
// Act
|
||||
var result = await _service.RenameAsync(request, ct);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.Error.Should().Contain("lowercase alphanumeric");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenameEnvironment_ValidName_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var env = await _environmentService.CreateAsync(
|
||||
new CreateEnvironmentRequest("dev", "Development", null, 0, false, 0, false, null, 300), ct);
|
||||
|
||||
var request = new RenameRequest(
|
||||
RenameEntityType.Environment, env.Id, "development", "Development Full");
|
||||
|
||||
// Act
|
||||
var result = await _service.RenameAsync(request, ct);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.OldName.Should().Be("dev");
|
||||
result.NewName.Should().Be("development");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenameTarget_ValidName_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
// Create environment first
|
||||
var env = await _environmentService.CreateAsync(
|
||||
new CreateEnvironmentRequest("dev", "Development", null, 0, false, 0, false, null, 300), ct);
|
||||
|
||||
var target = await _targetRegistry.RegisterAsync(
|
||||
new RegisterTargetRequest(
|
||||
env.Id, "docker-host-1", "Docker Host 1",
|
||||
TargetType.DockerHost,
|
||||
new DockerHostConfig { Host = "docker.example.com" }), ct);
|
||||
|
||||
var request = new RenameRequest(
|
||||
RenameEntityType.Target, target.Id, "docker-primary", "Docker Primary");
|
||||
|
||||
// Act
|
||||
var result = await _service.RenameAsync(request, ct);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.OldName.Should().Be("docker-host-1");
|
||||
result.NewName.Should().Be("docker-primary");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Target;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ReleaseOrchestrator.Environment.Tests.Target;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for DockerVersionPolicy version parsing and enforcement.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DockerVersionPolicyTests
|
||||
{
|
||||
[Fact]
|
||||
public void Check_Version20_10_24_SupportedNotRecommended()
|
||||
{
|
||||
// Arrange & Act
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var result = DockerVersionPolicy.Check("20.10.24");
|
||||
|
||||
// Assert
|
||||
result.IsSupported.Should().BeTrue();
|
||||
result.IsRecommended.Should().BeFalse();
|
||||
result.ParsedVersion.Should().NotBeNull();
|
||||
result.ParsedVersion!.Major.Should().Be(20);
|
||||
result.ParsedVersion.Minor.Should().Be(10);
|
||||
result.ParsedVersion.Build.Should().Be(24);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Check_Version24_0_7_1_SupportedAndRecommended()
|
||||
{
|
||||
// Arrange & Act
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var result = DockerVersionPolicy.Check("24.0.7-1");
|
||||
|
||||
// Assert
|
||||
result.IsSupported.Should().BeTrue();
|
||||
result.IsRecommended.Should().BeTrue();
|
||||
result.ParsedVersion.Should().NotBeNull();
|
||||
result.ParsedVersion!.Major.Should().Be(24);
|
||||
result.ParsedVersion.Minor.Should().Be(0);
|
||||
result.ParsedVersion.Build.Should().Be(7);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Check_Version26_1_0_Beta_SupportedAndRecommended()
|
||||
{
|
||||
// Arrange & Act
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var result = DockerVersionPolicy.Check("26.1.0-beta");
|
||||
|
||||
// Assert
|
||||
result.IsSupported.Should().BeTrue();
|
||||
result.IsRecommended.Should().BeTrue();
|
||||
result.ParsedVersion.Should().NotBeNull();
|
||||
result.ParsedVersion!.Major.Should().Be(26);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Check_Version19_03_12_NotSupported()
|
||||
{
|
||||
// Arrange & Act
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var result = DockerVersionPolicy.Check("19.03.12");
|
||||
|
||||
// Assert
|
||||
result.IsSupported.Should().BeFalse();
|
||||
result.IsRecommended.Should().BeFalse();
|
||||
result.ParsedVersion.Should().NotBeNull();
|
||||
result.Message.Should().Contain("below minimum supported version");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Check_LeadingV_Stripped()
|
||||
{
|
||||
// Arrange & Act
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var result = DockerVersionPolicy.Check("v24.0.0");
|
||||
|
||||
// Assert
|
||||
result.IsSupported.Should().BeTrue();
|
||||
result.IsRecommended.Should().BeTrue();
|
||||
result.ParsedVersion.Should().NotBeNull();
|
||||
result.ParsedVersion!.Major.Should().Be(24);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Check_NullOrEmpty_NotSupported(string? version)
|
||||
{
|
||||
// Arrange & Act
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var result = DockerVersionPolicy.Check(version);
|
||||
|
||||
// Assert
|
||||
result.IsSupported.Should().BeFalse();
|
||||
result.IsRecommended.Should().BeFalse();
|
||||
result.ParsedVersion.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Check_Invalid_NotSupported()
|
||||
{
|
||||
// Arrange & Act
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var result = DockerVersionPolicy.Check("invalid");
|
||||
|
||||
// Assert
|
||||
result.IsSupported.Should().BeFalse();
|
||||
result.IsRecommended.Should().BeFalse();
|
||||
result.ParsedVersion.Should().BeNull();
|
||||
result.Message.Should().Contain("Unable to parse");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user