feat(install): orchestrate install via single root-package LocalProvide call
This commit is contained in:
@@ -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.
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user