From af09c64555ef6c711a6ad9a34ca89dcaddab9620 Mon Sep 17 00:00:00 2001 From: Eddie Wassef Date: Tue, 15 Jul 2025 10:57:26 -0500 Subject: [PATCH 1/4] Add FallbackDockerEngine with intelligent fallback logic Introduces FallbackDockerEngine to provide a process-based Docker engine fallback when the programmatic client cannot connect. ServiceProviderBuilder now selects the fallback engine based on connection status and caches fallback state for 2 hours. Updates IDockerEngine and LocalDockerClient to support CanConnect(). Adds tests for FallbackDockerEngine. Also updates several package versions and changes RegistryHostPort to 50000. --- cli/src/Vdk/Constants/Containers.cs | 2 +- cli/src/Vdk/ServiceProviderBuilder.cs | 45 ++++++++- cli/src/Vdk/Services/FallbackDockerEngine.cs | 91 +++++++++++++++++++ cli/src/Vdk/Services/IDockerEngine.cs | 2 + cli/src/Vdk/Services/LocalDockerClient.cs | 13 +++ cli/src/Vdk/Vdk.csproj | 10 +- .../Vdk.Tests/FallbackDockerEngineTests.cs | 60 ++++++++++++ 7 files changed, 216 insertions(+), 7 deletions(-) create mode 100644 cli/src/Vdk/Services/FallbackDockerEngine.cs create mode 100644 cli/tests/Vdk.Tests/FallbackDockerEngineTests.cs diff --git a/cli/src/Vdk/Constants/Containers.cs b/cli/src/Vdk/Constants/Containers.cs index e71a420..2dba5eb 100644 --- a/cli/src/Vdk/Constants/Containers.cs +++ b/cli/src/Vdk/Constants/Containers.cs @@ -5,7 +5,7 @@ public static class Containers public const string RegistryName = "vega-registry"; public const string RegistryImage = "registry:2"; public const int RegistryContainerPort = 5000; - public const int RegistryHostPort = 5000; + public const int RegistryHostPort = 50000; public const string ProxyName = "vega-proxy"; public const string ProxyImage = "nginx:latest"; public const string CloudProviderKindName = "cloud-provider-kind"; diff --git a/cli/src/Vdk/ServiceProviderBuilder.cs b/cli/src/Vdk/ServiceProviderBuilder.cs index 95c8de4..73dae71 100644 --- a/cli/src/Vdk/ServiceProviderBuilder.cs +++ b/cli/src/Vdk/ServiceProviderBuilder.cs @@ -57,7 +57,50 @@ public static IServiceProvider Build() .AddSingleton() .AddSingleton() .AddSingleton() - .AddSingleton() + .AddSingleton(provider => + { + // Intelligent fallback logic + var config = provider.GetRequiredService(); + var fallbackFile = System.IO.Path.Combine(config.VegaDirectory, ".vdk_docker_fallback"); + DateTime now = DateTime.UtcNow; + DateTime fallbackUntil = DateTime.MinValue; + + if (System.IO.File.Exists(fallbackFile)) + { + try + { + var content = System.IO.File.ReadAllText(fallbackFile).Trim(); + if (DateTime.TryParse(content, null, System.Globalization.DateTimeStyles.AdjustToUniversal, out var parsed)) + { + fallbackUntil = parsed; + } + } + catch { /* ignore file errors */ } + } + + if (fallbackUntil > now) + { + // Still in fallback window + return new FallbackDockerEngine(); + } + + var docker = provider.GetService(); + if (docker == null) return new FallbackDockerEngine(); + + var programatic = new LocalDockerClient(docker); + if (!programatic.CanConnect()) + { + // Write fallback timestamp for 2 hours + try + { + System.IO.File.WriteAllText(fallbackFile, now.AddHours(2).ToString("o")); + } + catch { /* ignore file errors */ } + return new FallbackDockerEngine(); + } + + return programatic; + }) .AddSingleton() .AddSingleton(gitHubClient) .AddSingleton(new GlobalConfiguration()) diff --git a/cli/src/Vdk/Services/FallbackDockerEngine.cs b/cli/src/Vdk/Services/FallbackDockerEngine.cs new file mode 100644 index 0000000..f9795a5 --- /dev/null +++ b/cli/src/Vdk/Services/FallbackDockerEngine.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using Vdk.Models; + +namespace Vdk.Services +{ + public class FallbackDockerEngine : IDockerEngine + { + internal static bool RunProcess(string fileName, string arguments, out string stdOut, out string stdErr) + { + using var process = new Process(); + process.StartInfo.FileName = fileName; + process.StartInfo.Arguments = arguments; + process.StartInfo.RedirectStandardOutput = true; + process.StartInfo.RedirectStandardError = true; + process.StartInfo.UseShellExecute = false; + process.StartInfo.CreateNoWindow = true; + process.Start(); + stdOut = process.StandardOutput.ReadToEnd(); + stdErr = process.StandardError.ReadToEnd(); + process.WaitForExit(); + return process.ExitCode == 0; + } + + public bool Run(string image, string name, PortMapping[]? ports, Dictionary? envs, FileMapping[]? volumes, string[]? commands, string? network = null) + { + var args = $"run -d --name {name}"; + if (network != null) + args += $" --network {network}"; + if (ports != null) + { + foreach (var p in ports) + { + var proto = string.IsNullOrWhiteSpace(p.Protocol) ? "" : "/" + p.Protocol.ToLower(); + var listen = string.IsNullOrWhiteSpace(p.ListenAddress) ? "" : p.ListenAddress + ":"; + args += $" -p {listen}{p.HostPort}:{p.ContainerPort}{proto}"; + } + } + if (envs != null) + { + foreach (var kv in envs) + args += $" -e \"{kv.Key}={kv.Value}\""; + } + if (volumes != null) + { + foreach (var v in volumes) + args += $" -v \"{v.Source}:{v.Destination}\""; + } + args += $" {image}"; + if (commands != null && commands.Length > 0) + args += " " + string.Join(" ", commands); + return RunProcess("docker", args, out _, out _); + } + + public bool Exists(string name, bool checkRunning = true) + { + var filter = checkRunning ? "--filter \"status=running\"" : ""; + var args = $"ps -a {filter} --filter \"name=^{name}$\" --format \"{{{{.Names}}}}\""; + var ok = RunProcess("docker", args, out var output, out _); + if (!ok) return false; + foreach (var line in output.Split('\n')) + if (line.Trim() == name) return true; + return false; + } + + public bool Delete(string name) + { + // Try to remove forcefully (stops if running) + return RunProcess("docker", $"rm -f {name}", out _, out _); + } + + public bool Stop(string name) + { + return RunProcess("docker", $"stop {name}", out _, out _); + } + + public bool Exec(string name, string[] commands) + { + var args = $"exec {name} "; + args += string.Join(" ", commands); + return RunProcess("docker", args, out _, out _); + } + + public bool CanConnect() + { + var args = $"ps"; + return RunProcess("docker", args, out _, out _); + } + } +} diff --git a/cli/src/Vdk/Services/IDockerEngine.cs b/cli/src/Vdk/Services/IDockerEngine.cs index 4867973..f2c90c6 100644 --- a/cli/src/Vdk/Services/IDockerEngine.cs +++ b/cli/src/Vdk/Services/IDockerEngine.cs @@ -13,4 +13,6 @@ public interface IDockerEngine bool Stop(string name); bool Exec(string name, string[] commands); + + bool CanConnect(); } \ No newline at end of file diff --git a/cli/src/Vdk/Services/LocalDockerClient.cs b/cli/src/Vdk/Services/LocalDockerClient.cs index fe1f6c4..4cd7aa2 100644 --- a/cli/src/Vdk/Services/LocalDockerClient.cs +++ b/cli/src/Vdk/Services/LocalDockerClient.cs @@ -134,4 +134,17 @@ public bool Exec(string name, string[] commands) }).GetAwaiter().GetResult(); return true; } + + public bool CanConnect() + { + try + { + _dockerClient.Images.ListImagesAsync(new ImagesListParameters()).GetAwaiter().GetResult(); + return true; + } + catch + { + return false; + } + } } \ No newline at end of file diff --git a/cli/src/Vdk/Vdk.csproj b/cli/src/Vdk/Vdk.csproj index 2eb81bd..d77ca8f 100644 --- a/cli/src/Vdk/Vdk.csproj +++ b/cli/src/Vdk/Vdk.csproj @@ -19,15 +19,15 @@ - - + + - - - + + + diff --git a/cli/tests/Vdk.Tests/FallbackDockerEngineTests.cs b/cli/tests/Vdk.Tests/FallbackDockerEngineTests.cs new file mode 100644 index 0000000..0655d8c --- /dev/null +++ b/cli/tests/Vdk.Tests/FallbackDockerEngineTests.cs @@ -0,0 +1,60 @@ +using Vdk.Models; +using Vdk.Services; + +namespace Vdk.Tests +{ + public class FallbackDockerEngineTests : IDisposable + { + private readonly FallbackDockerEngine _engine = new(); + private readonly string _containerName = $"vdk_test_{Guid.NewGuid().ToString().Substring(0, 8)}"; + private readonly string _image = "alpine"; + + public FallbackDockerEngineTests() + { + // Pull image to avoid network flakiness in tests + FallbackDockerEngine.RunProcess("docker", $"pull {_image}", out _, out _); + } + + [Fact] + public void Run_And_Exists_And_Delete_Works() + { + var result = _engine.Run(_image, _containerName, null, null, null, new[] { "sleep", "60" }); + Assert.True(result, "Container should start"); + Assert.True(_engine.Exists(_containerName), "Container should exist and be running"); + Assert.True(_engine.Delete(_containerName), "Container should be deleted"); + Assert.False(_engine.Exists(_containerName, false), "Container should not exist after delete"); + } + + [Fact] + public void Stop_And_Exec_Works() + { + var started = _engine.Run(_image, _containerName, null, null, null, new[] { "sleep", "60" }); + Assert.True(started, "Container should start"); + // Exec a command + Assert.True(_engine.Exec(_containerName, new[] { "sh", "-c", "echo hello" }), "Exec should succeed"); + // Stop + Assert.True(_engine.Stop(_containerName), "Stop should succeed"); + // After stop, Exists with checkRunning=true should be false + Assert.False(_engine.Exists(_containerName, true), "Container should not be running after stop"); + // Delete + Assert.True(_engine.Delete(_containerName), "Container should be deleted"); + } + + [Fact] + public void Run_With_Ports_And_Volumes_Works() + { + var ports = new[] { new PortMapping { HostPort = 12345, ContainerPort = 80 } }; + var volumes = new[] { new FileMapping { Source = "/tmp", Destination = "/mnt" } }; + var result = _engine.Run(_image, _containerName, ports, null, volumes, new[] { "sleep", "60" }); + Assert.True(result, "Container should start with ports and volumes"); + Assert.True(_engine.Exists(_containerName), "Container should exist"); + Assert.True(_engine.Delete(_containerName), "Container should be deleted"); + } + + public void Dispose() + { + // Cleanup in case test fails + _engine.Delete(_containerName); + } + } +} From cb5984a238f3731250647efeee31c8fc86ed93dd Mon Sep 17 00:00:00 2001 From: Eddie Wassef Date: Tue, 15 Jul 2025 11:39:26 -0500 Subject: [PATCH 2/4] Refactor DockerEngine fallback logic and add tests Replaced direct System.IO usage with IFileSystem in ServiceProviderBuilder for improved testability. Added unit tests for DockerEngine fallback logic in ServiceProviderBuilderDockerEngineTests. Changed _profileDirectory visibility to internal in GlobalConfiguration. --- cli/src/Vdk/GlobalConfiguration.cs | 2 +- cli/src/Vdk/ServiceProviderBuilder.cs | 9 +- ...ServiceProviderBuilderDockerEngineTests.cs | 134 ++++++++++++++++++ 3 files changed, 140 insertions(+), 5 deletions(-) create mode 100644 cli/tests/Vdk.Tests/ServiceProviderBuilderDockerEngineTests.cs diff --git a/cli/src/Vdk/GlobalConfiguration.cs b/cli/src/Vdk/GlobalConfiguration.cs index 336eea9..b6aa543 100644 --- a/cli/src/Vdk/GlobalConfiguration.cs +++ b/cli/src/Vdk/GlobalConfiguration.cs @@ -4,7 +4,7 @@ namespace Vdk; public class GlobalConfiguration { - private string? _profileDirectory = null; + internal string? _profileDirectory = null; public string ConfigDirectoryName { get; set; } = Defaults.ConfigDirectoryName; diff --git a/cli/src/Vdk/ServiceProviderBuilder.cs b/cli/src/Vdk/ServiceProviderBuilder.cs index 73dae71..05138a7 100644 --- a/cli/src/Vdk/ServiceProviderBuilder.cs +++ b/cli/src/Vdk/ServiceProviderBuilder.cs @@ -60,16 +60,17 @@ public static IServiceProvider Build() .AddSingleton(provider => { // Intelligent fallback logic + var fileSystem = provider.GetRequiredService(); var config = provider.GetRequiredService(); - var fallbackFile = System.IO.Path.Combine(config.VegaDirectory, ".vdk_docker_fallback"); + var fallbackFile = fileSystem.Path.Combine(config.VegaDirectory, ".vdk_docker_fallback"); DateTime now = DateTime.UtcNow; DateTime fallbackUntil = DateTime.MinValue; - if (System.IO.File.Exists(fallbackFile)) + if (fileSystem.File.Exists(fallbackFile)) { try { - var content = System.IO.File.ReadAllText(fallbackFile).Trim(); + var content = fileSystem.File.ReadAllText(fallbackFile).Trim(); if (DateTime.TryParse(content, null, System.Globalization.DateTimeStyles.AdjustToUniversal, out var parsed)) { fallbackUntil = parsed; @@ -93,7 +94,7 @@ public static IServiceProvider Build() // Write fallback timestamp for 2 hours try { - System.IO.File.WriteAllText(fallbackFile, now.AddHours(2).ToString("o")); + fileSystem.File.WriteAllText(fallbackFile, now.AddHours(2).ToString("o")); } catch { /* ignore file errors */ } return new FallbackDockerEngine(); diff --git a/cli/tests/Vdk.Tests/ServiceProviderBuilderDockerEngineTests.cs b/cli/tests/Vdk.Tests/ServiceProviderBuilderDockerEngineTests.cs new file mode 100644 index 0000000..77bfe69 --- /dev/null +++ b/cli/tests/Vdk.Tests/ServiceProviderBuilderDockerEngineTests.cs @@ -0,0 +1,134 @@ +using System; +using System.IO.Abstractions; +using Docker.DotNet; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Vdk.Services; +using Xunit; +using Vdk.Models; + +namespace Vdk.Tests +{ + public class ServiceProviderBuilderDockerEngineTests + { + private readonly Mock _fileSystemMock = new(); + private readonly Mock _dockerClientMock = new(); + private readonly Mock _programmaticEngineMock = new(); + private readonly GlobalConfiguration _config; + private readonly string _fallbackFile; + private readonly ServiceCollection _services; + + public ServiceProviderBuilderDockerEngineTests() + { + _config = new GlobalConfiguration(); + _config._profileDirectory = "/tmp/vdk-test-vega"; + _fallbackFile = System.IO.Path.Combine(_config.VegaDirectory, ".vdk_docker_fallback"); + _services = new ServiceCollection(); + _services.AddSingleton(_fileSystemMock.Object); + _services.AddSingleton(_dockerClientMock.Object); + _services.AddSingleton(_config); + } + + [Fact] + public void UsesFallback_WhenFileHasFutureTimestamp() + { + _fileSystemMock.Setup(f => f.File.Exists(_fallbackFile)).Returns(true); + _fileSystemMock.Setup(f => f.File.ReadAllText(_fallbackFile)).Returns(DateTime.UtcNow.AddHours(1).ToString("o")); + var engine = BuildAndResolveEngine(); + Assert.IsType(engine); + } + + [Fact] + public void UsesProgrammatic_WhenNoFile_AndCanConnect() + { + _fileSystemMock.Setup(f => f.File.Exists(_fallbackFile)).Returns(false); + _programmaticEngineMock.Setup(x => x.CanConnect()).Returns(true); + var engine = BuildAndResolveEngine(); + Assert.Equal(_programmaticEngineMock.Object, engine); + } + + [Fact] + public void UsesFallback_WhenNoFile_AndCannotConnect() + { + _fileSystemMock.Setup(f => f.File.Exists(_fallbackFile)).Returns(false); + _programmaticEngineMock.Setup(x => x.CanConnect()).Returns(false); + _fileSystemMock.Setup(f => f.File.WriteAllText(_fallbackFile, It.IsAny())); + var engine = BuildAndResolveEngine(); + Assert.IsType(engine); + _fileSystemMock.Verify(f => f.File.WriteAllText(_fallbackFile, It.IsAny()), Times.Once); + } + + [Fact] + public void IgnoresInvalidFileContent() + { + _fileSystemMock.Setup(f => f.File.Exists(_fallbackFile)).Returns(true); + _fileSystemMock.Setup(f => f.File.ReadAllText(_fallbackFile)).Returns("not-a-date"); + _programmaticEngineMock.Setup(x => x.CanConnect()).Returns(true); + var engine = BuildAndResolveEngine(); + Assert.Equal(_programmaticEngineMock.Object, engine); + } + + [Fact] + public void UsesFallback_WhenFileHasPastTimestamp_AndCannotConnect() + { + _fileSystemMock.Setup(f => f.File.Exists(_fallbackFile)).Returns(true); + _fileSystemMock.Setup(f => f.File.ReadAllText(_fallbackFile)).Returns(DateTime.UtcNow.AddHours(-1).ToString("o")); + _programmaticEngineMock.Setup(x => x.CanConnect()).Returns(false); + _fileSystemMock.Setup(f => f.File.WriteAllText(_fallbackFile, It.IsAny())); + var engine = BuildAndResolveEngine(); + Assert.IsType(engine); + _fileSystemMock.Verify(f => f.File.WriteAllText(_fallbackFile, It.IsAny()), Times.Once); + } + + private IDockerEngine BuildAndResolveEngine() + { + _services.AddSingleton(provider => + { + var fileSystem = provider.GetRequiredService(); + var config = provider.GetRequiredService(); + var fallbackFile = System.IO.Path.Combine(config.VegaDirectory, ".vdk_docker_fallback"); + DateTime now = DateTime.UtcNow; + DateTime fallbackUntil = DateTime.MinValue; + + if (fileSystem.File.Exists(fallbackFile)) + { + try + { + var content = fileSystem.File.ReadAllText(fallbackFile).Trim(); + if (DateTime.TryParse(content, null, System.Globalization.DateTimeStyles.AdjustToUniversal, out var parsed)) + { + fallbackUntil = parsed; + } + } + catch { /* ignore file errors */ } + } + + if (fallbackUntil > now) + { + // Still in fallback window + return new FallbackDockerEngine(); + } + + var docker = provider.GetService(); + if (docker == null) return new FallbackDockerEngine(); + + var programatic = _programmaticEngineMock.Object; + if (!programatic.CanConnect()) + { + // Write fallback timestamp for 2 hours + try + { + fileSystem.File.WriteAllText(fallbackFile, now.AddHours(2).ToString("o")); + } + catch { /* ignore file errors */ } + return new FallbackDockerEngine(); + } + + return programatic; + }); + + var provider = _services.BuildServiceProvider(); + return provider.GetRequiredService(); + } + } +} From a1a3f6ade533736961077d6347becf61cf00cb4a Mon Sep 17 00:00:00 2001 From: Eddie Wassef Date: Thu, 17 Jul 2025 12:40:05 -0500 Subject: [PATCH 3/4] Update build matrix to support macOS runners Refactors the build matrix to specify runner types per runtime identifier, enabling builds on macOS runners for osx-x64 and osx-arm64. Also updates steps to conditionally install zip only on Linux and renames steps for clarity. --- .github/workflows/build.yaml | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index bb22f23..b26b046 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -10,10 +10,24 @@ on: - completed jobs: build: - runs-on: ubuntu-latest + runs-on: ${{ matrix.runner }} strategy: matrix: - rid: [linux-x64, linux-arm, linux-arm64, linux-musl-x64, linux-musl-arm64, osx-x64, osx-arm64] + include: + - rid: linux-x64 + runner: ubuntu-latest + - rid: linux-arm + runner: ubuntu-latest + - rid: linux-arm64 + runner: ubuntu-latest + - rid: linux-musl-x64 + runner: ubuntu-latest + - rid: linux-musl-arm64 + runner: ubuntu-latest + - rid: osx-x64 + runner: macos-15-large + - rid: osx-arm64 + runner: macos-15 steps: - name: Checkout code uses: actions/checkout@v4 @@ -40,10 +54,14 @@ jobs: - name: Test run: dotnet test ./cli --no-build --configuration Release --verbosity normal - - name: Install Zip Utility - run: sudo apt-get install -y zip && mkdir -p ./artifacts + - name: Install Zip Utility and Create Artifacts Directory + run: | + if [ "$RUNNER_OS" == "Linux" ]; then + sudo apt-get install -y zip + fi + mkdir -p ./artifacts - - name: Publish Linux Build + - name: Publish Build run: | RUNTIME_ID=${{ matrix.rid }} OUTPUT_DIR=./packages/build/${RUNTIME_ID} From 4fdfa09bcce2026dae957c2c29395ee989b3468a Mon Sep 17 00:00:00 2001 From: Eddie Date: Thu, 17 Jul 2025 12:49:57 -0500 Subject: [PATCH 4/4] Update cli/src/Vdk/Services/LocalDockerClient.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Eddie --- cli/src/Vdk/Services/LocalDockerClient.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cli/src/Vdk/Services/LocalDockerClient.cs b/cli/src/Vdk/Services/LocalDockerClient.cs index 4cd7aa2..695264f 100644 --- a/cli/src/Vdk/Services/LocalDockerClient.cs +++ b/cli/src/Vdk/Services/LocalDockerClient.cs @@ -142,8 +142,9 @@ public bool CanConnect() _dockerClient.Images.ListImagesAsync(new ImagesListParameters()).GetAwaiter().GetResult(); return true; } - catch + catch (Exception ex) { + Console.Error.WriteLine($"Error in CanConnect: {ex.Message}"); return false; } }