feat(install): invoke zeta -LocalProvide with ZETA_LOCAL_PACKAGES and ZETA_REPO env

This commit is contained in:
2026-08-08 18:05:27 -04:00
parent da19ac98b1
commit c43c1a78d9
2 changed files with 479 additions and 0 deletions
+129
View File
@@ -0,0 +1,129 @@
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.
Up-to-date tofu ~main: target for configuration [tofu-test-application] is up to date.
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 phasedonewarn corrupted cache for pkg: Found 'h' when expecting 'r'. (Line 1:2)
Warning: malformed TOML config at /tmp/tofu-test-config-bad-211516.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:04:14] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:04:14] "GET /recipes/hello/package.lua HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:04:14] "GET /recipes/hello/build.sh HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:04:14] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:04:14] "GET /recipes/hello/hello.recipe HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:04:14] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:04:14] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:04:14] "GET /recipes/hello/package.lua HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:04:14] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:04:14] "GET /recipes/hello/build.sh HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:04:14] "GET /recipes/hello/scripts/build.sh HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:04:14] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:04:14] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:04:14] "GET /recipes/hello/package.lua HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:04:14] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:04:14] "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
==> 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:47181//index.lua
 ok index loaded: 3 packages
 . fetching index http://127.0.0.1:53573//index.lua
 . fetching index http://127.0.0.1:58917//index.lua
 . fetching index http://127.0.0.1:50501//index.lua
 . fetching index http://127.0.0.1:53705//index.lua
 ok index loaded: 0 packages
 . fetching index http://127.0.0.1:58781//index.lua
 ok index loaded: 1 packages
 . fetching index http://127.0.0.1:48757//index.lua
 ok index loaded: 2 packages
 . fetching index http://127.0.0.1:53589//index.lua
 . fetching index http://127.0.0.1:59755//index.lua
 ok index loaded: 1 packages
