Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions cli/src/Vdk/Commands/UpdateClustersCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ public async Task InvokeAsync(bool verbose = false)
var fullChainPath = _fileSystem.Path.Combine("Certs", "fullchain.pem");
var privKeyPath = _fileSystem.Path.Combine("Certs", "privkey.pem");

// Check for and fix certificate paths that were incorrectly created as directories
// This can happen on Mac when Docker creates directories for missing mount paths
FixCertificatePathIfDirectory(fullChainPath, verbose);
FixCertificatePathIfDirectory(privKeyPath, verbose);

if (!_fileSystem.File.Exists(fullChainPath) || !_fileSystem.File.Exists(privKeyPath))
{
_console.WriteError("Certificate files not found. Expected: Certs/fullchain.pem and Certs/privkey.pem");
Expand Down Expand Up @@ -412,4 +417,29 @@ private async Task RolloutRestartDeployment(IKubernetesClient client, V1Deployme

await Task.CompletedTask;
}

/// <summary>
/// Checks if a certificate path exists as a directory instead of a file
/// and removes it. On some systems (especially Mac), Docker may incorrectly
/// create directories when mounting paths that don't exist.
/// </summary>
private void FixCertificatePathIfDirectory(string path, bool verbose)
{
if (_fileSystem.Directory.Exists(path))
{
_console.WriteWarning($"Certificate path '{path}' exists as a directory instead of a file. Removing...");
try
{
_fileSystem.Directory.Delete(path, recursive: true);
if (verbose)
{
_console.WriteLine($"[DEBUG] Successfully removed directory '{path}'");
}
}
catch (Exception ex)
{
_console.WriteError($"Failed to remove directory '{path}': {ex.Message}");
}
}
}
}
2 changes: 1 addition & 1 deletion cli/src/Vdk/Constants/Containers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ namespace Vdk.Constants;
public static class Containers
{
public const string RegistryName = "vega-registry";
public const string RegistryImage = "ghcr.io/project-zot/zot-linux-amd64:v2.1.0";
public const string RegistryImage = "ghcr.io/project-zot/zot:v2.1.0";
public const int RegistryContainerPort = 5000;
public const int RegistryHostPort = 5000;
public const string ProxyName = "vega-proxy";
Expand Down
5 changes: 5 additions & 0 deletions cli/src/Vdk/Services/FallbackDockerEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ public bool Exec(string name, string[] commands)
return RunProcess("docker", args, out _, out _);
}

public bool Restart(string name)
{
return RunProcess("docker", $"restart {name}", out _, out _);
}
Comment on lines +85 to +88
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new Restart method lacks test coverage. The existing test file FallbackDockerEngineTests.cs has tests for other Docker operations (Run, Exists, Delete, Stop, Exec), but doesn't include tests for the newly added Restart functionality.

Consider adding a test that verifies the Restart method works correctly, similar to the existing tests for other Docker operations.

Copilot uses AI. Check for mistakes.

public bool CanConnect()
{
var args = $"ps";
Expand Down
2 changes: 2 additions & 0 deletions cli/src/Vdk/Services/IDockerEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,7 @@ public interface IDockerEngine

bool Exec(string name, string[] commands);

bool Restart(string name);

bool CanConnect();
}
38 changes: 25 additions & 13 deletions cli/src/Vdk/Services/LocalDockerClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -81,14 +81,14 @@ public bool Exists(string name, bool checkRunning = true)
IList<ContainerListResponse> containers = _dockerClient.Containers.ListContainersAsync(
new ContainersListParameters()
{
Filters = new Dictionary<string, IDictionary<string, bool>> { { "label", new Dictionary<string, bool> { { "vega-component", true } } } },
Limit = 10,
Filters = new Dictionary<string, IDictionary<string, bool>> { { "name", new Dictionary<string, bool> { { name, true } } } },
All = true,
}).GetAwaiter().GetResult();
var container = containers.FirstOrDefault(x => x.Labels["vega-component"].Contains(name));
var container = containers.FirstOrDefault(x => x.Names.Any(n => n.TrimStart('/') == name));

if (container == null) return false;
if (checkRunning == false) return true;

return container.State == "running" ||
// this should be started, lets do it
_dockerClient.Containers.StartContainerAsync(container.ID, new ContainerStartParameters() { }).GetAwaiter().GetResult();
Comment on lines 92 to 94
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Exists method has inconsistent behavior between implementations of IDockerEngine:

  • FallbackDockerEngine.Exists() only checks existence without side effects
  • LocalDockerClient.Exists() attempts to auto-start stopped containers when checkRunning=true (line 92-94)

