using ControlPlane.Core.Models; using ControlPlane.Core.Services; using Docker.DotNet; using Docker.DotNet.Models; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; namespace ControlPlane.Api.Services; /// /// Orchestrates a release: finds all managed tenant containers matching the target /// environment, removes each one, and restarts it from the latest clarity-server image. /// Does NOT re-run Keycloak/Vault/DB steps — the container env vars are preserved from /// the original provisioning and re-injected from the XML registry. /// public class ReleaseService( IConfiguration config, TenantRegistryService registry, BuildHistoryService history, PromotionService promotions, ILogger logger) { private static readonly SemaphoreSlim _lock = new(1, 1); public bool IsReleasing => _lock.CurrentCount == 0; public string ImageName => config["Docker:ClarityServerImage"] ?? "clarity-server:latest"; /// /// Runs a release for the given environment and streams status lines to . /// public async Task ReleaseAsync( string targetEnv, Action onLine, CancellationToken ct) { if (!await _lock.WaitAsync(TimeSpan.Zero, ct)) { onLine("⚠️ A release is already in progress."); var blocked = new ReleaseRecord { Environment = targetEnv, ImageName = ImageName, Status = ReleaseStatus.Failed, FinishedAt = DateTimeOffset.UtcNow, }; blocked.Tenants.Add(new TenantReleaseResult { Subdomain = "*", ContainerName = "*", Success = false, Error = "Release already in progress.", }); return blocked; } // Resolve the Clarity branch for this environment and stamp the HEAD SHA // before creating the record so we capture "what was deployed" accurately. var branch = targetEnv switch { "fdev" => "develop", "staging" => "staging", "uat" => "uat", _ => "main" }; var currentSha = promotions.GetBranchTipSha("Clarity", branch); var record = await history.CreateReleaseAsync(targetEnv, ImageName, currentSha); try { onLine($"▶ Release to [{targetEnv}] using {ImageName}"); onLine("──────────────────────────────────────"); var socketUri = config["Docker:Socket"] ?? "npipe://./pipe/docker_engine"; using var docker = new DockerClientConfiguration(new Uri(socketUri)).CreateClient(); // Find all managed tenant containers for this environment var filterEnv = targetEnv == "all" ? new Dictionary> { ["label"] = new Dictionary { ["clarity.managed=true"] = true }, } : new Dictionary> { ["label"] = new Dictionary { ["clarity.managed=true"] = true, [$"clarity.env={targetEnv}"] = true, }, }; var containers = await docker.Containers.ListContainersAsync( new ContainersListParameters { All = true, Filters = filterEnv }, ct); if (containers.Count == 0) { onLine($" No managed containers found for environment [{targetEnv}]."); record.Status = ReleaseStatus.Succeeded; record.FinishedAt = DateTimeOffset.UtcNow; await history.UpdateReleaseAsync(record); return record; } onLine($" Found {containers.Count} container(s) to redeploy."); onLine(""); int succeeded = 0, failed = 0; foreach (var container in containers) { var name = container.Names.FirstOrDefault()?.TrimStart('/') ?? container.ID[..12]; var tenantResult = new TenantReleaseResult { ContainerName = name, Subdomain = container.Labels.TryGetValue("clarity.subdomain", out var sub) ? sub : name, }; record.Tenants.Add(tenantResult); try { onLine($" → {name}"); // Read env vars from existing container — preserve Keycloak/Vault/DB config var inspect = await docker.Containers.InspectContainerAsync(container.ID, ct); var env = inspect.Config.Env; var labels = inspect.Config.Labels; var network = inspect.HostConfig.NetworkMode; // Stop and remove old container onLine($" Stopping..."); try { await docker.Containers.StopContainerAsync( container.ID, new ContainerStopParameters { WaitBeforeKillSeconds = 8 }, ct); await docker.Containers.RemoveContainerAsync( container.ID, new ContainerRemoveParameters { Force = true }, ct); } catch (Exception ex) { logger.LogWarning(ex, "Stop/remove failed for {Name}, forcing removal.", name); await docker.Containers.RemoveContainerAsync( container.ID, new ContainerRemoveParameters { Force = true }, ct); } // Create fresh container from latest image, preserving all env vars and labels onLine($" Creating from {ImageName}..."); var created = await docker.Containers.CreateContainerAsync( new CreateContainerParameters { Name = name, Image = ImageName, Env = env, Labels = labels, HostConfig = new HostConfig { NetworkMode = network, RestartPolicy = new RestartPolicy { Name = RestartPolicyKind.UnlessStopped }, }, }, ct); // Start it var started = await docker.Containers.StartContainerAsync(created.ID, null, ct); if (!started) throw new InvalidOperationException("Docker returned false for start."); onLine($" ✔ {name} redeployed."); tenantResult.Success = true; succeeded++; } catch (Exception ex) { logger.LogError(ex, "Failed to redeploy {Name}.", name); onLine($" ✖ {name} failed: {ex.Message}"); tenantResult.Success = false; tenantResult.Error = ex.Message; failed++; } await history.UpdateReleaseAsync(record); } record.Status = failed == 0 ? ReleaseStatus.Succeeded : succeeded == 0 ? ReleaseStatus.Failed : ReleaseStatus.PartialFailure; record.FinishedAt = DateTimeOffset.UtcNow; onLine(""); onLine("──────────────────────────────────────"); onLine($"{(record.Status == ReleaseStatus.Succeeded ? "✔" : "⚠")} Release complete — {succeeded} succeeded, {failed} failed."); } catch (Exception ex) { logger.LogError(ex, "Release to [{Env}] threw an unhandled exception.", targetEnv); record.Status = ReleaseStatus.Failed; record.FinishedAt = DateTimeOffset.UtcNow; onLine($"✖ Release aborted: {ex.Message}"); } finally { // Stamp the exact OPC ticket numbers introduced by this release: // diff from previous release's SHA to this release's SHA on the Clarity branch. try { var prev = await history.GetLastSuccessfulReleaseForEnvAsync(targetEnv); // Exclude the current (in-flight) record — it's not succeeded yet var prevSha = prev?.Id == record.Id ? null : prev?.CommitSha; if (currentSha is not null) record.OpcNumbers = await promotions.ExtractOpcNumbersDeltaAsync("Clarity", currentSha, prevSha, ct); } catch { /* git not configured — continue without OPC stamp */ } await history.UpdateReleaseAsync(record); _lock.Release(); } return record; } }