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
7 changes: 6 additions & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@
"Bash(done)",
"Bash(for node in idp-control-plane idp-worker idp-worker2)",
"Bash(kubectl --context kind-idp get pods:*)",
"Bash(kubectl --context kind-idp delete pod:*)"
"Bash(kubectl --context kind-idp delete pod:*)",
"Bash(grep:*)",
"WebFetch(domain:github.com)",
"Bash(dotnet build:*)",
"Bash(dotnet test:*)",
"Bash(test:*)"
]
}
}
69 changes: 66 additions & 3 deletions cli/src/Vdk/Commands/UpdateClustersCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -420,7 +420,7 @@ private async Task RolloutRestartDeployment(IKubernetesClient client, V1Deployme

/// <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
/// and removes it. On some systems (especially Mac and WSL2), Docker may incorrectly
/// create directories when mounting paths that don't exist.
/// </summary>
private void FixCertificatePathIfDirectory(string path, bool verbose)
Expand All @@ -436,10 +436,73 @@ private void FixCertificatePathIfDirectory(string path, bool verbose)
_console.WriteLine($"[DEBUG] Successfully removed directory '{path}'");
}
}
catch (Exception ex)
catch (Exception)
{
_console.WriteError($"Failed to remove directory '{path}': {ex.Message}");
// On WSL2/Linux, directories created by Docker may have root ownership.
// Fall back to shell command with elevated permissions.
if (TryRemoveDirectoryWithShell(path, verbose))
{
if (verbose)
{
_console.WriteLine($"[DEBUG] Successfully removed directory '{path}' using shell command");
}
}
else
{
_console.WriteError($"Failed to remove directory '{path}'. Try running: sudo rm -rf \"{path}\"");
}
}
}
}

/// <summary>
/// Attempts to remove a directory using shell commands, which may succeed
/// when .NET Directory.Delete fails due to permission issues.
/// </summary>
private bool TryRemoveDirectoryWithShell(string path, bool verbose)
{
try
{
var isWindows = System.OperatingSystem.IsWindows();
var startInfo = new System.Diagnostics.ProcessStartInfo
{
FileName = isWindows ? "cmd.exe" : "/bin/sh",
Arguments = isWindows
? $"/c rmdir /s /q \"{path}\""
: $"-c \"rm -rf '{path}'\"",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};

using var process = System.Diagnostics.Process.Start(startInfo);
process?.WaitForExit(5000);

Comment on lines +479 to +481
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

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

WaitForExit(timeout) is used without checking completion; if rm/sudo hangs, the process can remain running after the timeout. Check the return value and kill the process on timeout to avoid leaving orphaned processes.

Copilot uses AI. Check for mistakes.
// Check if directory was actually removed
if (!_fileSystem.Directory.Exists(path))
{
return true;
}

// If still exists, try with sudo on Linux/Mac
if (!isWindows)
{
if (verbose)
{
_console.WriteLine($"[DEBUG] Attempting sudo rm -rf for '{path}'");
}
startInfo.Arguments = $"-c \"sudo rm -rf '{path}'\"";
using var sudoProcess = System.Diagnostics.Process.Start(startInfo);
sudoProcess?.WaitForExit(10000);
return !_fileSystem.Directory.Exists(path);
}

return false;
}
catch
{
return false;
}
}
}
61 changes: 61 additions & 0 deletions cli/src/Vdk/Services/DockerHubClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,67 @@ public void CreateRegistry()
var configFile = new FileInfo(Path.Combine("ConfigMounts", "zot-config.json"));
var imagesDir = new DirectoryInfo("images");

// Ensure ConfigMounts directory exists
var configMountsDir = configFile.Directory;
if (configMountsDir != null && !configMountsDir.Exists)
{
configMountsDir.Create();
}

// Fix: Check if config file was incorrectly created as a directory by Docker
if (Directory.Exists(configFile.FullName))
{
console.WriteLine($"Config path '{configFile.FullName}' exists as a directory instead of a file. Removing...");
Directory.Delete(configFile.FullName, recursive: true);
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

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

Directory.Delete(configFile.FullName, recursive: true) can throw (e.g., permission/root-owned directory created by Docker), which would break CreateRegistry(). Wrap in try/catch and provide an actionable error (or reuse the elevated-removal approach used for certs).

Suggested change
Directory.Delete(configFile.FullName, recursive: true);
try
{
Directory.Delete(configFile.FullName, recursive: true);
}
catch (UnauthorizedAccessException ex)
{
console.WriteLine($"Failed to remove directory at '{configFile.FullName}' due to insufficient permissions: {ex.Message}");
console.WriteLine("Please remove this directory manually (or re-run with elevated permissions) and try again.");
throw;
}
catch (IOException ex)
{
console.WriteLine($"Failed to remove directory at '{configFile.FullName}' because it is in use or locked: {ex.Message}");
console.WriteLine("Please ensure no other process is using this path, remove it manually, and try again.");
throw;
}
catch (Exception ex)
{
console.WriteLine($"Unexpected error while removing directory at '{configFile.FullName}': {ex.Message}");
console.WriteLine("Please remove this directory manually and try again.");
throw;
}

Copilot uses AI. Check for mistakes.
}

