feat(install): invoke zeta -LocalProvide with ZETA_LOCAL_PACKAGES and ZETA_REPO env
This commit is contained in:
@@ -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 phasedone[33mwarn corrupted cache for pkg: Found 'h' when expecting 'r'. (Line 1:2)[0m
|
||||
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 -
|
||||
[33mwarn skipping index entry with empty name[0m
|
||||
[33mwarn skipping index entry with empty name[0m
|
||||
[33mwarn skipping index entry 'badpool': invalid pool 'bad_pool_value'[0m
|
||||
[2m . checking binary firefox ...[0m
|
||||
- binary firefox-2.1.0 satisfies firefox>=2.0
|
||||
[2m . checking binary libfoo ...[0m
|
||||
- binary libfoo-1.9 does not satisfy libfoo>=2.0
|
||||
[2m . checking binary noexist ...[0m
|
||||
[2m . checking binary brokenpkg ...[0m
|
||||
[2m . checking binary badpkg ...[0m
|
||||
[2m . checking binary testpkg ...[0m
|
||||
- binary testpkg-2.1.0 satisfies testpkg
|
||||
[36m==> fetching recipe hello[0m
|
||||
[2m . downloading hello.recipe[0m
|
||||
[2m . downloading package.lua[0m
|
||||
[2m . downloading build.sh[0m
|
||||
[32m ok recipe hello downloaded[0m
|
||||
[36m==> fetching recipe hello[0m
|
||||
[2m . downloading hello.recipe[0m
|
||||
[36m==> fetching recipe hello[0m
|
||||
[2m . downloading hello.recipe[0m
|
||||
[2m . downloading package.lua[0m
|
||||
- no package.lua for hello (optional manifest)
|
||||
[2m . downloading build.sh[0m
|
||||
- no build.sh for hello (optional build script)
|
||||
[2m . custom build system: fetching scripts/build.sh[0m
|
||||
[32m ok recipe hello downloaded[0m
|
||||
[36m==> fetching recipe hello[0m
|
||||
[2m . downloading hello.recipe[0m
|
||||
[2m . downloading package.lua[0m
|
||||
- no package.lua for hello (optional manifest)
|
||||
[2m . downloading build.sh[0m
|
||||
- no build.sh for hello (optional build script)
|
||||
[32m ok recipe hello downloaded[0m
|
||||
[36m==> fetching recipe hello[0m
|
||||
[2m . downloading hello.recipe[0m
|
||||
[2m . fetching index http://127.0.0.1:47181//index.lua[0m
|
||||
[32m ok index loaded: 3 packages[0m
|
||||
[2m . fetching index http://127.0.0.1:53573//index.lua[0m
|
||||
[2m . fetching index http://127.0.0.1:58917//index.lua[0m
|
||||
[2m . fetching index http://127.0.0.1:50501//index.lua[0m
|
||||
[2m . fetching index http://127.0.0.1:53705//index.lua[0m
|
||||
[32m ok index loaded: 0 packages[0m
|
||||
[2m . fetching index http://127.0.0.1:58781//index.lua[0m
|
||||
[32m ok index loaded: 1 packages[0m
|
||||
[2m . fetching index http://127.0.0.1:48757//index.lua[0m
|
||||
[32m ok index loaded: 2 packages[0m
|
||||
[2m . fetching index http://127.0.0.1:53589//index.lua[0m
|
||||
[2m . fetching index http://127.0.0.1:59755//index.lua[0m
|
||||
[32m ok index loaded: 1 packages[0m
|
||||
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)
|
||||
[36m==> generating build plan[0m
|
||||
- + C (recipe)
|
||||
- + A (recipe)
|
||||
[32m ok build plan: 2 packages[0m
|
||||
[36m==> generating build plan[0m
|
||||
- recipe libfoo not in cache, re-fetching
|
||||
- + libfoo (recipe)
|
||||
[32m ok build plan: 1 packages[0m
|
||||
[36m==> generating build plan[0m
|
||||
[36m==> generating build plan[0m
|
||||
- nothing to build (all binary)
|
||||
[36m==> generating build plan[0m
|
||||
- + mypkg (recipe)
|
||||
[32m ok build plan: 1 packages[0m
|
||||
[36m==> generating build plan[0m
|
||||
- + libfoo (recipe)
|
||||
- + mypkg (recipe)
|
||||
[32m ok build plan: 2 packages[0m
|
||||
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
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user