From b4d46783b34283697838eb50081ac0d2f2c3a361 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Mon, 24 Aug 2026 10:02:19 +0200 Subject: [PATCH] feat(core): add Docker container provider --- .../Docker/DockerContainerParser.cs | 46 ++++++++++++++++++ src/Vantage.Core/Docker/IDockerProvider.cs | 17 +++++++ src/Vantage.Core/Docker/MockDockerProvider.cs | 47 +++++++++++++++++++ src/Vantage.Core/Docker/SshDockerProvider.cs | 35 ++++++++++++++ 4 files changed, 145 insertions(+) create mode 100644 src/Vantage.Core/Docker/DockerContainerParser.cs create mode 100644 src/Vantage.Core/Docker/IDockerProvider.cs create mode 100644 src/Vantage.Core/Docker/MockDockerProvider.cs create mode 100644 src/Vantage.Core/Docker/SshDockerProvider.cs diff --git a/src/Vantage.Core/Docker/DockerContainerParser.cs b/src/Vantage.Core/Docker/DockerContainerParser.cs new file mode 100644 index 0000000..e13d69e --- /dev/null +++ b/src/Vantage.Core/Docker/DockerContainerParser.cs @@ -0,0 +1,46 @@ +using Vantage.Core.Models; + +namespace Vantage.Core.Docker; + +public static class DockerContainerParser +{ + public const string Format = "{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.State}}\t{{.Status}}"; + + public static IReadOnlyList Parse(string psOutput) + { + var containers = new List(); + + foreach (var rawLine in psOutput.Replace("\r\n", "\n").Split('\n')) + { + var line = rawLine.TrimEnd(); + if (line.Length == 0) + continue; + + var fields = line.Split('\t'); + if (fields.Length < 5) + continue; + + containers.Add(new DockerContainer + { + Id = fields[0].Trim(), + Name = fields[1].Trim(), + Image = fields[2].Trim(), + State = MapState(fields[3].Trim()), + StatusText = fields[4].Trim() + }); + } + + return containers; + } + + private static ContainerState MapState(string state) => state.ToLowerInvariant() switch + { + "running" => ContainerState.Running, + "exited" => ContainerState.Exited, + "paused" => ContainerState.Paused, + "restarting" => ContainerState.Restarting, + "created" => ContainerState.Created, + "dead" => ContainerState.Dead, + _ => ContainerState.Other + }; +} diff --git a/src/Vantage.Core/Docker/IDockerProvider.cs b/src/Vantage.Core/Docker/IDockerProvider.cs new file mode 100644 index 0000000..dcfb515 --- /dev/null +++ b/src/Vantage.Core/Docker/IDockerProvider.cs @@ -0,0 +1,17 @@ +using Vantage.Core.Models; + +namespace Vantage.Core.Docker; + +public enum DockerAction +{ + Start, + Stop, + Restart +} + +public interface IDockerProvider +{ + Task> ListContainersAsync(HostConfig host, CancellationToken cancellationToken); + + Task ExecuteAsync(HostConfig host, DockerAction action, string containerId, CancellationToken cancellationToken); +} diff --git a/src/Vantage.Core/Docker/MockDockerProvider.cs b/src/Vantage.Core/Docker/MockDockerProvider.cs new file mode 100644 index 0000000..0846989 --- /dev/null +++ b/src/Vantage.Core/Docker/MockDockerProvider.cs @@ -0,0 +1,47 @@ +using System.Collections.Concurrent; +using Vantage.Core.Models; + +namespace Vantage.Core.Docker; + +public sealed class MockDockerProvider : IDockerProvider +{ + private readonly ConcurrentDictionary> byHost = new(); + + public Task> ListContainersAsync(HostConfig host, CancellationToken cancellationToken) + { + var containers = byHost.GetOrAdd(host.Id, _ => Seed()); + lock (containers) + return Task.FromResult>(containers.ToArray()); + } + + public Task ExecuteAsync(HostConfig host, DockerAction action, string containerId, CancellationToken cancellationToken) + { + var containers = byHost.GetOrAdd(host.Id, _ => Seed()); + lock (containers) + { + var index = containers.FindIndex(c => c.Id == containerId); + if (index < 0) + return Task.CompletedTask; + + containers[index] = Transition(containers[index], action); + } + + return Task.CompletedTask; + } + + private static DockerContainer Transition(DockerContainer container, DockerAction action) => action switch + { + DockerAction.Stop => container with { State = ContainerState.Exited, StatusText = "Exited (0) just now" }, + DockerAction.Start => container with { State = ContainerState.Running, StatusText = "Up 1 second" }, + DockerAction.Restart => container with { State = ContainerState.Running, StatusText = "Up 1 second (restarted)" }, + _ => container + }; + + private static List Seed() => new() + { + new DockerContainer { Id = "c1a2b3c4d5e6", Name = "caddy", Image = "caddy:2", State = ContainerState.Running, StatusText = "Up 6 days" }, + new DockerContainer { Id = "f7e8d9c0b1a2", Name = "postgres", Image = "postgres:16", State = ContainerState.Running, StatusText = "Up 6 days (healthy)" }, + new DockerContainer { Id = "1234abcd5678", Name = "grafana", Image = "grafana/grafana:11", State = ContainerState.Running, StatusText = "Up 2 days" }, + new DockerContainer { Id = "9f8e7d6c5b4a", Name = "backup-runner", Image = "alpine:3.20", State = ContainerState.Exited, StatusText = "Exited (0) 3 hours ago" } + }; +} diff --git a/src/Vantage.Core/Docker/SshDockerProvider.cs b/src/Vantage.Core/Docker/SshDockerProvider.cs new file mode 100644 index 0000000..16ca19e --- /dev/null +++ b/src/Vantage.Core/Docker/SshDockerProvider.cs @@ -0,0 +1,35 @@ +using Vantage.Core.Models; +using Vantage.Core.Ssh; + +namespace Vantage.Core.Docker; + +public sealed class SshDockerProvider : IDockerProvider +{ + private readonly ISshCommandRunner runner; + + public SshDockerProvider(ISshCommandRunner runner) + { + this.runner = runner; + } + + public async Task> ListContainersAsync(HostConfig host, CancellationToken cancellationToken) + { + var command = $"docker ps -a --no-trunc --format {ShellArgs.Quote(DockerContainerParser.Format)}"; + var output = await runner.RunAsync(host, command, cancellationToken).ConfigureAwait(false); + return DockerContainerParser.Parse(output); + } + + public async Task ExecuteAsync(HostConfig host, DockerAction action, string containerId, CancellationToken cancellationToken) + { + var verb = action switch + { + DockerAction.Start => "start", + DockerAction.Stop => "stop", + DockerAction.Restart => "restart", + _ => throw new ArgumentOutOfRangeException(nameof(action)) + }; + + var command = $"docker {verb} {ShellArgs.Quote(containerId)}"; + await runner.RunAsync(host, command, cancellationToken).ConfigureAwait(false); + } +}