OPC # 0001: Extract OPC into standalone repo
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
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,
|
||||
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
|
||||
{
|
||||
await history.UpdateReleaseAsync(record);
|
||||
_lock.Release();
|
||||
}
|
||||
|
||||
return record;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user