feat(install): orchestrate install via single root-package LocalProvide call

This commit is contained in:
2026-08-08 18:12:42 -04:00
parent 8f9a3c85a4
commit 88047eb4ca
3 changed files with 456 additions and 0 deletions
+253
View File
@@ -14,6 +14,7 @@ module tofu.install;
import tofu.config;
import tofu.log;
import tofu.types : BuildPlan, BuildPlanEntry, Source;
// Selective imports to avoid `Config` name conflict between
// `std.process.Config` and `tofu.config.Config`.
@@ -21,6 +22,8 @@ import std.process : pipeProcess, ProcessPipes, Redirect, wait,
ProcessException;
import std.stdio : write, writeln, stdout;
import std.string : indexOf;
import std.conv : to;
import std.file : exists;
// ─── Exception ───────────────────────────────────────────────────────────────
@@ -142,6 +145,77 @@ void runLocalProvide(string pkgName, Config cfg)
"install failed for " ~ pkgName ~ ": " ~ lastLines);
}
/// Install orchestrator: verify all recipe-built packages exist in the
/// built cache, then invoke `zeta -LocalProvide` on the **root** package.
/// ZETA's `deps.resolve` walks the full dependency tree internally —
/// tofu passes only the root, not individual dependencies.
///
/// Root = last entry in plan.order() (topological order: deps first,
/// target last — same convention as `tofu.deps` and `tofu.resolve`).
///
/// Params:
/// plan = build plan from `generateBuildPlan` (only recipe entries).
/// cfg = loaded tofu configuration.
///
/// Throws:
/// InstallException if any recipe-built package is missing from the
/// cache, or if the zeta invocation fails.
///
/// Empty plan is a no-op (logInfo + return).
///
/// "already installed" is handled by `runLocalProvide` — exit 0 with
/// the expected message produces a logInfo note and no throw.
void installAll(BuildPlan plan, Config cfg)
{
// ── 1. Empty plan → no-op ────────────────────────────────────────────
if (plan.isEmpty())
{
logInfo("nothing to install");
return;
}
auto order = plan.order();
// ── 2. Verify all recipe-built packages exist in cache ───────────────
// ZETA's deps.resolve fetches package.lua from ZETA_LOCAL_PACKAGES.
// If a dependency is missing, deps.resolve fails with an unclear
// error — pre-checking gives a clear tofu-level message.
foreach (entry; order)
{
if (entry.source == Source.recipe)
{
auto pkgPath = cfg.builtPackagesDir() ~ "/" ~ entry.name
~ "/package.lua";
bool pkgExists;
() @trusted { pkgExists = exists(pkgPath); }();
if (!pkgExists)
{
throw new InstallException(
"built package missing from cache: " ~ entry.name
~ " (was the build skipped?)");
}
}
}
// ── 3. Determine root (last entry — topological order) ──────────────
string rootName = order[$ - 1].name;
// ── 4. Call runLocalProvide for the root ONLY ───────────────────────
// ZETA's deps.resolve handles the full tree: recipe-built deps via
// ZETA_LOCAL_PACKAGES, binary deps via ZETA_REPO. Tofu does NOT
// iterate per package. InstallException propagates to caller.
runLocalProvide(rootName, cfg);
// ── 5. Success ──────────────────────────────────────────────────────
logOk("installed " ~ rootName ~ " with "
~ (order.length - 1).to!string ~ " dependencies");
}
// ─── Unittests ───────────────────────────────────────────────────────────────
private:
@@ -348,3 +422,182 @@ SCRIPT");
"expected ZETA_REPO=" ~ cfg.zuurUrl ~ "/binary"
~ " in: " ~ captured);
}
// ═══════════════════════════════════════════════════════════════════════════════
// installAll unittests
// ═══════════════════════════════════════════════════════════════════════════════
// ── Test (1): plan [B, C, A(root)] with all package.lua
// → fake zeta invoked with ONLY root name "A" ──────────────────────────
@safe unittest
{
import std.file : mkdirRecurse, write, readText;
import std.string : strip, replace;
auto tmp = makeTempDir("installall-args");
scope (exit) removeDir(tmp);
// Create built package directories with package.lua files.
auto builtDir = tmp ~ "/built/packages";
foreach (name; ["A", "B", "C"])
{
auto pkgDir = builtDir ~ "/" ~ name;
() @trusted { mkdirRecurse(pkgDir); }();
() @trusted { write(pkgDir ~ "/package.lua", "-- fake package\n"); }();
}
// Fake zeta: capture package name arg ($2) to a file.
auto scriptPath = tmp ~ "/fake-zeta";
string scriptContent = replace(q"SCRIPT
#!/bin/bash
echo "$2" >> __ARGS_FILE__
exit 0
SCRIPT", "__ARGS_FILE__", tmp ~ "/args.txt");
writeFakeScript(scriptPath, scriptContent);
auto cfg = testConfig(scriptPath, tmp);
BuildPlan plan;
plan.add("B", "/fake/B.recipe", Source.recipe);
plan.add("C", "/fake/C.recipe", Source.recipe);
plan.add("A", "/fake/A.recipe", Source.recipe);
installAll(plan, cfg);
// Verify fake zeta received ONLY "A" as the package name.
string argsContent;
() @trusted { argsContent = readText(tmp ~ "/args.txt"); }();
auto capturedName = argsContent.strip;
assert(capturedName == "A",
"expected root name 'A', got '" ~ capturedName ~ "'");
}
// ── Test (2): missing built package.lua → InstallException ──────────────
@safe unittest
{
import std.file : mkdirRecurse, write;
auto tmp = makeTempDir("installall-missing");
scope (exit) removeDir(tmp);
// Create package.lua for C and A only — B is missing.
auto builtDir = tmp ~ "/built/packages";
foreach (name; ["A", "C"])
{
auto pkgDir = builtDir ~ "/" ~ name;
() @trusted { mkdirRecurse(pkgDir); }();
() @trusted { write(pkgDir ~ "/package.lua", "-- fake package\n"); }();
}
auto scriptPath = tmp ~ "/fake-zeta";
writeFakeScript(scriptPath, q"SCRIPT
#!/bin/bash
exit 0
SCRIPT");
auto cfg = testConfig(scriptPath, tmp);
BuildPlan plan;
plan.add("B", "/fake/B.recipe", Source.recipe);
plan.add("C", "/fake/C.recipe", Source.recipe);
plan.add("A", "/fake/A.recipe", Source.recipe);
try
{
installAll(plan, cfg);
assert(false, "expected InstallException for missing built package");
}
catch (InstallException e)
{
assert(e.msg.indexOf("built package missing from cache: B") >= 0,
"expected 'built package missing from cache: B' in: " ~ e.msg);
assert(e.msg.indexOf("was the build skipped?") >= 0);
}
}
// ── Test (3): empty plan → no-op (logInfo, return) ──────────────────────
@safe unittest
{
auto tmp = makeTempDir("installall-empty");
scope (exit) removeDir(tmp);
auto scriptPath = tmp ~ "/fake-zeta";
writeFakeScript(scriptPath, q"SCRIPT
#!/bin/bash
echo "SHOULD NOT BE INVOKED"
exit 1
SCRIPT");
auto cfg = testConfig(scriptPath, tmp);
BuildPlan plan; // empty
installAll(plan, cfg); // Should not throw and not invoke zeta.
}
// ── Test (4): fake zeta exits 1 → InstallException ──────────────────────
@safe unittest
{
import std.file : mkdirRecurse, write;
auto tmp = makeTempDir("installall-exit1");
scope (exit) removeDir(tmp);
auto pkgDir = tmp ~ "/built/packages/A";
() @trusted { mkdirRecurse(pkgDir); }();
() @trusted { write(pkgDir ~ "/package.lua", "-- fake package\n"); }();
auto scriptPath = tmp ~ "/fake-zeta";
writeFakeScript(scriptPath, q"SCRIPT
#!/bin/bash
echo "install failed: conflict detected"
exit 1
SCRIPT");
auto cfg = testConfig(scriptPath, tmp);
BuildPlan plan;
plan.add("A", "/fake/A.recipe", Source.recipe);
try
{
installAll(plan, cfg);
assert(false, "expected InstallException");
}
catch (InstallException e)
{
assert(e.msg.indexOf("install failed for A") >= 0,
"expected 'install failed for A' in: " ~ e.msg);
assert(e.msg.indexOf("install failed: conflict detected") >= 0);
}
}
// ── Test (5): "already installed" exit 0 → no throw ─────────────────────
@safe unittest
{
import std.file : mkdirRecurse, write;
auto tmp = makeTempDir("installall-already");
scope (exit) removeDir(tmp);
auto pkgDir = tmp ~ "/built/packages/A";
() @trusted { mkdirRecurse(pkgDir); }();
() @trusted { write(pkgDir ~ "/package.lua", "-- fake package\n"); }();
auto scriptPath = tmp ~ "/fake-zeta";
writeFakeScript(scriptPath, q"SCRIPT
#!/bin/bash
echo "testpkg-2.1 is already installed -- use -ReProvide to reinstall"
exit 0
SCRIPT");
auto cfg = testConfig(scriptPath, tmp);
BuildPlan plan;
plan.add("A", "/fake/A.recipe", Source.recipe);
installAll(plan, cfg); // Should not throw.
}