installing package...error: build failedreason: missing dependencytestpkg-2.1 is already installed -- use -ReProvide to reinstall13 modules passed unittests
- already installed — skipping
- 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
+350
View File
@@ -0,0 +1,350 @@
/// tofu.install — invoke ZETA to install built packages into the system.
///
/// Wraps `zeta -LocalProvide <pkgName> --pass` with:
/// - Environment: `ZETA_LOCAL_PACKAGES` (built packages dir) and
/// `ZETA_REPO` (binary pool URL), passed per-child via `pipeProcess`.
/// - ZETA_ROOT is intentionally NOT set (config has no such field).
/// - Real-time output streaming with a rolling 20-line buffer for
/// error reporting.
///
/// The per-child env is set via `pipeProcess`'s `env` parameter — the
/// child process inherits the parent env plus these overrides. No
/// parent-process environment mutation is needed.
module tofu.install;
import tofu.config;
import tofu.log;
// Selective imports to avoid `Config` name conflict between
// `std.process.Config` and `tofu.config.Config`.
import std.process : pipeProcess, ProcessPipes, Redirect, wait,
ProcessException;
import std.stdio : write, writeln, stdout;
import std.string : indexOf;
// ─── Exception ───────────────────────────────────────────────────────────────
/// Thrown when a package install fails.
class InstallException : Exception
{
this(string message, string file = __FILE__, size_t line = __LINE__)
@safe pure nothrow
{
super(message, file, line);
}
}
// ─── Public API ──────────────────────────────────────────────────────────────
@safe:
/// Invoke `zeta -LocalProvide <pkgName> --pass` to install a previously-built
/// package from the local tree into the system.
///
/// The child process is spawned with `pipeProcess` so output is captured in
/// real-time: each line is written immediately to the parent's stdout and
/// buffered (last 20 lines) for error reporting.
///
/// Params:
/// pkgName = Name of the package to install (passed to `-LocalProvide`).
/// cfg = Loaded tofu configuration.
///
/// Throws:
/// InstallException if zeta exits non-zero or if the zeta binary cannot
/// be found.
///
/// "already installed" messages (ZETA `actions.localprovide` line 174–178)
/// produce exit code 0 — treated as a non-error with a logInfo note.
void runLocalProvide(string pkgName, Config cfg)
{
// ── 1. Determine zeta binary ────────────────────────────────────────
string zetaBin = cfg.zetaPath.length > 0 ? cfg.zetaPath : "zeta";
// ── 2. Build per-child environment ──────────────────────────────────
const string[string] childEnv = [
"ZETA_LOCAL_PACKAGES": cfg.builtPackagesDir(),
"ZETA_REPO": cfg.zuurUrl ~ "/binary",
];
// ZETA_ROOT intentionally not set — config has no ZETA_ROOT field.
// ── 3. Spawn child with piped stdout + stderr-into-stdout ───────────
ProcessPipes pipes;
() @trusted
{
try
{
pipes = pipeProcess(
[zetaBin, "-LocalProvide", pkgName, "--pass"],
Redirect.stdout | Redirect.stderrToStdout,
childEnv,
);
}
catch (ProcessException e)
{
throw new InstallException(
"zeta not found. Install Zeta to use package management.");
}
}();
scope (exit)
{
() @trusted
{
try { pipes.stdout.close(); } catch (Exception) {}
}();
}
// ── 4. Read output in real-time (tee + rolling buffer) ─────────────
string[] rollingBuffer;
() @trusted
{
foreach (line; pipes.stdout.byLine)
{
string s = line.idup;
write(s); // line already ends with \n
stdout.flush();
rollingBuffer ~= s;
if (rollingBuffer.length > 20)
rollingBuffer = rollingBuffer[1 .. $];
}
}();
// ── 5. Wait for exit ───────────────────────────────────────────────
int exitStatus;
() @trusted { exitStatus = wait(pipes.pid); }();
// ── 6. Build last-lines string (for error / "already installed") ───
string lastLines;
foreach (line; rollingBuffer)
lastLines ~= line; // line already ends with \n
// ── 7. Handle result ───────────────────────────────────────────────
if (exitStatus == 0)
{
// ZETA actions.localprovide: already-installed + exit 0 → NOT an error.
if (lastLines.indexOf("already installed") >= 0)
{
logInfo("already installed — skipping");
}
return;
}
// Non-zero exit → InstallException with last 20 lines of output.
throw new InstallException(
"install failed for " ~ pkgName ~ ": " ~ lastLines);
}
// ─── Unittests ───────────────────────────────────────────────────────────────
private:
/// Write a shell script to the given path and make it executable.
@trusted void writeFakeScript(string path, string content)
{
import std.file : write, setAttributes;
write(path, content);
// chmod +x
import std.process : execute;
execute(["chmod", "+x", path]);
}
/// Remove a file, ignoring errors.
@trusted void removeFile(string path)
{
import std.file : remove;
try { remove(path); } catch (Exception) {}
}
/// Create a unique temp directory path for a test.
@trusted string makeTempDir(string suffix)
{
import std.path : buildPath;
import std.file : tempDir, mkdirRecurse;
import std.process : thisProcessID;
import std.conv : to;
auto path = buildPath(tempDir, "tofu-install-test-" ~ suffix
~ "-" ~ thisProcessID.to!string);
mkdirRecurse(path);
return path;
}
/// Remove a directory tree, ignoring errors.
@trusted void removeDir(string path)
{
import std.file : rmdirRecurse;
try { rmdirRecurse(path); } catch (Exception) {}
}
/// Build a `Config` that points `zetaPath` at a fake script and uses
/// a given temp dir as cache root (so `builtPackagesDir()` resolves).
@safe Config testConfig(string zetaScript, string cacheDir)
{
import tofu.config : load;
const string[string] env = [
"TOFU_CACHE_DIR": cacheDir,
"TOFU_ZETA_PATH": zetaScript,
];
return load(null, env);
}
// ── Test (1): fake zeta exits 0 → returns, no throw ─────────────────────
@safe unittest
{
auto tmp = makeTempDir("exit0");
scope (exit) removeDir(tmp);
// Fake script that writes env vars to a file and exits cleanly.
auto scriptPath = tmp ~ "/fake-zeta";
import std.string : replace;
string scriptContent = replace(q"SCRIPT
#!/bin/bash
echo "ZETA_LOCAL_PACKAGES=$ZETA_LOCAL_PACKAGES" >> __ENV_FILE__
echo "ZETA_REPO=$ZETA_REPO" >> __ENV_FILE__
echo "installing package..." >&2
exit 0
SCRIPT", "__ENV_FILE__", tmp ~ "/env.txt");
writeFakeScript(scriptPath, scriptContent);
auto cfg = testConfig(scriptPath, tmp);
runLocalProvide("testpkg", cfg);
// Verify env vars were set correctly.
import std.file : readText;
string envContent;
() @trusted { envContent = readText(tmp ~ "/env.txt"); }();
assert(envContent.indexOf("ZETA_LOCAL_PACKAGES=" ~ cfg.builtPackagesDir()) >= 0);
assert(envContent.indexOf("ZETA_REPO=" ~ cfg.zuurUrl ~ "/binary") >= 0);
}
// ── Test (2): fake zeta exits 1 → InstallException ──────────────────────
@safe unittest
{
auto tmp = makeTempDir("exit1");
scope (exit) removeDir(tmp);
auto scriptPath = tmp ~ "/fake-zeta";
writeFakeScript(scriptPath, q"SCRIPT
#!/bin/bash
echo "error: build failed"
echo "reason: missing dependency"
exit 1
SCRIPT");
auto cfg = testConfig(scriptPath, tmp);
try
{
runLocalProvide("badpkg", cfg);
assert(false, "expected InstallException");
}
catch (InstallException e)
{
assert(e.msg.indexOf("install failed for badpkg") >= 0);
assert(e.msg.indexOf("error: build failed") >= 0);
assert(e.msg.indexOf("reason: missing dependency") >= 0);
}
}
// ── Test (3): "already installed" exit 0 → no throw, logInfo note ──────
@safe unittest
{
auto tmp = makeTempDir("already");
scope (exit) removeDir(tmp);
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);
// Should not throw.
runLocalProvide("testpkg", cfg);
}
// ── Test (4): zeta not found → InstallException ────────────────────────
@safe unittest
{
auto tmp = makeTempDir("notfound");
scope (exit) removeDir(tmp);
// Point at a nonexistent binary path.
auto cfg = testConfig(tmp ~ "/nonexistent-zeta", tmp);
try
{
runLocalProvide("testpkg", cfg);
assert(false, "expected InstallException");
}
catch (InstallException e)
{
assert(e.msg.indexOf("zeta not found") >= 0);
}
}
// ── Test (5): env correctness (ZETA_LOCAL_PACKAGES, ZETA_REPO) ─────────
@safe unittest
{
auto tmp = makeTempDir("envcheck");
scope (exit) removeDir(tmp);
auto scriptPath = tmp ~ "/fake-zeta";
// Script that dumps env vars and exits 0.
writeFakeScript(scriptPath, q"SCRIPT
#!/bin/bash
echo "ZETA_LOCAL_PACKAGES=$ZETA_LOCAL_PACKAGES"
echo "ZETA_REPO=$ZETA_REPO"
exit 0
SCRIPT");
auto cfg = testConfig(scriptPath, tmp);
// Capture stdout during the call to verify env values in output.
import std.file : exists, remove, tempDir;
import std.stdio : File;
auto capturePath = tempDir ~ "/tofu-install-capture-" ~
() @trusted {
import std.process : thisProcessID;
import std.conv : to;
return thisProcessID.to!string;
}() ~ ".txt";
scope (exit) { () @trusted { if (exists(capturePath)) remove(capturePath); }(); }
// Capture stdout via file-swap (trusted — mirrors log.d's capture()).
() @trusted {
auto captureFile = File(capturePath, "w+");
auto savedStdout = stdout;
stdout = captureFile;
scope (exit) stdout = savedStdout;
runLocalProvide("testpkg", cfg);
stdout.flush();
}();
// Read captured output.
import std.file : readText;
string captured;
() @trusted { captured = readText(capturePath); }();
assert(captured.indexOf("ZETA_LOCAL_PACKAGES=" ~ cfg.builtPackagesDir()) >= 0,
"expected ZETA_LOCAL_PACKAGES=" ~ cfg.builtPackagesDir()
~ " in: " ~ captured);
assert(captured.indexOf("ZETA_REPO=" ~ cfg.zuurUrl ~ "/binary") >= 0,
"expected ZETA_REPO=" ~ cfg.zuurUrl ~ "/binary"
~ " in: " ~ captured);
}