Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
cd6bc50
fix: a successful in-mesh compile's diagnostics reach its ACTIVITY
rbuergi Aug 30, 2026
447910f
docs: the fleet test shape — a test carries NO dependencies, not even…
rbuergi Aug 30, 2026
503d726
fix: the install no longer waits 30 s for a cover grant it documents …
rbuergi Aug 30, 2026
2f93a17
feat(cli): memex build project — compile a csproj against the contain…
rbuergi Aug 30, 2026
94be044
Merge remote-tracking branch 'origin/main' into feat/cli-build-project
rbuergi Aug 30, 2026
f63d19b
fix(build): a FATAL is never silent, and one unreadable file no longe…
rbuergi Aug 30, 2026
29ac217
Merge remote-tracking branch 'origin/feat/in-mesh-diagnostics-to-acti…
rbuergi Aug 30, 2026
1872b20
Merge remote-tracking branch 'origin/docs/tests-carry-no-dependencies…
rbuergi Aug 30, 2026
2d1cd0e
Merge remote-tracking branch 'origin/fix/gating-settle-stall' into in…
rbuergi Aug 30, 2026
ab449b5
Merge remote-tracking branch 'origin/feat/cli-build-project' into int…
rbuergi Aug 30, 2026
7cea835
Merge remote-tracking branch 'origin/fix/cascade-build-silent-fatal' …
rbuergi Aug 30, 2026
ba8f06a
Merge remote-tracking branch 'origin/main' into integration/core-batch
rbuergi Aug 30, 2026
62910be
feat(build-project): embedded resources, with the SDK's manifest names
rbuergi Aug 31, 2026
a8f38af
fix(build-project): three more resource rules the SDK does not do the…
rbuergi Aug 31, 2026
38d2142
Merge main into feat/build-project-embedded-resources — same two-side…
rbuergi Aug 31, 2026
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
111 changes: 15 additions & 96 deletions src/MeshWeaver.Cli/BuildPluginCommand.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using System.Diagnostics;

namespace MeshWeaver.Cli;