This inconsistency can lead to different behavior depending on which Docker engine is in use. The auto-start behavior in LocalDockerClient appears to be the problematic one, as it prevents the restart logic in ReverseProxyClient.Create() from working correctly.

Consider removing the auto-start behavior from LocalDockerClient.Exists() to maintain consistency across implementations and make the method behavior match its name (it should only check existence, not start containers).

Suggested change
return container.State == "running" ||
// this should be started, lets do it
_dockerClient.Containers.StartContainerAsync(container.ID, new ContainerStartParameters() { }).GetAwaiter().GetResult();
// When checkRunning is true, only report whether the container is running;
// do not start or otherwise modify container state.
return container.State == "running";

Copilot uses AI. Check for mistakes.
Expand All @@ -99,13 +99,13 @@ public bool Delete(string name)
IList<ContainerListResponse> containers = _dockerClient.Containers.ListContainersAsync(
new ContainersListParameters()
{
Filters = new Dictionary<string, IDictionary<string, bool>> { { "label", new Dictionary<string, bool> { { "vega-component", true } } } },
Limit = 10,
Filters = new Dictionary<string, IDictionary<string, bool>> { { "name", new Dictionary<string, bool> { { name, true } } } },
All = true,
}).GetAwaiter().GetResult();
var container = containers.FirstOrDefault(x => x.Labels["vega-component"].Contains(name));
var container = containers.FirstOrDefault(x => x.Names.Any(n => n.TrimStart('/') == name));
if (container != null)
{
_dockerClient.Containers.RemoveContainerAsync(container.ID, new ContainerRemoveParameters(){Force = true});
_dockerClient.Containers.RemoveContainerAsync(container.ID, new ContainerRemoveParameters(){Force = true}).GetAwaiter().GetResult();
}
return true;
}
Expand All @@ -117,13 +117,11 @@ public bool Stop(string name)

public bool Exec(string name, string[] commands)
{
// Get container id from name and labels
var container = _dockerClient.Containers.ListContainersAsync(
new ContainersListParameters()
{
Filters = new Dictionary<string, IDictionary<string, bool>> { { "label", new Dictionary<string, bool> { { "vega-component", true } } } },
Limit = 10,
}).GetAwaiter().GetResult().FirstOrDefault(x => x.Labels["vega-component"].Contains(name));
Filters = new Dictionary<string, IDictionary<string, bool>> { { "name", new Dictionary<string, bool> { { name, true } } } },
}).GetAwaiter().GetResult().FirstOrDefault(x => x.Names.Any(n => n.TrimStart('/') == name));

if (container == null) return false;
_dockerClient.Exec.ExecCreateContainerAsync(container.ID, new ContainerExecCreateParameters
Expand All @@ -135,6 +133,20 @@ public bool Exec(string name, string[] commands)
return true;
}

public bool Restart(string name)
{
var container = _dockerClient.Containers.ListContainersAsync(
new ContainersListParameters()
{
Filters = new Dictionary<string, IDictionary<string, bool>> { { "name", new Dictionary<string, bool> { { name, true } } } },
All = true,
}).GetAwaiter().GetResult().FirstOrDefault(x => x.Names.Any(n => n.TrimStart('/') == name));

if (container == null) return false;
_dockerClient.Containers.RestartContainerAsync(container.ID, new ContainerRestartParameters()).GetAwaiter().GetResult();
return true;
}
Comment on lines +136 to +148
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new Restart method lacks test coverage. Consider adding tests for this new functionality, especially since the codebase has tests for other IDockerEngine implementations (e.g., FallbackDockerEngineTests.cs tests Run, Exists, Delete, Stop, and Exec operations).

Copilot uses AI. Check for mistakes.

