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,
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;
}
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>
{
["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
{
await history.UpdateReleaseAsync(record);
_lock.Release();
}
return record;
}
}