Skip to content

Commit fa70cdc

Browse files
fix: normalize bind mount path for container runtime on Windows
Rancher Desktop uses a Linux dockerd inside WSL2 that cannot resolve Windows-style paths. Detect the runtime at startup via \docker info\ and convert Windows drive paths to the WSL2 /mnt/<drive>/... format when running under Rancher Desktop; all other runtimes (Docker Desktop etc.) receive the native Windows path and let DCP/Docker handle it. Also expand NormalizeBindMountPath unit tests with argument-based cases covering multiple drive letters, path depths, and both runtime branches, and add four additional ComputeCommonAncestor scenarios (three paths, all-same, parent-among-children, deep hierarchy). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 7a6d653 commit fa70cdc

12 files changed

Lines changed: 533 additions & 518 deletions

File tree

.github/copilot-instructions.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Copilot Instructions
22

3+
## Non-negotiables
4+
5+
- **Always run `just test` (unit + integration) to verify changes** — running only `just test-unit` is not acceptable. Unit tests prove nothing about the wider system; integration tests exercise Docker, container startup, bind mounts, and the full Aspire lifecycle.
6+
- **Always run `just lintcheck` (or `just lintfix`) before committing** — CSharpier formatting is enforced and CI will reject unformatted code.
7+
38
## Build, test, and lint
49

510
This repo uses [just](https://just.systems/) as a task runner. All commands operate on `src/AspireC4.slnx`.

src/src/AspireC4.Core/ExcludeFromLikeC4Annotation.cs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,5 @@ namespace Aspire.Hosting.AspireC4;
55
/// <summary>
66
/// Annotation that excludes a resource from the generated LikeC4 diagram.
77
/// Apply via <c>builder.ExcludeFromLikeC4()</c>.
8-
/// <param name="Exclude">Indicates whether the resource should be excluded from the LikeC4 diagram. Defaults to <c>true</c>.</param>
98
/// </summary>
10-
public sealed class ExcludeFromLikeC4Annotation(bool Exclude = true) : IResourceAnnotation;
9+
public sealed class ExcludeFromLikeC4Annotation : IResourceAnnotation;

src/src/AspireC4/AspireC4.csproj

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,10 @@
2929
<ItemGroup>
3030
<ProjectReference Include="..\AspireC4.Core\AspireC4.Core.csproj" />
3131
</ItemGroup>
32+
33+
<ItemGroup>
34+
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
35+
<_Parameter1>AspireC4.UnitTests</_Parameter1>
36+
</AssemblyAttribute>
37+
</ItemGroup>
3238
</Project>

src/src/AspireC4/AspireC4Builder.cs

Lines changed: 87 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
using System.Security.Cryptography;
2-
using System.Text;
31
using Aspire.Hosting.ApplicationModel;
42
using Microsoft.Extensions.DependencyInjection;
53

@@ -77,33 +75,6 @@ public IAspireC4Builder WithAdditionalDSLFile(string sourcePath)
7775
opts.AdditionalDSLFiles.Add(absoluteSource)
7876
);
7977

80-
// For container resources, bind-mount the source directory directly into the container
81-
// so the LikeC4 server sees live edits without Aspire restarting.
82-
if (LikeC4ResourceBuilder.Resource is ContainerResource containerResource)
83-
{
84-
var sourceDir = Path.GetDirectoryName(absoluteSource)!;
85-
var hash = ComputeShortHash(sourceDir);
86-
var mountTarget = $"{LikeC4ServerResource.WorkspacePath}/ext/{hash}";
87-
88-
// De-duplicate: only add the bind mount once per unique source directory.
89-
var alreadyMounted = containerResource
90-
.Annotations.OfType<ContainerMountAnnotation>()
91-
.Any(a => a.Target == mountTarget);
92-
93-
if (!alreadyMounted)
94-
{
95-
containerResource.Annotations.Add(
96-
new ContainerMountAnnotation(sourceDir, mountTarget, ContainerMountType.BindMount, isReadOnly: true)
97-
);
98-
}
99-
100-
// Tell the lifecycle hook not to also sync this file into the named Docker volume,
101-
// which would create a duplicate definition visible to LikeC4.
102-
ApplicationBuilder.Services.Configure<LikeC4ContainerWorkspaceOptions>(wsOpts =>
103-
wsOpts.BindMountedSourceFiles.Add(absoluteSource)
104-
);
105-
}
106-
10778
return this;
10879
}
10980

@@ -119,33 +90,6 @@ public IAspireC4Builder WithAdditionalDSLFolder(string folderPath)
11990
opts.AdditionalDSLFolders.Add(absoluteFolder)
12091
);
12192