public bool CanConnect()
{
try
Expand Down
157 changes: 101 additions & 56 deletions cli/src/Vdk/Services/ReverseProxyClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,60 +34,103 @@ public ReverseProxyClient(IDockerEngine docker, Func<string, IKubernetesClient>

public void Create()
{
var proxyExists = Exists();
if (!proxyExists)
// Check if the container exists (running or stopped)
if (_docker.Exists(Containers.ProxyName))
{
_console.WriteLine("Creating Vega VDK Proxy");
_console.WriteLine(" - This may take a few minutes...");
var conf = new FileInfo(NginxConf);
if (!conf.Exists)
_console.WriteLine("Vega VDK Proxy is running.");
return;
}

// Container exists but is stopped - restart it
if (_docker.Exists(Containers.ProxyName, false))
{
_console.WriteLine("Vega VDK Proxy exists but is not running. Restarting...");
_docker.Restart(Containers.ProxyName);
return;
}

// Container does not exist at all - create it
_console.WriteLine("Creating Vega VDK Proxy");
_console.WriteLine(" - This may take a few minutes...");
var conf = new FileInfo(NginxConf);
if (!conf.Exists)
{
if (!conf.Directory!.Exists)
{
if (!conf.Directory!.Exists)
conf.Directory.Create();
}

InitConfFile(conf);
}
else
{
_console.WriteLine($" - Reverse proxy configuration for {conf.FullName} exists, running a quick validation...");
conf.Delete();
InitConfFile(conf);
// iterate the clusters and create the endpoints for each
_kind.ListClusters().ForEach(tuple =>
{
if (tuple is { isVdk: true, master: not null } && tuple.master.HttpsHostPort.HasValue)
{
conf.Directory.Create();
_console.WriteLine($" - Adding cluster {tuple.name} to reverse proxy configuration");
UpsertCluster(tuple.name, tuple.master.HttpsHostPort.Value, tuple.master.HttpHostPort.Value, false);
}
});
}
var fullChain = new FileInfo(Path.Combine("Certs", "fullchain.pem"));
var privKey = new FileInfo(Path.Combine("Certs", "privkey.pem"));

InitConfFile(conf);
}
else
{
_console.WriteLine($" - Reverse proxy configuration for {conf.FullName} exists, running a quick validation...");
conf.Delete();
InitConfFile(conf);
// iterate the clusters and create the endpoints for each
_kind.ListClusters().ForEach(tuple =>
// Check for and fix certificate paths that were incorrectly created as directories
if (!ValidateAndFixCertificatePath(fullChain.FullName) ||
!ValidateAndFixCertificatePath(privKey.FullName))
{
_console.WriteError("Certificate files are missing. Please ensure Certs/fullchain.pem and Certs/privkey.pem exist.");
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error handling here returns early when certificate files are missing, which prevents the Docker container from being created. However, the error message suggests the user should "ensure" the files exist, but doesn't provide guidance on how to create or obtain these certificate files.

Consider enhancing the error message to include actionable instructions, such as pointing to documentation on how to generate or obtain the required certificate files, or providing a command to generate self-signed certificates for development purposes.

Suggested change
_console.WriteError("Certificate files are missing. Please ensure Certs/fullchain.pem and Certs/privkey.pem exist.");
_console.WriteError("Certificate files are missing. The reverse proxy cannot be started without TLS certificates.");
_console.WriteError("Expected files: Certs/fullchain.pem and Certs/privkey.pem");
_console.WriteError("For local development, you can create self-signed certificates with:");
_console.WriteError(" mkdir -p Certs && openssl req -x509 -nodes -newkey rsa:2048 -keyout Certs/privkey.pem -out Certs/fullchain.pem -days 365");
_console.WriteError("For production, use certificates issued by a trusted CA and refer to the Vega VDK documentation on TLS certificates.");

Copilot uses AI. Check for mistakes.
return;
}

try
{
_docker.Run(Containers.ProxyImage, Containers.ProxyName,
new[] { new PortMapping() { HostPort = ReverseProxyHostPort, ContainerPort = 443 } },
null,
new[]
{
if (tuple is { isVdk: true, master: not null } && tuple.master.HttpsHostPort.HasValue)
{
_console.WriteLine($" - Adding cluster {tuple.name} to reverse proxy configuration");
UpsertCluster(tuple.name, tuple.master.HttpsHostPort.Value, tuple.master.HttpHostPort.Value, false);
}
});
}
var fullChain = new FileInfo(Path.Combine("Certs", "fullchain.pem"));
var privKey = new FileInfo(Path.Combine("Certs", "privkey.pem"));
new FileMapping() { Destination = "/etc/nginx/conf.d/vega.conf", Source = conf.FullName },
new FileMapping() { Destination = "/etc/certs/fullchain.pem", Source = fullChain.FullName },
new FileMapping() { Destination = "/etc/certs/privkey.pem", Source = privKey.FullName },
},
null);
}
catch (Exception e)
{
Console.WriteLine($"Error creating reverse proxy: {e}");
}
}

