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
+152
View File
@@ -0,0 +1,152 @@
Warning
Warning ## Warning for package tofu ##
Warning
Warning The following compiler flags have been specified in the package description
Warning file. They are handled by DUB and direct use in packages is discouraged.
Warning Alternatively, you can set the DFLAGS environment variable to pass custom flags
Warning to the compiler, or use one of the suggestions below:
Warning
Warning warningsAsErrors: Use "buildRequirements" to control the warning level
Warning
Generating test runner configuration 'tofu-test-application' for 'application' (executable).
Warning Excluding main source file src/main.d from test.
Starting Performing "unittest" build using /usr/bin/dmd for x86_64.
Up-to-date toml 1.0.0: target for configuration [library] is up to date.
Building tofu ~main: building configuration [tofu-test-application]
Linking tofu-test-application
Finished To force a rebuild of up-to-date targets, run again with --force
Running tofu-test-application
zeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasedonezeta-makepkg: building depA...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building depB...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building target...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building depA...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phaseerror build failed for depA: build failed for depA:
zeta-makepkg: building depA...
zeta-makepkg: configure phase
zeta-makepkg: build phase
zeta-makepkg: install phase
zeta-makepkg: building pkg...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phaseerror build failed for noexist: recipe not found at /tmp/tofu-test-build-buildall-norecipe-216783/nonexistent.recipe
warn corrupted cache for pkg: Found 'h' when expecting 'r'. (Line 1:2)
Warning: malformed TOML config at /tmp/tofu-test-config-bad-216783.toml: Invalid table key declaration (2:0)
Warning: invalid TOFU_DEFAULT_JOBS 'not-a-number', using default 1
127.0.0.1 - - [08/Aug/2026 18:12:13] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:12:13] "GET /recipes/hello/package.lua HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:12:13] "GET /recipes/hello/build.sh HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:12:13] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:12:13] "GET /recipes/hello/hello.recipe HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:12:13] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:12:13] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:12:13] "GET /recipes/hello/package.lua HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:12:13] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:12:13] "GET /recipes/hello/build.sh HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:12:13] "GET /recipes/hello/scripts/build.sh HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:12:13] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:12:13] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:12:13] "GET /recipes/hello/package.lua HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:12:13] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:12:13] "GET /recipes/hello/build.sh HTTP/1.1" 404 -
warn skipping index entry with empty name
warn skipping index entry with empty name
warn skipping index entry 'badpool': invalid pool 'bad_pool_value'
 . checking binary firefox ...
- binary firefox-2.1.0 satisfies firefox>=2.0
 . checking binary libfoo ...
- binary libfoo-1.9 does not satisfy libfoo>=2.0
 . checking binary noexist ...
 . checking binary brokenpkg ...
 . checking binary badpkg ...
 . checking binary testpkg ...
- binary testpkg-2.1.0 satisfies testpkg
==> ──── building depA (1/3) ────
 ok depA
==> ──── building depB (2/3) ────
 ok depB
==> ──── building target (3/3) ────
 ok target
==> ──── building depA (1/3) ────
- nothing to build
==> ──── building pkg (1/1) ────
- pkg already built, skipping
==> ──── building pkg (1/1) ────
 ok pkg
==> fetching recipe hello
 . downloading hello.recipe
 . downloading package.lua
 . downloading build.sh
 ok recipe hello downloaded
==> fetching recipe hello
 . downloading hello.recipe
==> fetching recipe hello
 . downloading hello.recipe
 . downloading package.lua
- no package.lua for hello (optional manifest)
 . downloading build.sh
- no build.sh for hello (optional build script)
 . custom build system: fetching scripts/build.sh
 ok recipe hello downloaded
==> fetching recipe hello
 . downloading hello.recipe
 . downloading package.lua
- no package.lua for hello (optional manifest)
 . downloading build.sh
- no build.sh for hello (optional build script)
 ok recipe hello downloaded