122-
if (LikeC4ResourceBuilder.Resource is ContainerResource containerResource)
123-
{
124-
var hash = ComputeShortHash(absoluteFolder);
125-
var containerRelative = $"ext/{hash}";
126-
var mountTarget = $"{LikeC4ServerResource.WorkspacePath}/{containerRelative}";
127-
128-
var alreadyMounted = containerResource
129-
.Annotations.OfType<ContainerMountAnnotation>()
130-
.Any(a => a.Target == mountTarget);
131-
132-
if (!alreadyMounted)
133-
{
134-
containerResource.Annotations.Add(
135-
new ContainerMountAnnotation(
136-
absoluteFolder,
137-
mountTarget,
138-
ContainerMountType.BindMount,
139-
isReadOnly: true
140-
)
141-
);
142-
}
143-
144-
ApplicationBuilder.Services.Configure<LikeC4ContainerWorkspaceOptions>(wsOpts =>
145-
wsOpts.BindMountedFolderTargets.TryAdd(absoluteFolder, containerRelative)
146-
);
147-
}
148-
14993
return this;
15094
}
15195

@@ -165,33 +109,6 @@ public IAspireC4Builder WithImageAliasFolder(string aliasKey, string folderPath)
165109
opts.ImageAliases[aliasKey] = absoluteFolder
166110
);
167111

168-
if (LikeC4ResourceBuilder.Resource is ContainerResource containerResource)
169-
{
170-
var hash = ComputeShortHash(absoluteFolder);
171-
var containerRelative = $"img/{hash}";
172-
var mountTarget = $"{LikeC4ServerResource.WorkspacePath}/{containerRelative}";
173-
174-
var alreadyMounted = containerResource
175-
.Annotations.OfType<ContainerMountAnnotation>()
176-
.Any(a => a.Target == mountTarget);
177-
178-
if (!alreadyMounted)
179-
{
180-
containerResource.Annotations.Add(
181-
new ContainerMountAnnotation(
182-
absoluteFolder,
183-
mountTarget,
184-
ContainerMountType.BindMount,
185-
isReadOnly: true
186-
)
187-
);
188-
}
189-
190-
ApplicationBuilder.Services.Configure<LikeC4ContainerWorkspaceOptions>(wsOpts =>
191-
wsOpts.BindMountedImageAliasFolderTargets.TryAdd(aliasKey, containerRelative)
192-
);
193-
}
194-
195112
return this;
196113
}
197114

@@ -202,12 +119,89 @@ public IAspireC4Builder WithoutConfigFileGeneration()
202119
return this;
203120
}
204121

