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
+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);
}