Expand All @@ -18,8 +17,9 @@ namespace MeshWeaver.Cli;
/// </summary>
public sealed class BuildPluginCommand(TextWriter output, TextWriter error)
{
/// <summary>Attempts for a registry PULL — see <see cref="PullImage"/>.</summary>
private const int PullAttempts = 3;
/// <summary>The docker plumbing this verb shares with <c>build project</c>: the pull
/// retry, the digest pin, and the process launch. One copy, so the pin cannot drift.</summary>
private readonly ImageRunner _runner = new(output, error);

/// <summary>
/// The file the compile stage writes to record which framework the bundles were built against.
Expand Down Expand Up @@ -55,7 +55,7 @@ await error.WriteLineAsync(
// this (`${IMAGE%%:*}@${DIGEST}`) for a reason: a tag can move between the compile and the
// gate, and then the bytes tested are not the bytes baked — which is the one claim the
// two-stage split exists to make.
var pinned = await PullImage(options.Image, ct);
var pinned = await _runner.PullImage(options.Image, ct);
if (pinned is null) return 4;
await output.WriteLineAsync($"image: {pinned}");

Expand All @@ -70,11 +70,7 @@ await error.WriteLineAsync(
// writing the first bundle. Applied to a caller-supplied directory too: the caller asked
// for a bake THERE, and a mount the container cannot write is a promise this command
// cannot keep. Windows has no unix mode and no such non-root docker convention; skip.
if (!OperatingSystem.IsWindows())
File.SetUnixFileMode(bake,
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
UnixFileMode.GroupRead | UnixFileMode.GroupWrite | UnixFileMode.GroupExecute |
UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute);
ImageRunner.MakeContainerWritable(bake);

// ── the upstream seed comes FIRST, because it is also where the modules come from ───────
// Round 4's lesson: sourcing module DLLs from the package index takes the LATEST version,
Expand All @@ -93,7 +89,7 @@ await error.WriteLineAsync(
string? sealedIdentity = null;
if (options.UpstreamSeed is { Length: > 0 } upstreams)
{
var line = await Capture("docker",
var line = await ImageRunner.Capture("docker",
["run", "--rm", "--init", "--entrypoint", "/app/mw-plugin-test", pinned,
"--print-framework-identity"], ct);
var idIdx = line.IndexOf("identity=", StringComparison.Ordinal);
Expand Down Expand Up @@ -140,14 +136,14 @@ await error.WriteLineAsync(
{ moduleArgs.Add("--module"); moduleArgs.Add($"/ext/{name}/{name}.dll"); }
}
}
if (extDir is not null && !OperatingSystem.IsWindows() && Directory.Exists(extDir))
File.SetUnixFileMode(extDir, WorldRwx);
if (extDir is not null && Directory.Exists(extDir))
ImageRunner.MakeContainerWritable(extDir);

// ── stage 1: PRODUCE ───────────────────────────────────────────────────────────────────
// Module args go to BOTH stages: the compile needs the externals to RESOLVE their types,
// the gate needs them to REGISTER them (the node-repo gate passes them to both for exactly
// this reason, and dropping either half reintroduces one of the two failures).
var compile = await RunInImage(pinned, repo, options, extDir,
var compile = await RunInImage(pinned, repo, extDir,
mounts: [$"{bake}:/bake"],
env: [],
args: ["compile", "/repo", ..AllowArgs(repo, options), ..moduleArgs, ..options.ExtraArgs,
Expand All @@ -166,18 +162,13 @@ await error.WriteLineAsync(


// ── stage 2: CONSUME — stand a mesh up on the bytes stage 1 produced ───────────────────
return await RunInImage(pinned, repo, options, extDir,
return await RunInImage(pinned, repo, extDir,
mounts: [$"{bake}:/seed:ro"],
env: ["MW_INSTALL_DIFF=1"],
args: ["/repo", ..AllowArgs(repo, options), ..moduleArgs, ..options.ExtraArgs, "--seed", "/seed"],
ct);
}

private const UnixFileMode WorldRwx =
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
UnixFileMode.GroupRead | UnixFileMode.GroupWrite | UnixFileMode.GroupExecute |
UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute;

/// <summary>
/// Fetches each named package's sealed bundle from the plugin registry and composes it under
/// <paramref name="extDir"/> in the layout the tester consumes (<c>/ext/&lt;name&gt;/&lt;name&gt;.dll</c>).
Expand Down Expand Up @@ -309,47 +300,6 @@ private static IEnumerable<string> AllowArgs(string repo, BuildPluginOptions o)
private static IEnumerable<string> SourceShaArgs(BuildPluginOptions o) =>
o.SourceSha is { Length: > 0 } sha ? ["--source-sha", sha] : [];

/// <summary>
/// Pull the image and resolve it to a digest.
///
/// <para>🚨 Bounded retry, and deliberately NOT a skip-trapdoor: after the last attempt this
/// returns null and the command fails RED. It exists because a registry pull is the one step
/// here that fails for reasons that have nothing to do with the change under test — on
/// 2026-08-30 core CD lost a whole release to `Connection refused` on one pull, and the same
/// class of fault had already put a bounded retry into three node-repo workflows.</para>
/// </summary>
private async Task<string?> PullImage(string image, CancellationToken ct)
{
for (var attempt = 1; attempt <= PullAttempts; attempt++)
{
if (await Exec("docker", ["pull", image], ct) == 0)
{
var digest = await Capture("docker",
["image", "inspect", image, "--format", "{{index .RepoDigests 0}}"], ct);
// No digest is not a failure to retry: a locally-built image legitimately has none,
// and pinning is then simply not available. Say so rather than inventing one.
if (string.IsNullOrWhiteSpace(digest))
{
await output.WriteLineAsync(
$"note: '{image}' has no repo digest (local image?) — using the tag as given, "
+ "so the two stages are pinned only as well as the tag is.");
return image;
}
return digest.Trim();
}
if (attempt < PullAttempts)
{
await error.WriteLineAsync(
$"pull attempt {attempt} of {PullAttempts} failed for '{image}' — retrying in 10s");
await Task.Delay(TimeSpan.FromSeconds(10), ct);
}
}
await error.WriteLineAsync(
$"error: could not pull '{image}' in {PullAttempts} attempts. Three consecutive failures "
+ "is no longer a transient — check the registry and the credentials rather than retrying.");
return null;
}

/// <summary>
/// Composes the requested module packages from the upstream publication's SEALED MODULE SET —
/// <c>…/api/plugins/bundles/prebuilt/{identity}/{source}/modules</c> — the client half of the
Expand Down Expand Up @@ -549,47 +499,16 @@ await error.WriteLineAsync(
}

private Task<int> RunInImage(
string image, string repo, BuildPluginOptions o,
string image, string repo,
string? extDir,
IEnumerable<string> mounts, IEnumerable<string> env, IEnumerable<string> args,
CancellationToken ct)
{
var docker = new List<string> { "run", "--rm", "-v", $"{repo}:/repo" };
foreach (var m in mounts) { docker.Add("-v"); docker.Add(m); }
var all = new List<string> { $"{repo}:/repo" };
all.AddRange(mounts);
if (extDir is { Length: > 0 } ext)
{ docker.Add("-v"); docker.Add($"{ext}:/ext"); }
foreach (var e in env) { docker.Add("-e"); docker.Add(e); }
docker.Add("--entrypoint"); docker.Add("/app/mw-plugin-test");
docker.Add(image);
docker.AddRange(args);
return Exec("docker", docker, ct);
}

private async Task<int> Exec(string file, IEnumerable<string> args, CancellationToken ct)
{
var psi = new ProcessStartInfo(file) { UseShellExecute = false };
foreach (var a in args) psi.ArgumentList.Add(a);
await output.WriteLineAsync($"$ {file} {string.Join(' ', psi.ArgumentList)}");
using var p = Process.Start(psi);
if (p is null)
{
await error.WriteLineAsync($"error: could not start '{file}' — is Docker installed and on PATH?");
return 127;
}
await p.WaitForExitAsync(ct);
return p.ExitCode;
}

private static async Task<string> Capture(string file, IEnumerable<string> args, CancellationToken ct)
{
var psi = new ProcessStartInfo(file)
{ UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true };
foreach (var a in args) psi.ArgumentList.Add(a);
using var p = Process.Start(psi);
if (p is null) return string.Empty;
var stdout = await p.StandardOutput.ReadToEndAsync(ct);
await p.WaitForExitAsync(ct);
return p.ExitCode == 0 ? stdout : string.Empty;
all.Add($"{ext}:/ext");
return _runner.RunInImage(image, all, env, args, ct);
}
}

Expand Down
190 changes: 190 additions & 0 deletions src/MeshWeaver.Cli/BuildProjectCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
namespace MeshWeaver.Cli;

/// <summary>
/// <c>memex build project &lt;csproj|dir&gt; [--image &lt;image&gt;]</c> — compile a .NET project with
/// NO dotnet SDK and NO NuGet restore, against the assemblies of a MeshWeaver image (maintainer,
/// 2026-08-30: <i>"the platform builds dll completely without any external dotnet kit or
/// nuget"</i>).
///
/// <para><b>This half is only the trip into the container.</b> The work — evaluating the
/// <c>.csproj</c> without MSBuild, resolving every reference from <c>/app</c> and the image's own
/// <c>.deps.json</c>, sequencing the <c>ProjectReference</c> graph and running Roslyn — is
/// <c>mw-plugin-test build-project</c>, which is already in the image. It shares
/// <see cref="ImageRunner"/> with <see cref="BuildPluginCommand"/> rather than carrying a second
/// copy of the pull retry and the digest pin.</para>
///
/// <para><b>Two ways to run.</b> With <c>--image</c> the tool pulls that image, pins it by digest
/// and runs the builder inside it. Without <c>--image</c> it runs the builder that is already
/// beside it — the case where this command is itself executing inside a MeshWeaver image. There is
/// no third way, and in particular no "just use the local SDK" fallback: the whole point is that
/// the reference set is a container's, so a run that cannot name a container fails.</para>
///
/// <para><b>What gets mounted.</b> <c>/repo</c> is the SOURCE ROOT — the directory of the nearest
/// <c>Directory.Build.props</c> above the project, or the project's own directory when there is
/// none. That is the same boundary the builder uses to decide which <c>ProjectReference</c>s are
/// its to build and which are the container's to supply, so mounting anything narrower would
/// silently change the answer. <c>--root</c> overrides it when a repo's import chain reaches
/// further up.</para>
/// </summary>
public sealed class BuildProjectCommand(TextWriter output, TextWriter error)
{
private readonly ImageRunner _runner = new(output, error);

/// <summary>Where the builder lives inside every MeshWeaver image.</summary>
public const string InImageBuilder = "/app/mw-plugin-test";

/// <summary>The verb the in-image builder is invoked with.</summary>
public const string BuilderVerb = "build-project";

/// <summary>Runs the build.</summary>
/// <param name="options">What to build and how.</param>
/// <param name="ct">Cancellation.</param>
/// <returns>The builder's exit code, or a usage/plumbing code (2, 4, 5).</returns>
public async Task<int> RunAsync(BuildProjectOptions options, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(options);

var entry = Path.GetFullPath(options.ProjectPath);
if (!File.Exists(entry) && !Directory.Exists(entry))
{
await error.WriteLineAsync($"error: '{entry}' is neither a project file nor a directory.");
return 2;
Comment on lines +43 to +51
}

var projectDirectory = File.Exists(entry) ? Path.GetDirectoryName(entry)! : entry;
var root = options.SourceRoot is { Length: > 0 } explicitRoot
? Path.GetFullPath(explicitRoot)
: FindSourceRoot(projectDirectory);
if (!entry.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)
&& !string.Equals(entry, root, StringComparison.OrdinalIgnoreCase))
{
await error.WriteLineAsync(
$"error: the project '{entry}' is not inside the source root '{root}', so mounting the "
+ "root would not carry it into the container. Pass --root with a directory that "
+ "contains both the project and its Directory.Build.props chain.");
return 2;
}

var relative = Path.GetRelativePath(root, entry).Replace(Path.DirectorySeparatorChar, '/');
var outputDirectory = options.Output is { Length: > 0 } o
? Path.GetFullPath(o)
: Path.Combine(Path.GetTempPath(), $"memex-build-project-{Environment.ProcessId}");
Directory.CreateDirectory(outputDirectory);
// The image runs as a non-root user, so an output mount it cannot write is a promise this
// command cannot keep (the Manufacturing #37 failure, applied to a caller-named directory).
ImageRunner.MakeContainerWritable(outputDirectory);

var builderArgs = new List<string> { BuilderVerb, $"/repo/{relative}", "--output", "/out" };
foreach (var accept in options.Accept)
{
builderArgs.Add("--accept");
builderArgs.Add(accept);
}
if (options.AllowWarnings)
builderArgs.Add("--allow-warnings");
foreach (var extra in options.ExtraArgs)
builderArgs.Add(extra);

if (options.Image is not { Length: > 0 } image)
{
// No image named: the only remaining honest reference set is the one this process is
// already standing in. If the builder is not beside us, say which of the two modes the
// caller meant rather than falling back to a local SDK build that would answer a
// different question.
if (!File.Exists(InImageBuilder))
{
await error.WriteLineAsync(
$"error: no --image was given and '{InImageBuilder}' is not here, so there is no "
+ "container to build against. Pass --image <image> to run the build inside one; "
+ "omitting it is only valid when this command is itself running inside a "
+ "MeshWeaver image. There is deliberately no local-SDK fallback — the reference "
+ "set IS the container.");
return 4;
}
var local = new List<string> { BuilderVerb, entry, "--output", outputDirectory };
local.AddRange(builderArgs.Skip(4));
return await _runner.Exec(InImageBuilder, local, ct);
}

var pinned = options.NoPull
? await _runner.UseLocalImage(image, ct)
: await _runner.PullImage(image, ct);
if (pinned is null) return 4;
await output.WriteLineAsync($"image: {pinned}");
await output.WriteLineAsync($"source root: {root} → /repo; project: /repo/{relative}");

var mounts = new List<string> { $"{root}:/repo:ro", $"{outputDirectory}:/out" };
foreach (var dir in options.ExtraReferenceDirectories.Select(Path.GetFullPath))
{
if (!Directory.Exists(dir))
{
await error.WriteLineAsync(
$"error: --extra-refs '{dir}' does not exist. An additional-library directory that "
+ "is not there would silently supply nothing.");
return 2;
}
var mountPoint = $"/refs/{Path.GetFileName(dir.TrimEnd(Path.DirectorySeparatorChar))}";
mounts.Add($"{dir}:{mountPoint}:ro");
builderArgs.Add("--extra-refs");
builderArgs.Add(mountPoint);
}

var exit = await _runner.RunInImage(pinned, mounts, [], builderArgs, ct);
if (exit == 0)
await output.WriteLineAsync($"assemblies: {outputDirectory}");
return exit;
}

/// <summary>
/// The nearest <c>Directory.Build.props</c> ancestor's directory, or <paramref name="start"/>
/// when no ancestor carries one. The same walk MSBuild performs, and the same one the in-image
/// builder performs — the two must agree or the mount and the build disagree about which
/// projects are ours.
/// </summary>
/// <param name="start">The project's directory.</param>
/// <returns>The source root.</returns>
public static string FindSourceRoot(string start)
{
ArgumentException.ThrowIfNullOrWhiteSpace(start);
var dir = new DirectoryInfo(Path.GetFullPath(start));
while (dir is not null)
{
if (File.Exists(Path.Combine(dir.FullName, "Directory.Build.props")))
return dir.FullName;
dir = dir.Parent;
}
return Path.GetFullPath(start);
}
}

/// <summary>Arguments for <see cref="BuildProjectCommand"/>. A record so a caller cannot half-set it.</summary>
public sealed record BuildProjectOptions
{
/// <summary>The <c>.csproj</c> to build, or a directory holding exactly one.</summary>
public required string ProjectPath { get; init; }

/// <summary>Image to build against; omitted only when already running inside one.</summary>
public string? Image { get; init; }

/// <summary>Use the image the docker daemon already has instead of pulling it — for an image
/// built locally, which no registry can serve.</summary>
public bool NoPull { get; init; }

/// <summary>Where the emitted assemblies land on the host. Default: a temp directory.</summary>
public string? Output { get; init; }

/// <summary>Overrides the mounted source root when a repo's import chain reaches above it.</summary>
public string? SourceRoot { get; init; }

/// <summary>Host directories of libraries ADDITIONAL to the platform, mounted read-only.</summary>
public IReadOnlyList<string> ExtraReferenceDirectories { get; init; } = [];

/// <summary>Constructs the evaluator may not reproduce, acknowledged one by one.</summary>
public IReadOnlyList<string> Accept { get; init; } = [];

/// <summary>Opts out of the no-warn policy (<c>--no-warn=false</c> / <c>--allow-warnings</c>).</summary>
public bool AllowWarnings { get; init; }

/// <summary>Anything else to pass straight through to the in-image builder.</summary>
public IReadOnlyList<string> ExtraArgs { get; init; } = [];
}
Loading
Loading