OPC # 0001: Extract OPC into standalone repo
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
using ControlPlane.Core.Models;
|
||||
using ControlPlane.Core.Services;
|
||||
using Docker.DotNet;
|
||||
using Docker.DotNet.Models;
|
||||
|
||||
namespace ControlPlane.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Drives `docker build` for the clarity-server image via the Docker SDK.
|
||||
/// Streams each build log line to the provided callback so the API endpoint
|
||||
/// can forward it as SSE to the control plane UI in real time.
|
||||
/// Persists build history via BuildHistoryService.
|
||||
/// </summary>
|
||||
public class ImageBuildService(
|
||||
IConfiguration config,
|
||||
BuildHistoryService history,
|
||||
ILogger<ImageBuildService> logger)
|
||||
{
|
||||
private static readonly SemaphoreSlim _lock = new(1, 1);
|
||||
|
||||
public bool IsBuilding => _lock.CurrentCount == 0;
|
||||
public string ImageName => config["Docker:ClarityServerImage"] ?? "clarity-server:latest";
|
||||
|
||||
public async Task<ImageBuildStatus> GetStatusAsync()
|
||||
{
|
||||
var builds = await history.GetBuildsAsync();
|
||||
var last = builds.FirstOrDefault(b => b.Kind == BuildKind.DockerImage);
|
||||
return new ImageBuildStatus(
|
||||
last?.Target,
|
||||
last?.FinishedAt,
|
||||
last?.Status.ToString() ?? "Never built",
|
||||
IsBuilding);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs docker build and streams each log line to <paramref name="onLine"/>.
|
||||
/// Returns true on success, false if the build failed or was already running.
|
||||
/// </summary>
|
||||
public async Task<bool> BuildAsync(
|
||||
string repoRoot,
|
||||
Action<string> onLine,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!await _lock.WaitAsync(TimeSpan.Zero, ct))
|
||||
{
|
||||
onLine("⚠️ A build is already in progress.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var record = await history.CreateBuildAsync(BuildKind.DockerImage, ImageName);
|
||||
|
||||
try
|
||||
{
|
||||
var socketUri = config["Docker:Socket"] ?? "npipe://./pipe/docker_engine";
|
||||
using var docker = new DockerClientConfiguration(new Uri(socketUri)).CreateClient();
|
||||
|
||||
var (repo, tag) = SplitImageTag(ImageName);
|
||||
var dockerfilePath = "Clarity.Server/Dockerfile";
|
||||
|
||||
void Log(string line) { onLine(line); record.Log.Add(line); }
|
||||
|
||||
Log($"▶ Building {ImageName} from {repoRoot}");
|
||||
Log($" Dockerfile: {dockerfilePath}");
|
||||
Log("──────────────────────────────────────");
|
||||
|
||||
var buildParams = new ImageBuildParameters
|
||||
{
|
||||
Dockerfile = dockerfilePath,
|
||||
Tags = [$"{repo}:{tag}"],
|
||||
Remove = true,
|
||||
ForceRemove = true,
|
||||
};
|
||||
|
||||
bool success = true;
|
||||
string? errorDetail = null;
|
||||
|
||||
await docker.Images.BuildImageFromDockerfileAsync(
|
||||
buildParams,
|
||||
await CreateTarballAsync(repoRoot, ct),
|
||||
authConfigs: null,
|
||||
headers: null,
|
||||
new Progress<JSONMessage>(msg =>
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(msg.Stream))
|
||||
Log(msg.Stream.TrimEnd('\n'));
|
||||
|
||||
if (msg.Error is not null)
|
||||
{
|
||||
success = false;
|
||||
errorDetail = msg.Error.Message;
|
||||
Log($"✖ {msg.Error.Message}");
|
||||
}
|
||||
}),
|
||||
ct);
|
||||
|
||||
Log("──────────────────────────────────────");
|
||||
if (success) Log($"✔ {ImageName} built successfully at {DateTimeOffset.UtcNow:u}");
|
||||
else Log($"✖ Build failed: {errorDetail}");
|
||||
|
||||
await history.CompleteBuildAsync(record, success ? BuildStatus.Succeeded : BuildStatus.Failed);
|
||||
logger.LogInformation("Image build {Result} for {Image}", success ? "succeeded" : "failed", ImageName);
|
||||
return success;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
record.Log.Add($"Exception: {ex.Message}");
|
||||
await history.CompleteBuildAsync(record, BuildStatus.Failed);
|
||||
onLine($"✖ Exception during build: {ex.Message}");
|
||||
logger.LogError(ex, "Image build threw an exception.");
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Packs the entire repo root into a tar stream for the Docker build context.
|
||||
/// Respects .dockerignore if present.
|
||||
/// </summary>
|
||||
private static async Task<Stream> CreateTarballAsync(string repoRoot, CancellationToken ct)
|
||||
{
|
||||
// Use docker's own CLI to create the tarball via stdin — avoids reimplementing
|
||||
// .dockerignore parsing. Fall back to a pure managed tar if CLI isn't available.
|
||||
// For simplicity we use a managed approach: stream the directory as a tar.
|
||||
var ms = new MemoryStream();
|
||||
await Task.Run(() => TarHelper.Pack(repoRoot, ms), ct);
|
||||
ms.Position = 0;
|
||||
return ms;
|
||||
}
|
||||
|
||||
private static (string repo, string tag) SplitImageTag(string image)
|
||||
{
|
||||
var colon = image.LastIndexOf(':');
|
||||
return colon < 0 ? (image, "latest") : (image[..colon], image[(colon + 1)..]);
|
||||
}
|
||||
}
|
||||
|
||||
public record ImageBuildStatus(
|
||||
string? ImageName,
|
||||
DateTimeOffset? BuiltAt,
|
||||
string LastMessage,
|
||||
bool IsBuilding);
|
||||
Reference in New Issue
Block a user