79c69e1363
Co-authored-by: Copilot <copilot@github.com>
198 lines
8.5 KiB
C#
198 lines
8.5 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;
|
|
}
|
|
|
|
var record = await history.CreateReleaseAsync(targetEnv, ImageName);
|
|
|
|
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 OPC ticket numbers from recent commits on the target branch.
|
|
var branch = targetEnv switch { "fdev" => "develop", "staging" => "staging", "uat" => "uat", _ => "main" };
|
|
try { record.OpcNumbers = await promotions.ExtractOpcNumbersAsync("Clarity", branch, 50, ct); }
|
|
catch { /* git not configured — continue without OPC stamp */ }
|
|
|
|
await history.UpdateReleaseAsync(record);
|
|
_lock.Release();
|
|
}
|
|
|
|
return record;
|
|
}
|
|
}
|