==> fetching recipe hello
 . downloading hello.recipe
 . fetching index http://127.0.0.1:50721//index.lua
 ok index loaded: 3 packages
 . fetching index http://127.0.0.1:47569//index.lua
 . fetching index http://127.0.0.1:42797//index.lua
 . fetching index http://127.0.0.1:42795//index.lua
 . fetching index http://127.0.0.1:51365//index.lua
 ok index loaded: 0 packages
 . fetching index http://127.0.0.1:51389//index.lua
 ok index loaded: 1 packages
 . fetching index http://127.0.0.1:42841//index.lua
 ok index loaded: 2 packages
 . fetching index http://127.0.0.1:59485//index.lua
 . fetching index http://127.0.0.1:35061//index.lua
 ok index loaded: 1 packages
installing package...error: build failedreason: missing dependencytestpkg-2.1 is already installed -- use -ReProvide to reinstall - already installed — skipping
 ok installed A with 2 dependencies
- nothing to install
install failed: conflict detectedtestpkg-2.1 is already installed -- use -ReProvide to reinstall13 modules passed unittests
- already installed — skipping
 ok installed A with 0 dependencies
- binary libfoo-2.1 satisfies libfoo>=2.0
- libfoo: binary 1.9 too old, building from recipe
- libfoo: binary 1.9 too old, building from recipe
- binary libbar-3.0 satisfies libbar (unconstrained)
- libbaz: no binary available, building from recipe
- binary libfoo-1.5 satisfies libfoo>=1.0
- libfoo: binary 1.5 too old, building from recipe
- binary parentA-1.5 satisfies parentA (unconstrained)
- binary parentB-1.5 satisfies parentB (unconstrained)
==> generating build plan
- + C (recipe)
- + A (recipe)
 ok build plan: 2 packages
==> generating build plan
- recipe libfoo not in cache, re-fetching
- + libfoo (recipe)
 ok build plan: 1 packages
==> generating build plan
==> generating build plan
- nothing to build (all binary)
==> generating build plan
- + mypkg (recipe)
 ok build plan: 1 packages
==> generating build plan
- + libfoo (recipe)
- + mypkg (recipe)
 ok build plan: 2 packages
