Files
OPC/ControlPlane.Api/Services/ReleaseService.cs
T
2026-04-26 14:30:10 -04:00

210 lines
9.1 KiB
C#

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;
/// <summary>
/// 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.
/// </summary>
public class ReleaseService(
IConfiguration config,
TenantRegistryService registry,
BuildHistoryService history,
PromotionService promotions,
ILogger<ReleaseService> logger)
{
private static readonly SemaphoreSlim _lock = new(1, 1);
public bool IsReleasing => _lock.CurrentCount == 0;
public string ImageName => config["Docker:ClarityServerImage"] ?? "clarity-server:latest";
/// <summary>
/// Runs a release for the given environment and streams status lines to <paramref name="onLine"/>.
/// </summary>
public async Task<ReleaseRecord> ReleaseAsync(
string targetEnv,
Action<string> 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<string, IDictionary<string, bool>>
{
["label"] = new Dictionary<string, bool> { ["clarity.managed=true"] = true },
}
: new Dictionary<string, IDictionary<string, bool>>
{
["label"] = new Dictionary<string, bool>
{
["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;
}
}