// Ensure config file exists - try to copy from app directory or create default
if (!configFile.Exists)
{
// Try to find config in application base directory
var appBaseConfig = Path.Combine(AppContext.BaseDirectory, "ConfigMounts", "zot-config.json");
if (File.Exists(appBaseConfig))
{
console.WriteLine($"Copying zot config from {appBaseConfig}");
File.Copy(appBaseConfig, configFile.FullName);
}
else
{
// Create default config
console.WriteLine("Creating default zot-config.json");
var defaultConfig = """
{
"distSpecVersion": "1.1.0",
"storage": {
"rootDirectory": "/var/lib/registry",
"gc": true,
"gcDelay": "1h",
"gcInterval": "24h"
},
"http": {
"address": "0.0.0.0",
"port": "5000"
},
"log": {
"level": "info"
},
"extensions": {
"ui": {
"enable": true
},
"search": {
"enable": true,
"cve": {
"updateInterval": "24h"
}
}
}
}
""";
File.WriteAllText(configFile.FullName, defaultConfig);
}
}

// Ensure images directory exists
if (!imagesDir.Exists)
{
Expand Down
58 changes: 58 additions & 0 deletions cli/src/Vdk/Services/FallbackDockerEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@ internal static bool RunProcess(string fileName, string arguments, out string st

public bool Run(string image, string name, PortMapping[]? ports, Dictionary<string, string>? envs, FileMapping[]? volumes, string[]? commands, string? network = null)
{
// Validate and fix volume mount sources before creating container
// This prevents Docker from creating directories when files are expected
if (volumes != null)
{
foreach (var volume in volumes)
{
EnsureVolumeMountSource(volume.Source, volume.Destination);
}
Comment on lines +30 to +35
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

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

This PR adds mount-source validation (EnsureVolumeMountSource) but existing FallbackDockerEngine tests don’t cover the new behavior (e.g., missing file mount should throw; directory mistakenly created for file mount should be removed). Add focused tests to prevent regressions (ideally by extracting the path-fix logic into a testable helper).

Copilot uses AI. Check for mistakes.
}

var args = $"run -d --name {name}";
if (network != null)
args += $" --network {network}";
Expand Down Expand Up @@ -53,6 +63,54 @@ public bool Run(string image, string name, PortMapping[]? ports, Dictionary<stri
return RunProcess("docker", args, out _, out _);
}

/// <summary>
/// Ensures that a volume mount source path exists correctly.
/// Fixes the common Docker issue where mounting a non-existent file creates a directory instead.
/// </summary>
private void EnsureVolumeMountSource(string sourcePath, string destinationPath)
{
// Determine if destination looks like a file (has extension) or directory
bool isFilePath = Path.HasExtension(destinationPath) && !destinationPath.EndsWith('/') && !destinationPath.EndsWith('\\');

Comment on lines +72 to +74
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

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

The file-vs-directory detection uses Path.HasExtension(destinationPath) which misclassifies valid file mounts without extensions (e.g., /var/run/docker.sock is mounted elsewhere). In the missing-source case this can create a directory at a path that should be a file/socket, and File.Exists may not work for non-regular files. Prefer inferring from the existing sourcePath entry type (file/dir) and require an explicit indicator when sourcePath doesn’t exist.

Copilot uses AI. Check for mistakes.
// Check if path was incorrectly created as a directory when it should be a file
if (isFilePath && Directory.Exists(sourcePath))
{
Console.WriteLine($"Mount path '{sourcePath}' exists as a directory instead of a file. Removing...");
try
{
Directory.Delete(sourcePath, recursive: true);
}
catch (Exception ex)
{
throw new InvalidOperationException(
$"Failed to remove directory '{sourcePath}': {ex.Message}",
ex);
}
}

// Ensure parent directory exists
var parentDir = Path.GetDirectoryName(sourcePath);
if (!string.IsNullOrEmpty(parentDir) && !Directory.Exists(parentDir))
{
Directory.CreateDirectory(parentDir);
}

// For file paths, ensure the file exists
if (isFilePath && !File.Exists(sourcePath))
{
throw new FileNotFoundException(
$"Mount source file does not exist: '{sourcePath}'. " +
$"Please ensure the required config file exists before running this command.",
sourcePath);
}

// For directory paths, ensure directory exists
if (!isFilePath && !Directory.Exists(sourcePath))
{
Directory.CreateDirectory(sourcePath);
}
}

public bool Exists(string name, bool checkRunning = true)
{
var filter = checkRunning ? "--filter \"status=running\"" : "";
Expand Down
58 changes: 58 additions & 0 deletions cli/src/Vdk/Services/LocalDockerClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@ public LocalDockerClient(Docker.DotNet.IDockerClient dockerClient)

public bool Run(string image, string name, PortMapping[]? ports, Dictionary<string, string>? envs, FileMapping[]? volumes, string[]? commands, string? network = null)
{
// Validate and fix volume mount sources before creating container
// This prevents Docker from creating directories when files are expected
if (volumes != null)
{
foreach (var volume in volumes)
{
EnsureVolumeMountSource(volume.Source, volume.Destination);
}
}

_dockerClient.Images.CreateImageAsync(
new ImagesCreateParameters
{
Expand Down Expand Up @@ -160,4 +170,52 @@ public bool CanConnect()
return false;
}
}

/// <summary>
/// Ensures that a volume mount source path exists correctly.
/// Fixes the common Docker issue where mounting a non-existent file creates a directory instead.
/// </summary>
private void EnsureVolumeMountSource(string sourcePath, string destinationPath)
{
// Determine if destination looks like a file (has extension) or directory
bool isFilePath = Path.HasExtension(destinationPath) && !destinationPath.EndsWith('/') && !destinationPath.EndsWith('\\');

// Check if path was incorrectly created as a directory when it should be a file
if (isFilePath && Directory.Exists(sourcePath))
{
Console.WriteLine($"Certificate path '{sourcePath}' exists as a directory instead of a file. Removing...");
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

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

The log message says Certificate path ... but EnsureVolumeMountSource is used for all volume mounts (not just certs). Consider changing the wording to something neutral like Mount source path to avoid confusing users when non-cert mounts are validated.

Suggested change
Console.WriteLine($"Certificate path '{sourcePath}' exists as a directory instead of a file. Removing...");
Console.WriteLine($"Mount source path '{sourcePath}' exists as a directory instead of a file. Removing...");

Copilot uses AI. Check for mistakes.
try
{
Directory.Delete(sourcePath, recursive: true);
}
catch (Exception ex)
{
throw new InvalidOperationException(
$"Failed to remove directory '{sourcePath}': {ex.Message}",
ex);
}
}
Comment on lines +180 to +197
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

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

The file-vs-directory detection uses Path.HasExtension(destinationPath) which misclassifies valid file mounts without extensions (e.g., /var/run/docker.sock used elsewhere) as directories. In the missing-source case this code may create a directory at a path that should be a file/socket, and File.Exists may also be false for non-regular files. Consider determining type from sourcePath when it exists (file vs directory), and when it doesn’t exist require an explicit indicator (or treat as file unless destination ends with a path separator).

Suggested change
// Determine if destination looks like a file (has extension) or directory
bool isFilePath = Path.HasExtension(destinationPath) && !destinationPath.EndsWith('/') && !destinationPath.EndsWith('\\');
// Check if path was incorrectly created as a directory when it should be a file
if (isFilePath && Directory.Exists(sourcePath))
{
Console.WriteLine($"Certificate path '{sourcePath}' exists as a directory instead of a file. Removing...");
try
{
Directory.Delete(sourcePath, recursive: true);
}
catch (Exception ex)
{
throw new InvalidOperationException(
$"Failed to remove directory '{sourcePath}': {ex.Message}",
ex);
}
}
// Determine whether this mount should be treated as a file or directory.
// Prefer the actual type of sourcePath when it exists; otherwise infer from destinationPath.
bool sourceIsFile = File.Exists(sourcePath);
bool sourceIsDirectory = !sourceIsFile && Directory.Exists(sourcePath);
bool isFilePath;
if (sourceIsFile)
{
// Existing source is a file
isFilePath = true;
}
else if (sourceIsDirectory)
{
// Existing source is a directory
isFilePath = false;
}
else
{
// Source does not exist yet, infer from destination:
// treat as a directory only if destination ends with a directory separator.
bool endsWithSeparator =
destinationPath.EndsWith(Path.DirectorySeparatorChar.ToString()) ||
destinationPath.EndsWith(Path.AltDirectorySeparatorChar.ToString());
isFilePath = !endsWithSeparator;
}

Copilot uses AI. Check for mistakes.

// Ensure parent directory exists
var parentDir = Path.GetDirectoryName(sourcePath);
if (!string.IsNullOrEmpty(parentDir) && !Directory.Exists(parentDir))
{
Directory.CreateDirectory(parentDir);
}

// For file paths, ensure the file exists
if (isFilePath && !File.Exists(sourcePath))
{
throw new FileNotFoundException(
$"Mount source file does not exist: '{sourcePath}'. " +
$"Please ensure the required config file exists before running this command.",
sourcePath);
}

// For directory paths, ensure directory exists
if (!isFilePath && !Directory.Exists(sourcePath))
{
Directory.CreateDirectory(sourcePath);
}
}
}
Loading
Loading