Warning
Warning ## Warning for package tofu ##
Warning
Warning The following compiler flags have been specified in the package description
Warning file. They are handled by DUB and direct use in packages is discouraged.
Warning Alternatively, you can set the DFLAGS environment variable to pass custom flags
Warning to the compiler, or use one of the suggestions below:
Warning
Warning warningsAsErrors: Use "buildRequirements" to control the warning level
Warning
Starting Performing "debug" build using /usr/bin/dmd for x86_64.
Up-to-date toml 1.0.0: target for configuration [library] is up to date.
Up-to-date tofu ~main: target for configuration [application] is up to date.
Finished To force a rebuild of up-to-date targets, run again with --force
+51
View File
@@ -684,3 +684,54 @@ The sandbox test (test 2) creates a sentinel file, serves an index containing `o
- `dub test` passes — all 13 modules, including 6 new buildAll unittests. - `dub test` passes — all 13 modules, including 6 new buildAll unittests.
- `dub build` passes with `warningsAsErrors`. - `dub build` passes with `warningsAsErrors`.
- Evidence logged to `.omo/evidence/task-15-tofu-core.log`. - Evidence logged to `.omo/evidence/task-15-tofu-core.log`.
---
## Task 17 — `tofu.install.installAll` (install orchestrator)
### Architecture
- `installAll(BuildPlan plan, Config cfg)` added to `tofu.install` — single root-package `-LocalProvide` call.
- Imports added: `tofu.types` (selective: `BuildPlan`, `BuildPlanEntry`, `Source`), `std.conv : to`, `std.file : exists`.
- Reuses `runLocalProvide` — no new process-spawning logic.
### Algorithm
1. **Empty plan** → `logInfo("nothing to install")`, return early.
2. **Verify built cache**: for each `Source.recipe` entry, check `cfg.builtPackagesDir()/name/package.lua` exists. Missing → `InstallException("built package missing from cache: <name> (was the build skipped?)")`.
3. **Root = last entry** in `plan.order()` — matches `deps.resolve`/`generateBuildPlan` convention (topological order, root last).
4. **Single call**: `runLocalProvide(rootName, cfg)` — ZETA's `deps.resolve` walks the full tree from `ZETA_LOCAL_PACKAGES` + `ZETA_REPO`.
5. **Success**: `logOk("installed <root> with N dependencies")` where N = `order.length - 1`.
6. **InstallException from `runLocalProvide`**: propagates to caller (no catch needed — install command records state).
### "already installed" handling
- Handled internally by `runLocalProvide` — exit 0 + "already installed" substring in output → `logInfo("already installed — skipping")`, no throw.
- `installAll` continues to `logOk` after.
### Dependencies counted
- All entries in `BuildPlan` are recipe-sourced (binary excluded by `generateBuildPlan`). Root is last entry. Dep count = `order.length - 1`.
### `@safe` / `@trusted` architecture
- `installAll` is `@safe` public.
- Only `std.file.exists` requires `@trusted` wrapper — inline lambda `() @trusted { pkgExists = exists(pkgPath); }()`.
### Imports strategy
- Top-level selective import: `import tofu.types : BuildPlan, BuildPlanEntry, Source;` — avoids pulling in the full types module.
- `import std.conv : to;` for `to!string(size_t)`.
- `import std.file : exists;` at module level for the built-cache verification check.
### Unittests — 5 new test blocks (test 1–5 for installAll)
- Reused existing test infrastructure: `makeTempDir`, `removeDir`, `writeFakeScript`, `testConfig`.
- Test (1): plan [B, C, A] with all package.lua + fake zeta captures `$2` → "A" only, no "B"/"C".
- Test (2): plan missing B's package.lua → `InstallException` "built package missing from cache: B".
- Test (3): empty plan → no-op, no throw.
- Test (4): fake zeta exits 1 → `InstallException` "install failed for A".
- Test (5): fake zeta prints "already installed" + exits 0 → no throw, `logOk` succeeds.
### Arg capture pattern
- Fake zeta script: `echo "$2" >> <path>` — `$2` = package name (args: `fake-zeta -LocalProvide <pkgName> --pass`).
- Previous learnings used `$3` incorrectly — in bash, `$0`=script name, `$1`=-LocalProvide, `$2`=pkgName, `$3`=--pass.
### Build verified
- `dub test` passes — all 13 modules, including 5 new installAll unittests (plus 5 pre-existing runLocalProvide tests = 10 total in install.d).
- `dub build` passes with `warningsAsErrors`.
- No D LSP server configured for `.d` — diagnostics verified via compiler.
- Evidence logged to `.omo/evidence/task-17-tofu-core.log`.
+253
View File
@@ -14,6 +14,7 @@ module tofu.install;
import tofu.config; import tofu.config;
import tofu.log; import tofu.log;
import tofu.types : BuildPlan, BuildPlanEntry, Source;
// Selective imports to avoid `Config` name conflict between // Selective imports to avoid `Config` name conflict between
// `std.process.Config` and `tofu.config.Config`. // `std.process.Config` and `tofu.config.Config`.
@@ -21,6 +22,8 @@ import std.process : pipeProcess, ProcessPipes, Redirect, wait,
ProcessException; ProcessException;
import std.stdio : write, writeln, stdout; import std.stdio : write, writeln, stdout;
import std.string : indexOf; import std.string : indexOf;
import std.conv : to;
import std.file : exists;
// ─── Exception ─────────────────────────────────────────────────────────────── // ─── Exception ───────────────────────────────────────────────────────────────
@@ -142,6 +145,77 @@ void runLocalProvide(string pkgName, Config cfg)
"install failed for " ~ pkgName ~ ": " ~ lastLines); "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 ─────────────────────────────────────────────────────────────── // ─── Unittests ───────────────────────────────────────────────────────────────
private: private:
@@ -348,3 +422,182 @@ SCRIPT");
"expected ZETA_REPO=" ~ cfg.zuurUrl ~ "/binary" "expected ZETA_REPO=" ~ cfg.zuurUrl ~ "/binary"
~ " in: " ~ captured); ~ " 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.
}