/// <summary>
/// Validates a certificate path exists as a file, not a directory.
/// On some systems (especially Mac), Docker may incorrectly create directories
/// when mounting paths that don't exist. This method detects and removes such directories.
/// </summary>
private bool ValidateAndFixCertificatePath(string path)
{
// Check if path exists as a directory (incorrect state)
if (Directory.Exists(path))
{
_console.WriteWarning($"Certificate path '{path}' exists as a directory instead of a file. Removing...");
try
{
_docker.Run(Containers.ProxyImage, Containers.ProxyName,
new[] { new PortMapping() { HostPort = ReverseProxyHostPort, ContainerPort = 443 } },
null,
new[]
{
new FileMapping() { Destination = "/etc/nginx/conf.d/vega.conf", Source = conf.FullName },
new FileMapping() { Destination = "/etc/certs/fullchain.pem", Source = fullChain.FullName },
new FileMapping() { Destination = "/etc/certs/privkey.pem", Source = privKey.FullName },
},
null);
Directory.Delete(path, recursive: true);
}
catch (Exception e)
catch (Exception ex)
{
Console.WriteLine($"Error creating reverse proxy: {e}");
_console.WriteError($"Failed to remove directory '{path}': {ex.Message}");
return false;
}
}
else
{
_console.WriteLine("Vega VDK Proxy already created");
}

// Now check if the file exists
return File.Exists(path);
}
Comment on lines +115 to 134
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The certificate validation logic is duplicated between ValidateAndFixCertificatePath() in ReverseProxyClient.cs and FixCertificatePathIfDirectory() in UpdateClustersCommand.cs. Both methods perform the same task: checking if a path exists as a directory and removing it.

Consider extracting this logic into a shared utility method or service to avoid duplication and ensure consistency. This would make maintenance easier and reduce the risk of the two implementations diverging over time.

Copilot uses AI. Check for mistakes.
Comment on lines +115 to 134
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new ValidateAndFixCertificatePath method lacks test coverage. This is an important utility function that modifies the filesystem by detecting and removing incorrectly-created certificate directories.

The codebase has extensive tests for ReverseProxyClient in ReverseProxyClientTests.cs (testing methods like PatchCoreDns, InitConfFile, etc.), so adding tests for this new method would align with the existing testing patterns. Consider adding tests that verify:

  1. The method returns false when the path is a directory and can be removed
  2. The method returns true when the path is a valid file
  3. The method handles errors gracefully when directory removal fails

Copilot uses AI. Check for mistakes.

public bool Exists()
Expand All @@ -102,8 +145,6 @@ public bool Exists()
_console.WriteError(e);
return true;
}

return false;
}

private void InitConfFile(FileInfo conf)
Expand Down Expand Up @@ -257,15 +298,8 @@ private bool PatchCoreDns(string clusterName)
return false;
}

// insert the new entry after the kubernetes block by searching for the closing brace then inserting it

var closingBraceIndex = lines.FindIndex(kubernetesBlockIndex, line => line.Trim() == "}");
if (closingBraceIndex == -1)
{
_console.WriteError("CoreDNS Corefile does not contain a closing brace for the kubernetes block. Please check the configuration and try again.");
return false;
}
lines.Insert(closingBraceIndex, rewriteString);
// insert the rewrite BEFORE the kubernetes block so it is evaluated first by CoreDNS
lines.Insert(kubernetesBlockIndex, rewriteString);

// join the lines back into a single string
var updatedCorefile = string.Join(Environment.NewLine, lines);
Expand Down Expand Up @@ -324,6 +358,16 @@ private bool CreateTlsSecret(string clusterName)
return true;
}

