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:
master
2026-03-16 08:12:39 +02:00
parent 602df77467
commit da76d6e93e
223 changed files with 24763 additions and 489 deletions

View File

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

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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();
}

View File

@@ -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());
}
}

View File

@@ -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;

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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();
}

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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);

View File

@@ -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');

View File

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

View File

@@ -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
}

View File

@@ -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; }
}

View File

@@ -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
}

View File

@@ -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; }
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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();
}

View File

@@ -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
];
}

View File

@@ -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
};
}
}

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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();
}

View File

@@ -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; }
}

View File

@@ -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 };
}

View File

@@ -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();
}

View File

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

View File

@@ -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);

View File

@@ -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));
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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");
}
}

View File

@@ -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");
}
}