205-
static string ComputeShortHash(string value)
122+
/// <summary>
123+
/// Returns the correct bind-mount source path for the given host directory.
124+
/// </summary>
125+
/// <remarks>
126+
/// On Windows the correct format depends on the container runtime:
127+
/// <list type="bullet">
128+
/// <item><description>
129+
/// <b>Docker Desktop (official)</b> — natively understands Windows paths
130+
/// (<c>C:\…</c>); DCP passes them verbatim to the Docker API so the path is
131+
/// returned unchanged.
132+
/// </description></item>
133+
/// <item><description>
134+
/// <b>Rancher Desktop</b> — runs a Linux <c>dockerd</c> inside WSL2 that only
135+
/// understands Linux paths. Windows drives are accessible inside WSL2 at
136+
/// <c>/mnt/&lt;drive&gt;/…</c> (standard WSL2 mount points), so the path is
137+
/// converted to that format.
138+
/// </description></item>
139+
/// </list>
140+
/// On non-Windows the path is returned unchanged.
141+
/// </remarks>
142+
internal static string NormalizeBindMountPath(string absolutePath) =>
143+
NormalizeBindMountPath(absolutePath, _isRancherDesktop.Value);
144+
145+
/// <summary>Overload with an explicit runtime flag — used by unit tests to avoid
146+
/// spawning a <c>docker</c> process.</summary>
147+
internal static string NormalizeBindMountPath(string absolutePath, bool isRancherDesktop)
148+
{
149+
if (!OperatingSystem.IsWindows())
150+
return absolutePath;
151+
152+
var fullPath = Path.GetFullPath(absolutePath);
153+
154+
// Rancher Desktop exposes Windows drives under /mnt/<letter>/ inside WSL2.
155+
// CA1308: intentional — Linux paths require lower-case.
156+
if (isRancherDesktop && fullPath.Length >= 2 && char.IsAsciiLetter(fullPath[0]) && fullPath[1] == ':')
157+
{
158+
#pragma warning disable CA1308
159+
return $"/mnt/{char.ToLowerInvariant(fullPath[0])}{fullPath[2..].Replace('\\', '/')}".ToLowerInvariant();
160+
#pragma warning restore CA1308
161+
}
162+
163+
// Docker Desktop or other runtime: return Windows path as-is; DCP / Docker Desktop
164+
// translate it internally.
165+
return fullPath;
166+
}
167+
168+
static readonly Lazy<bool> _isRancherDesktop = new(
169+
DetectRancherDesktop,
170+
LazyThreadSafetyMode.ExecutionAndPublication
171+
);
172+
173+
[System.Diagnostics.CodeAnalysis.SuppressMessage(
174+
"Design",
175+
"CA1031:Do not catch general exception types",
176+
Justification = "Runtime detection must not throw; failure falls back to Docker Desktop behavior."
177+
)]
178+
static bool DetectRancherDesktop()
206179
{
207-
var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(value));
208-
#pragma warning disable CA1308 // Normalize strings to uppercase
209-
return Convert.ToHexString(hashBytes)[..8].ToLowerInvariant();
210-
#pragma warning restore CA1308 // Normalize strings to uppercase
180+
if (!OperatingSystem.IsWindows())
181+
return false;
182+
183+
try
184+
{
185+
using var process = System.Diagnostics.Process.Start(
186+
new System.Diagnostics.ProcessStartInfo
187+
{
188+
FileName = "docker",
189+
Arguments = "info --format {{.OperatingSystem}}",
190+
RedirectStandardOutput = true,
191+
RedirectStandardError = true,
192+
UseShellExecute = false,
193+
CreateNoWindow = true,
194+
}
195+
);
196+
197+
process?.WaitForExit(5_000);
198+
var os = process?.StandardOutput.ReadToEnd().Trim() ?? "";
199+
return os.Contains("Rancher Desktop", StringComparison.OrdinalIgnoreCase);
200+
}
201+
catch
202+
{
203+
return false;
204+
}
211205
}
212206

213207
static LikeC4LocalCLIRuntime DetectRuntime()
@@ -231,7 +225,7 @@ static LikeC4LocalCLIRuntime DetectRuntime()
231225
throw new DistributedApplicationException(
232226
"No supported JavaScript package manager was found on the system PATH. "
233227
+ "Install one of: Node.js (npx), pnpm, yarn, bun, or Deno, then retry. "
234-
+ "Alternatively, remove WithLocalCli() to use the Docker container (default)."
228+
+ "Alternatively, remove WithLocalCLI() to use the Docker container (default)."
235229
);
236230
}
237231

@@ -273,7 +267,9 @@ static bool IsExecutableOnPath(string executable)
273267
/// <example>
274268
/// Npx → <c>("npx", ["likec4"])</c> so the full call is <c>npx likec4 format ...</c>
275269
/// Pnpm → <c>("pnpm", ["exec", "likec4"])</c>
276-
/// Bun → <c>("bunx", ["likec4"])</c>
270+
/// Yarn → <c>("yarn", ["dlx", "likec4"])</c>
271+
/// Bun → <c>("bunx", ["--bun", "likec4"])</c>
272+
/// Deno → <c>("deno", ["run", "--allow-all", "likec4"])</c>
277273
/// </example>
278274
internal static (string Command, string[] Prefix) BuildLikeC4CliPrefix(LikeC4LocalCLIRuntime runtime) =>
279275
runtime switch
@@ -302,7 +298,7 @@ int port
302298
LikeC4LocalCLIRuntime.Npx => ("npx", ["likec4", "serve", outputDirectory, "--port", portStr]),
303299
LikeC4LocalCLIRuntime.Pnpm => ("pnpm", ["exec", "likec4", "serve", outputDirectory, "--port", portStr]),
304300
LikeC4LocalCLIRuntime.Yarn => ("yarn", ["dlx", "likec4", "serve", outputDirectory, "--port", portStr]),
305-
LikeC4LocalCLIRuntime.Bun => ("bunx", ["likec4", "serve", outputDirectory, "--port", portStr]),
301+
LikeC4LocalCLIRuntime.Bun => ("bunx", ["--bun", "likec4", "serve", outputDirectory, "--port", portStr]),
306302
LikeC4LocalCLIRuntime.Deno => (
307303
"deno",
308304
["run", "--allow-all", "likec4", "serve", outputDirectory, "--port", portStr]

0 commit comments

Comments
 (0)