// Validate certificate files before reading
var fullChainPath = Path.Combine("Certs", "fullchain.pem");
var privKeyPath = Path.Combine("Certs", "privkey.pem");
if (!ValidateAndFixCertificatePath(fullChainPath) ||
!ValidateAndFixCertificatePath(privKeyPath))
{
_console.WriteError("Certificate files are missing. Cannot create TLS secret.");
Comment on lines +364 to +367
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message "Certificate files are missing. Cannot create TLS secret." could be more specific about which certificate file(s) are missing. Since the validation checks two separate files (fullchain.pem and privkey.pem), indicating which one failed would help users troubleshoot the issue more efficiently.

Consider updating the error message to specify which file(s) are missing or failed validation, for example: "Certificate file is missing or invalid: {path}"

Suggested change
if (!ValidateAndFixCertificatePath(fullChainPath) ||
!ValidateAndFixCertificatePath(privKeyPath))
{
_console.WriteError("Certificate files are missing. Cannot create TLS secret.");
var invalidCertificatePaths = new List<string>();
if (!ValidateAndFixCertificatePath(fullChainPath))
{
invalidCertificatePaths.Add(fullChainPath);
}
if (!ValidateAndFixCertificatePath(privKeyPath))
{
invalidCertificatePaths.Add(privKeyPath);
}
if (invalidCertificatePaths.Count > 0)
{
_console.WriteError(
$"Certificate file(s) are missing or invalid: {string.Join(", ", invalidCertificatePaths)}. Cannot create TLS secret.");

Copilot uses AI. Check for mistakes.
return true;
}

// write the cert secret to the cluster
var tls = new V1Secret()
{
Expand All @@ -335,8 +379,8 @@ private bool CreateTlsSecret(string clusterName)
Type = "kubernetes.io/tls",
Data = new Dictionary<string, byte[]>
{
{ "tls.crt", File.ReadAllBytes("Certs/fullchain.pem") },
{ "tls.key", File.ReadAllBytes("Certs/privkey.pem") }
{ "tls.crt", File.ReadAllBytes(fullChainPath) },
{ "tls.key", File.ReadAllBytes(privKeyPath) }
}
};
var secret = _client(clusterName).Get<V1Secret>("dev-tls", "vega-system");
Expand All @@ -351,7 +395,8 @@ private bool CreateTlsSecret(string clusterName)

private void ReloadConfigs()
{
_docker.Exec("nginx", new[] { "nginx", "-s", "reload" });
_console.WriteLine("Restarting reverse proxy to apply configuration changes...");
_docker.Restart(Containers.ProxyName);
}

private static StreamWriter ClearCluster(string clusterName)
Expand Down
21 changes: 14 additions & 7 deletions cli/tests/Vdk.Tests/ReverseProxyClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -120,22 +120,29 @@ public void PatchCoreDns_ReturnsFalse_WhenIngressServiceNotFound()
}

[Fact]
public void PatchCoreDns_ReturnsFalse_WhenNoClosingBrace()
public void PatchCoreDns_InsertsRewriteBeforeKubernetesBlock()
{
// Arrange
// Arrange: Corefile with kubernetes block but no closing brace — rewrite should still insert before the kubernetes line
var clusterName = "test-cluster";
var corefile = "kubernetes cluster.local in-addr.arpa ip6.arpa {";
var configMap = new V1ConfigMap { Data = new Dictionary<string, string> { { "Corefile", corefile } } };
var pod = new V1Pod { Metadata = new V1ObjectMeta { Name = "coredns-1" } };
_kubeClientMock.Setup(x => x.Get<V1Service>("kgateway-system-kgateway", "kgateway-system"))
.Returns(new V1Service { Metadata = new V1ObjectMeta { Name = "svc", NamespaceProperty = "ns" } });
_kubeClientMock.Setup(x => x.Get<V1ConfigMap>("coredns", "kube-system"))
.Returns(new V1ConfigMap { Data = new Dictionary<string, string> { { "Corefile", corefile } } });
.Returns(configMap);
_kubeClientMock.Setup(x => x.List<V1Pod>("kube-system", It.IsAny<string>()))
.Returns(new List<V1Pod> { pod });
var client = new ReverseProxyClient(_dockerMock.Object, _clientFunc, _consoleMock.Object, _kindMock.Object);

// Act
var result = InvokePatchCoreDns(client, "test-cluster");
var result = InvokePatchCoreDns(client, clusterName);

// Assert
Assert.False(result);
_consoleMock.Verify(x => x.WriteError(It.Is<string>(s => s.Contains("does not contain a closing brace"))), Times.Once);
// Assert — rewrite is inserted before the kubernetes block
Assert.True(result);
_kubeClientMock.Verify(x => x.Update(It.Is<V1ConfigMap>(cm =>
cm.Data["Corefile"].Contains($"rewrite name {clusterName}.dev-k8s.cloud svc.ns.svc.cluster.local"))), Times.Once);
_kubeClientMock.Verify(x => x.Delete(pod), Times.Once);
}

[Fact]
Expand Down
Loading