feat: nix builds the managed runtime dir from runtime-pins.json
nix/npm-12-0-2.nix pinned npm 12.0.2 with an SRI hash while runtime-pins.json pinned the same npm with a hex digest, and nothing connected them: two files to bump, and a devShell free to ship a different npm than every user's install. Nix is now a consumer of the pin table, not a second table. Shape: one derivation per pinned tool, `extends` in the table becoming a real Nix dependency (npm's derivation takes node's, so Nix orders the builds and neither side restates "npm needs node"), and a bundle that symlinks them into a runtime dir. That bundle is deliberately not a set of specially-wrapped programs. It is the directory layout runtime_registry.py already describes, and its runtimes.json is written by the registry's own code, so `hermes_cli.runtime_env` derives PATH order, GIT_EXEC_PATH and npm_config_cache from it exactly as on any other install kind. Nothing nix-specific: an earlier draft grew per-tool wrappers for each of those and every one duplicated behaviour that already existed and was tested. Sealed installs now fail loudly on drift. A git checkout provisions on demand, so a mismatch there is transient and raising would break the run that fixes it; a nix/docker/desktop tree cannot provision at all, so a mismatch means the artifact was assembled against a different pin table than the code it ships. `require_current_runtimes` refuses at that point and `hermes doctor` reports drift as an error rather than a warning, both keyed off the existing runtime_tree Sealed/GitCheckout split. Also fixes a real packaging bug this uncovered: runtime-pins.json lived only at the repo root and was never packaged, so any sealed venv install (uv2nix, docker, the desktop payload's site-packages) could not read the pin table it was built from. It ships inside hermes_cli too now, via a symlink so there is still one table, with pins_path() preferring the repo copy and a test asserting the two agree.
This commit is contained in:
parent
b812c34fb9
commit
d92d174ca4
|
|
@ -213,18 +213,36 @@ def _check_managed_runtimes() -> None:
|
|||
them rather than re-deriving existence by probing paths (hermes-home
|
||||
lifetime split, phase 3.10). Tools the registry does not know about
|
||||
are still reported from PATH so a system copy is visible.
|
||||
|
||||
Drift is a WARNING in a git checkout and an ERROR in a sealed tree.
|
||||
A checkout heals itself on the next `hermes update`; a sealed tree
|
||||
cannot provision anything, so a mismatch there means the artifact was
|
||||
built against a different pin table than the code it ships, and only
|
||||
its steward can fix it.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.runtime_registry import load_facts, load_pins
|
||||
from hermes_constants import get_runtime_dir
|
||||
from hermes_cli.runtime_tree import Sealed, runtime_tree
|
||||
from hermes_constants import get_install_root, get_runtime_dir
|
||||
|
||||
runtime_dir = get_runtime_dir()
|
||||
pins = load_pins()
|
||||
facts = load_facts(runtime_dir)
|
||||
tree = runtime_tree(get_install_root())
|
||||
except Exception as exc:
|
||||
check_warn("Managed runtimes unreadable", f"({exc})")
|
||||
return
|
||||
|
||||
sealed = isinstance(tree, Sealed)
|
||||
# What a user can actually do about it differs per install kind:
|
||||
# a checkout runs an update, a sealed tree rebuilds with its steward.
|
||||
remedy = (
|
||||
f"(rebuild this {tree.steward} artifact — a sealed install cannot provision)"
|
||||
if sealed
|
||||
else "(reprovisioned on the next 'hermes update')"
|
||||
)
|
||||
report_drift = check_fail if sealed else check_warn
|
||||
|
||||
for tool, pin in pins.items():
|
||||
fact = facts.get(tool)
|
||||
if fact is None:
|
||||
|
|
@ -232,23 +250,20 @@ def _check_managed_runtimes() -> None:
|
|||
if system:
|
||||
check_ok(f"{tool} (system)", f"at {system}")
|
||||
else:
|
||||
check_warn(
|
||||
f"{tool} not provisioned",
|
||||
"(installed on the next 'hermes update')",
|
||||
)
|
||||
report_drift(f"{tool} not provisioned", remedy)
|
||||
continue
|
||||
if not (runtime_dir / fact.path).is_file():
|
||||
check_warn(
|
||||
report_drift(
|
||||
f"{tool} recorded but missing",
|
||||
"(runtime dir was modified; 'hermes update' reinstalls it)",
|
||||
"(runtime dir was modified) " + remedy if not sealed else remedy,
|
||||
)
|
||||
continue
|
||||
# Pins are exact, so this is equality: anything else means the
|
||||
# pin moved and this install has not caught up yet.
|
||||
if fact.version != pin["version"]:
|
||||
check_warn(
|
||||
report_drift(
|
||||
f"{tool} {fact.version} does not match the pin {pin['version']}",
|
||||
"(reprovisioned on the next 'hermes update')",
|
||||
remedy,
|
||||
)
|
||||
continue
|
||||
check_ok(f"{tool} {fact.version}", "(managed)")
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
../runtime-pins.json
|
||||
|
|
@ -41,7 +41,8 @@ from dataclasses import dataclass
|
|||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
from hermes_constants import get_runtime_dir
|
||||
from hermes_constants import get_install_root, get_runtime_dir
|
||||
from hermes_cli.runtime_tree import Sealed, runtime_tree
|
||||
from hermes_cli.runtime_registry import (
|
||||
PinnedFile,
|
||||
RuntimeFact,
|
||||
|
|
@ -530,6 +531,80 @@ def provision_runtimes(
|
|||
return results
|
||||
|
||||
|
||||
def stale_tools(
|
||||
runtime_dir: Path | None = None,
|
||||
install_root: Path | None = None,
|
||||
target: str | None = None,
|
||||
) -> dict[str, tuple[str, Optional[str]]]:
|
||||
"""Pinned tools whose installed state does not match the pin table.
|
||||
|
||||
Maps tool → (pinned version, installed version or None). Empty means
|
||||
every pin is satisfied. This is the same equality check
|
||||
``_provision_one`` makes before deciding to re-download — exact pins
|
||||
make it an equality check, not a range check.
|
||||
"""
|
||||
rt = runtime_dir if runtime_dir is not None else get_runtime_dir()
|
||||
resolved_target = target or current_target()
|
||||
facts = load_facts(rt)
|
||||
drift: dict[str, tuple[str, Optional[str]]] = {}
|
||||
|
||||
for tool, entry in load_pins(install_root).items():
|
||||
fact = facts.get(tool)
|
||||
installed = fact.version if fact is not None else None
|
||||
if fact is not None and not (rt / _binary_rel(tool, resolved_target)).is_file():
|
||||
# Recorded but vanished reads as unprovisioned everywhere
|
||||
# else; say so here too rather than reporting it as current.
|
||||
installed = None
|
||||
if installed != entry["version"]:
|
||||
drift[tool] = (entry["version"], installed)
|
||||
return drift
|
||||
|
||||
|
||||
class StaleManagedRuntimes(RuntimeError):
|
||||
"""A sealed install's runtime tools disagree with its pin table."""
|
||||
|
||||
|
||||
def require_current_runtimes(
|
||||
project_root: Path | None = None,
|
||||
runtime_dir: Path | None = None,
|
||||
install_root: Path | None = None,
|
||||
) -> None:
|
||||
"""Fail fast when a SEALED install ships out-of-date runtime tools.
|
||||
|
||||
A git checkout provisions on demand: drift there is a normal state
|
||||
that the next `hermes update` (or the self-heal path) resolves, and
|
||||
raising would break the very run that fixes it.
|
||||
|
||||
A sealed tree cannot self-heal. Its steward — Nix, Docker, the
|
||||
desktop bundle — builds the runtime tools as part of the artifact, so
|
||||
drift means the artifact was assembled against a different pin table
|
||||
than the code it ships. Every consequence of that is worse and more
|
||||
confusing than stopping here: tools silently missing from PATH,
|
||||
or a version the code does not expect. The steward has to rebuild.
|
||||
"""
|
||||
root = project_root if project_root is not None else get_install_root()
|
||||
tree = runtime_tree(root)
|
||||
if not isinstance(tree, Sealed):
|
||||
return
|
||||
|
||||
drift = stale_tools(runtime_dir=runtime_dir, install_root=install_root)
|
||||
if not drift:
|
||||
return
|
||||
|
||||
lines = [
|
||||
f" {tool}: pinned {pinned}, installed {installed or 'nothing'}"
|
||||
for tool, (pinned, installed) in sorted(drift.items())
|
||||
]
|
||||
raise StaleManagedRuntimes(
|
||||
f"This Hermes is a sealed install managed by {tree.steward!r}, and its "
|
||||
"managed runtime tools do not match runtime-pins.json:\n"
|
||||
+ "\n".join(lines)
|
||||
+ "\n\nThe artifact was built against a different pin table than the code "
|
||||
"it ships. Rebuild it with its steward — a sealed tree cannot provision "
|
||||
"these itself."
|
||||
)
|
||||
|
||||
|
||||
def step_provision_runtimes() -> dict:
|
||||
"""post_update MACHINE_STEPS entry."""
|
||||
results = provision_runtimes()
|
||||
|
|
|
|||
|
|
@ -124,10 +124,20 @@ def pins_path(install_root: Path | None = None) -> Path:
|
|||
repo root for a checkout, the payload's repo/ dir for the desktop
|
||||
bundle) rather than ``get_install_root()`` — the install root is where
|
||||
tools get INSTALLED, and callers may point it elsewhere.
|
||||
|
||||
A wheel has no repo root: `pip install .` lays out site-packages with
|
||||
no parent-level data files, so the table is also packaged INSIDE
|
||||
hermes_cli (see the package-data entry in pyproject.toml, fed by a
|
||||
symlink to the root file so there is still one table). Sealed venv
|
||||
installs — uv2nix, Docker, the desktop payload's site-packages — read
|
||||
that copy; nothing else changes.
|
||||
"""
|
||||
if install_root is not None:
|
||||
return install_root / PINS_FILENAME
|
||||
return Path(__file__).resolve().parent.parent / PINS_FILENAME
|
||||
repo_copy = Path(__file__).resolve().parent.parent / PINS_FILENAME
|
||||
if repo_copy.is_file():
|
||||
return repo_copy
|
||||
return Path(__file__).resolve().parent / PINS_FILENAME
|
||||
|
||||
|
||||
# Loopback http is allowed so tests can serve real archives from a local
|
||||
|
|
|
|||
122
nix/checks.nix
122
nix/checks.nix
|
|
@ -36,6 +36,128 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2)
|
|||
packages.configKeys = configKeys;
|
||||
|
||||
checks = {
|
||||
# The Nix runtime dir IS the pin table, not a copy of it. Before
|
||||
# this, nix/npm-12-0-2.nix and runtime-pins.json pinned the same
|
||||
# npm independently: two files to bump, and a devShell that could
|
||||
# silently ship a different npm than every user's install.
|
||||
#
|
||||
# Asserting the relationship rather than the values means a pin
|
||||
# bump needs no edit here — and the artifact this builds is the
|
||||
# same runtime dir Hermes provisions, read by the same registry
|
||||
# code, so a drift would fail `hermes doctor` on a nix install.
|
||||
runtime-pins-are-the-source =
|
||||
let
|
||||
pins = (builtins.fromJSON (builtins.readFile ../runtime-pins.json)).tools;
|
||||
runtimeDir = pkgs.callPackage ./runtime-pins.nix { };
|
||||
mismatched = lib.filterAttrs (
|
||||
name: entry: runtimeDir.${name}.pinnedVersion != entry.version
|
||||
) pins;
|
||||
in
|
||||
pkgs.runCommand "hermes-runtime-pins-source" { } (
|
||||
if mismatched != { } then
|
||||
throw "Nix runtime tools disagree with runtime-pins.json: ${
|
||||
toString (builtins.attrNames mismatched)
|
||||
}"
|
||||
else
|
||||
''
|
||||
echo "PASS: ${toString (builtins.length (builtins.attrNames pins))} tools built from runtime-pins.json"
|
||||
mkdir -p $out && echo ok > $out/result
|
||||
''
|
||||
);
|
||||
|
||||
# The nix-built runtime dir satisfies the Python readers with no
|
||||
# nix-specific code: `hermes_cli.runtime_env` assembles PATH from
|
||||
# its facts, every pinned tool runs (patchelf'd), and each reports
|
||||
# the version the table pinned. That is the whole contract — if
|
||||
# this passes, a nix install needs no special handling anywhere.
|
||||
runtime-dir-serves-python =
|
||||
let
|
||||
runtimeDir = pkgs.callPackage ./runtime-pins.nix { };
|
||||
pins = (builtins.fromJSON (builtins.readFile ../runtime-pins.json)).tools;
|
||||
# "How do I ask your version" is genuinely per-tool; the TOOLS
|
||||
# come from the table, so a new pin surfaces here as a missing
|
||||
# probe rather than going silently unchecked.
|
||||
#
|
||||
# npm is resolved from PATH but launched through node on
|
||||
# purpose: its shim is `#!/usr/bin/env node`, and the Nix
|
||||
# build sandbox has no /usr/bin/env (exit 126). That is a
|
||||
# sandbox artifact, not a property of the artifact under test
|
||||
# — `command -v npm` still proves the assembled PATH resolves
|
||||
# to the pinned npm rather than node's bundled copy.
|
||||
probes = {
|
||||
node = "node --version";
|
||||
npm = "node \"$(command -v npm)\" --version";
|
||||
uv = "uv --version";
|
||||
git = "git --version";
|
||||
gh = "gh --version";
|
||||
ripgrep = "rg --version";
|
||||
};
|
||||
probeFor =
|
||||
name:
|
||||
probes.${name}
|
||||
or (throw "runtime-dir-serves-python: no version probe for pinned tool '${name}'");
|
||||
in
|
||||
pkgs.runCommand "hermes-runtime-dir-serves-python"
|
||||
{
|
||||
nativeBuildInputs = [ hermesVenv ];
|
||||
}
|
||||
''
|
||||
set -euo pipefail
|
||||
export HOME=$TMPDIR
|
||||
|
||||
# PATH comes from the facts file, via the same assembler
|
||||
# every Hermes subprocess uses. No hand-built PATH here:
|
||||
# that would test a PATH this check invented. It is seeded
|
||||
# with the build PATH because the assembler PREPENDS (that
|
||||
# is the behaviour under test) and the script still needs
|
||||
# coreutils afterwards.
|
||||
eval "$(${hermesVenv}/bin/python3 - <<'PY'
|
||||
import os
|
||||
from pathlib import Path
|
||||
from hermes_cli.runtime_env import with_managed_runtimes
|
||||
|
||||
env = with_managed_runtimes(
|
||||
{"PATH": os.environ["PATH"]}, runtime_dir=Path("${runtimeDir}")
|
||||
)
|
||||
for key in ("PATH", "GIT_EXEC_PATH", "GIT_SSL_CAINFO", "npm_config_cache"):
|
||||
if env.get(key):
|
||||
print(f"export {key}={env[key]!r}")
|
||||
PY
|
||||
)"
|
||||
|
||||
${lib.concatStringsSep "\n" (
|
||||
lib.mapAttrsToList (name: entry: ''
|
||||
echo "== ${name} (pinned ${entry.version})"
|
||||
got=$(${probeFor name} 2>&1 | head -1)
|
||||
echo " $got"
|
||||
case "$got" in
|
||||
*${entry.version}*) ;;
|
||||
*) echo "expected ${entry.version} from ${name}"; exit 1 ;;
|
||||
esac
|
||||
'') pins
|
||||
)}
|
||||
|
||||
# And the sealed-install gate agrees this dir is current.
|
||||
${hermesVenv}/bin/python3 - <<'PY'
|
||||
import json, tempfile
|
||||
from pathlib import Path
|
||||
from hermes_cli.runtime_provisioner import require_current_runtimes, stale_tools
|
||||
|
||||
runtime_dir = Path("${runtimeDir}")
|
||||
drift = stale_tools(runtime_dir=runtime_dir)
|
||||
assert not drift, f"nix runtime dir is stale against its own pins: {drift}"
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
sealed = Path(td)
|
||||
(sealed / "install-stamp.json").write_text(json.dumps({"distribution": "nix"}))
|
||||
require_current_runtimes(project_root=sealed, runtime_dir=runtime_dir)
|
||||
print("sealed-install gate: current")
|
||||
PY
|
||||
|
||||
mkdir -p $out && echo ok > $out/result
|
||||
'';
|
||||
|
||||
|
||||
# Cross-platform evaluation — catches "not supported for interpreter"
|
||||
# errors (e.g. sphinx dropping python311) without needing a darwin builder.
|
||||
# Evaluation is pure and instant; it doesn't build anything.
|
||||
|
|
|
|||
13
nix/lib.nix
13
nix/lib.nix
|
|
@ -32,10 +32,21 @@
|
|||
let
|
||||
repoRoot = ./..;
|
||||
|
||||
npm12 = callPackage ./npm-12-0-2.nix { };
|
||||
# The managed runtime tools come from the repo's pin table, not from a
|
||||
# version and digest restated here. runtime-pins.json is what a source
|
||||
# install, `hermes update` and the desktop payload all provision from,
|
||||
# so the devShell and every nix build get the same tools those users
|
||||
# get. npm used to be pinned twice (nix/npm-12-0-2.nix vs the table),
|
||||
# which is two places to bump and one silent skew when someone bumps
|
||||
# only one.
|
||||
runtimeDir = callPackage ./runtime-pins.nix { };
|
||||
npm12 = runtimeDir.npm;
|
||||
node_gyp_11_4_0 = callPackage ./node-gyp-11-4-0.nix { };
|
||||
nodejs_26_npm_12 = symlinkJoin {
|
||||
name = "nodejs-26-npm-12";
|
||||
# npm FIRST: it supersedes the npm bundled inside node, and
|
||||
# symlinkJoin resolves a collision in favour of the earlier path.
|
||||
# The pin table states that relationship as npm's `extends: [node]`.
|
||||
paths = [
|
||||
npm12
|
||||
nodejs_26
|
||||
|
|
|
|||
|
|
@ -1,29 +0,0 @@
|
|||
{
|
||||
stdenv,
|
||||
makeWrapper,
|
||||
fetchurl,
|
||||
nodejs_26,
|
||||
}:
|
||||
stdenv.mkDerivation rec {
|
||||
pname = "npm";
|
||||
version = "12.0.2";
|
||||
|
||||
src = fetchurl {
|
||||
url = "https://registry.npmjs.org/npm/-/npm-${version}.tgz";
|
||||
hash = "sha256-XbuGxx0HoZV/LpBzQJLdali9zZ68LY1ByhxuaiHTZOE=";
|
||||
};
|
||||
|
||||
nativeBuildInputs = [ makeWrapper ];
|
||||
dontBuild = true;
|
||||
|
||||
installPhase = ''
|
||||
mkdir -p $out/lib/npm12
|
||||
cp -r . $out/lib/npm12/
|
||||
mkdir -p $out/bin
|
||||
|
||||
makeWrapper ${nodejs_26}/bin/node $out/bin/npm \
|
||||
--add-flags "$out/lib/npm12/bin/npm-cli.js"
|
||||
makeWrapper ${nodejs_26}/bin/node $out/bin/npx \
|
||||
--add-flags "$out/lib/npm12/bin/npx-cli.js"
|
||||
'';
|
||||
}
|
||||
|
|
@ -0,0 +1,243 @@
|
|||
# nix/runtime-pins.nix — managed runtime tools, built from runtime-pins.json
|
||||
#
|
||||
# runtime-pins.json is the ONE table of managed tool versions and digests.
|
||||
# It already feeds the Python provisioner (source installs, `hermes
|
||||
# update`, desktop payload staging). This file makes Nix a fourth consumer
|
||||
# of that table rather than a second table: every version, URL and digest
|
||||
# here is read from the JSON, so a pin bump stays one edit.
|
||||
#
|
||||
# Shape:
|
||||
#
|
||||
# * one derivation per pinned tool, each holding that tool's own tree
|
||||
# exactly as upstream ships it;
|
||||
# * `extends` in the table becomes a real Nix dependency — npm's
|
||||
# derivation takes node's, so Nix orders the builds and neither this
|
||||
# file nor a reader restates "npm needs node";
|
||||
# * `bundle` symlinks those derivations into the directory layout
|
||||
# `hermes_cli/runtime_registry.py` describes, and writes `runtimes.json`
|
||||
# with the registry's own code.
|
||||
#
|
||||
# Nothing here wraps a program or exports an environment variable. The
|
||||
# bundle is a runtime dir, and `hermes_cli/runtime_env.py` already knows
|
||||
# how to turn one of those into PATH, GIT_EXEC_PATH, npm_config_cache and
|
||||
# the rest — on every install kind. A Nix-specific version of any of that
|
||||
# would be a second implementation of tested behaviour.
|
||||
{
|
||||
lib,
|
||||
stdenv,
|
||||
fetchurl,
|
||||
autoPatchelfHook,
|
||||
unzip,
|
||||
python3,
|
||||
runCommand,
|
||||
curl,
|
||||
expat,
|
||||
fontconfig,
|
||||
zlib,
|
||||
}:
|
||||
let
|
||||
repoRoot = ../.;
|
||||
pins = (builtins.fromJSON (builtins.readFile ../runtime-pins.json)).tools;
|
||||
|
||||
# Pin-table target keys use Node/Python spellings so one string works on
|
||||
# both sides of the JS/Python boundary; Nix systems spell it the other
|
||||
# way round. This is the only place the two vocabularies meet.
|
||||
targetBySystem = {
|
||||
"x86_64-linux" = "linux-x64";
|
||||
"aarch64-linux" = "linux-arm64";
|
||||
"x86_64-darwin" = "darwin-x64";
|
||||
"aarch64-darwin" = "darwin-arm64";
|
||||
};
|
||||
|
||||
target =
|
||||
targetBySystem.${stdenv.hostPlatform.system}
|
||||
or (throw "runtime-pins: no pin target for ${stdenv.hostPlatform.system}");
|
||||
|
||||
# A tool either pins one target-independent artifact ('any', a registry
|
||||
# tarball whose bytes do not vary) or one per target. Same resolution
|
||||
# the Python registry does — see `pinned_file`.
|
||||
artifactOf =
|
||||
name: entry:
|
||||
entry.files.any
|
||||
or entry.files.${target}
|
||||
or (throw "runtime-pins: ${name} has no pinned download for ${target}");
|
||||
|
||||
# fetchurl's `sha256` takes the bare lowercase hex the table already
|
||||
# stores — the same string the Python provisioner verifies, so there is
|
||||
# no second encoding to keep in sync. Nix enforces it as a fixed-output
|
||||
# derivation: a tampered pin fails the build, as it fails provisioning.
|
||||
fetchPinned = name: entry: fetchurl { inherit (artifactOf name entry) url sha256; };
|
||||
|
||||
extendsOf = entry: entry.extends or [ ];
|
||||
|
||||
# Prebuilt upstream binaries link against a normal FHS glibc, which does
|
||||
# not exist here. autoPatchelfHook rewrites the interpreter and RPATH
|
||||
# onto the nixpkgs runtime; macOS binaries are already relocatable.
|
||||
#
|
||||
# One library set covers every tool: these are the shared objects the
|
||||
# pinned artifacts actually ask for (zlib broadly; curl/expat for
|
||||
# dugite's http helpers; fontconfig for the Skia lib dugite ships).
|
||||
patchelfInputs = lib.optionals stdenv.hostPlatform.isLinux [
|
||||
stdenv.cc.cc.lib
|
||||
zlib
|
||||
curl
|
||||
expat
|
||||
fontconfig
|
||||
];
|
||||
|
||||
mkToolBase =
|
||||
name: entry: extra:
|
||||
stdenv.mkDerivation (
|
||||
{
|
||||
pname = "hermes-runtime-${name}";
|
||||
version = entry.version;
|
||||
src = fetchPinned name entry;
|
||||
|
||||
nativeBuildInputs =
|
||||
[ unzip ] ++ lib.optionals stdenv.hostPlatform.isLinux [ autoPatchelfHook ];
|
||||
buildInputs = patchelfInputs;
|
||||
|
||||
dontUnpack = true;
|
||||
dontBuild = true;
|
||||
dontConfigure = true;
|
||||
|
||||
passthru = {
|
||||
pinnedVersion = entry.version;
|
||||
pinnedUrl = (artifactOf name entry).url;
|
||||
extends = map (dep: tools.${dep}) (extendsOf entry);
|
||||
};
|
||||
|
||||
meta = {
|
||||
description = "Hermes managed runtime ${name} ${entry.version} (pinned in runtime-pins.json)";
|
||||
platforms = lib.platforms.unix;
|
||||
};
|
||||
}
|
||||
// extra
|
||||
);
|
||||
|
||||
# The common case: unpack the artifact and keep upstream's own layout.
|
||||
#
|
||||
# Un-nesting a lone versioned wrapper directory is decided by what the
|
||||
# archive CONTAINS, not by a per-tool list — the same rule the Python
|
||||
# provisioner uses, and for the same reason (uv nests on POSIX and not
|
||||
# on Windows, so a hardcoded list gets it wrong).
|
||||
mkUnpackedTool =
|
||||
name: entry:
|
||||
mkToolBase name entry {
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
mkdir -p unpacked
|
||||
tar -xf "$src" -C unpacked 2>/dev/null || unzip -q "$src" -d unpacked
|
||||
|
||||
inner=unpacked
|
||||
entries=("$inner"/*)
|
||||
if [ ''${#entries[@]} -eq 1 ] && [ -d "''${entries[0]}" ]; then
|
||||
inner="''${entries[0]}"
|
||||
fi
|
||||
|
||||
mkdir -p "$out"
|
||||
cp -R "$inner"/. "$out/"
|
||||
runHook postInstall
|
||||
'';
|
||||
};
|
||||
|
||||
# npm is the one tool that cannot simply be unpacked. Its own bin/npm
|
||||
# resolves npm-cli.js from dirname(process.execPath), so unpacked onto a
|
||||
# PATH it finds the npm BUNDLED inside node — the copy this pin exists
|
||||
# to supersede — and dies with MODULE_NOT_FOUND when that copy is gone.
|
||||
# Letting npm install itself produces the launchers the platform
|
||||
# actually needs instead of hand-written shims, and is the same install
|
||||
# the Python provisioner performs (`_stage_npm`) from the same
|
||||
# digest-verified tarball, offline.
|
||||
#
|
||||
# `extends` is what makes this work without a special case: node is a
|
||||
# real build input, so Nix has already built and patched it.
|
||||
mkNpmTool =
|
||||
name: entry:
|
||||
let
|
||||
node = tools.node;
|
||||
in
|
||||
mkToolBase name entry {
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
mkdir -p "$out"
|
||||
# npm insists on a writable HOME and cache; the sandbox's HOME is
|
||||
# deliberately unwritable. Neither belongs in $out — a real
|
||||
# install keeps its cache in the runtime dir, which
|
||||
# managed_tool_env points npm_config_cache at.
|
||||
HOME="$TMPDIR" npm_config_cache="$TMPDIR/npm-cache" \
|
||||
${node}/bin/node ${node}/lib/node_modules/npm/bin/npm-cli.js \
|
||||
install --global --prefix "$out" --offline --no-audit --no-fund \
|
||||
"$src"
|
||||
runHook postInstall
|
||||
'';
|
||||
};
|
||||
|
||||
# An `extends` edge means "staged by what it extends", which today is
|
||||
# npm-shaped: run the extended tool's installer. A second extender with
|
||||
# different mechanics would add a branch here; one edge, one meaning
|
||||
# until then.
|
||||
mkTool =
|
||||
name: entry: if extendsOf entry == [ ] then mkUnpackedTool name entry else mkNpmTool name entry;
|
||||
|
||||
tools = lib.mapAttrs mkTool pins;
|
||||
|
||||
# The registry code that writes the facts, as a store path. Only the
|
||||
# leaf modules the fact-writing imports — all pure-stdlib, so a bare
|
||||
# python3 loads them with no venv, and the bundle does not depend on
|
||||
# the whole repo (which would rebuild it on any source change).
|
||||
registrySrc = runCommand "hermes-runtime-registry-src" { } ''
|
||||
mkdir -p "$out/hermes_cli"
|
||||
touch "$out/hermes_cli/__init__.py"
|
||||
cp ${../hermes_cli/runtime_registry.py} "$out/hermes_cli/runtime_registry.py"
|
||||
cp ${../hermes_cli/runtime_provisioner.py} "$out/hermes_cli/runtime_provisioner.py"
|
||||
cp ${../hermes_cli/runtime_tree.py} "$out/hermes_cli/runtime_tree.py"
|
||||
cp ${../hermes_constants.py} "$out/hermes_constants.py"
|
||||
cp ${../runtime-pins.json} "$out/runtime-pins.json"
|
||||
'';
|
||||
|
||||
# The bundle IS a runtime dir: `<dir>/<tool>/...` per the registry's
|
||||
# layout, plus the `runtimes.json` facts manifest. Symlinks, so the
|
||||
# tools stay separately built and separately cached.
|
||||
#
|
||||
# Facts are written by runtime_registry.py itself — schema version,
|
||||
# `extends`-derived PATH order and JSON shape have one implementation,
|
||||
# and the ordering the Python readers apply is the ordering the table
|
||||
# declares.
|
||||
bundle = runCommand "hermes-runtime-dir" { passthru = tools // { inherit target; }; } ''
|
||||
mkdir -p "$out"
|
||||
${lib.concatStringsSep "\n" (
|
||||
lib.mapAttrsToList (name: drv: ''ln -s ${drv} "$out/${name}"'') tools
|
||||
)}
|
||||
|
||||
export PYTHONPATH=${registrySrc}
|
||||
${python3}/bin/python3 - "$out" ${registrySrc} "${target}" <<'PY'
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_cli.runtime_registry import (
|
||||
RuntimeFact, install_order, load_pins, path_order, save_facts,
|
||||
)
|
||||
from hermes_cli.runtime_provisioner import _binary_rel, _path_dirs
|
||||
|
||||
runtime_dir, registry_src, target = (
|
||||
Path(sys.argv[1]), Path(sys.argv[2]), sys.argv[3],
|
||||
)
|
||||
pins = load_pins(registry_src)
|
||||
|
||||
facts = {}
|
||||
for tool in install_order(pins):
|
||||
rel = _binary_rel(tool, target)
|
||||
if not (runtime_dir / rel).exists():
|
||||
raise SystemExit(f"runtime-pins: {tool} is missing {rel}")
|
||||
facts[tool] = RuntimeFact(
|
||||
version=pins[tool]["version"],
|
||||
path=rel,
|
||||
path_dirs=_path_dirs(tool, target),
|
||||
)
|
||||
|
||||
save_facts(facts, runtime_dir, path_order=path_order(pins))
|
||||
PY
|
||||
'';
|
||||
in
|
||||
bundle
|
||||
|
|
@ -401,7 +401,7 @@ py-modules = [
|
|||
include = ["agent", "agent.*", "tools", "tools.*", "hermes_cli", "hermes_cli.*", "gateway", "gateway.*", "tui_gateway", "tui_gateway.*", "cron", "cron.*", "acp_adapter", "plugins", "plugins.*", "providers", "providers.*"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
hermes_cli = ["observability/schemas/*.json"]
|
||||
hermes_cli = ["observability/schemas/*.json", "runtime-pins.json"]
|
||||
# gateway/assets/ ships status_phrases.yaml and the Telegram BotFather
|
||||
# screenshot. Without this, sealed venvs (uv2nix) silently lose both —
|
||||
# status phrases fall back to the tiny hardcoded set and the Telegram
|
||||
|
|
|
|||
|
|
@ -313,6 +313,143 @@ class TestSelectiveProvisioning:
|
|||
assert [r.tool for r in results] == ["gh"]
|
||||
|
||||
|
||||
class TestSealedInstallStalenessGate:
|
||||
"""A sealed tree cannot provision, so drift there is fatal.
|
||||
|
||||
A git checkout heals itself on the next update — raising would break
|
||||
the very run that fixes it. A Nix/Docker/desktop artifact has its
|
||||
tools built in by its steward, so a mismatch means the artifact was
|
||||
assembled against a different pin table than the code it ships.
|
||||
"""
|
||||
|
||||
def _sealed(self, root: Path, steward: str = "nix") -> Path:
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
(root / "install-stamp.json").write_text(
|
||||
json.dumps({"distribution": steward}), encoding="utf-8"
|
||||
)
|
||||
return root
|
||||
|
||||
def _current_runtime(self, rt: Path, pins_root: Path, target: str) -> None:
|
||||
"""A runtime dir that satisfies every pin in *pins_root*."""
|
||||
facts = {}
|
||||
for tool, entry in rr.load_pins(pins_root).items():
|
||||
rel = rp._binary_rel(tool, target)
|
||||
binary = rt / rel
|
||||
binary.parent.mkdir(parents=True, exist_ok=True)
|
||||
binary.write_text("#!/bin/sh\n")
|
||||
facts[tool] = rr.RuntimeFact(version=entry["version"], path=rel)
|
||||
rr.save_facts(facts, rt)
|
||||
|
||||
def test_a_current_sealed_install_passes(self, tmp_path, target):
|
||||
pins = _pins_file(tmp_path / "repo", {
|
||||
"gh": {"version": "2.97.0", "files": {
|
||||
target: {"url": "https://example.invalid/gh.tar.gz", "sha256": "a" * 64}}},
|
||||
})
|
||||
rt = tmp_path / "rt"
|
||||
self._current_runtime(rt, pins, target)
|
||||
|
||||
rp.require_current_runtimes(
|
||||
project_root=self._sealed(tmp_path / "sealed"),
|
||||
runtime_dir=rt,
|
||||
install_root=pins,
|
||||
)
|
||||
|
||||
def test_a_stale_sealed_install_refuses_with_the_drift_named(
|
||||
self, tmp_path, target
|
||||
):
|
||||
pins = _pins_file(tmp_path / "repo", {
|
||||
"gh": {"version": "2.98.0", "files": {
|
||||
target: {"url": "https://example.invalid/gh.tar.gz", "sha256": "a" * 64}}},
|
||||
})
|
||||
rt = tmp_path / "rt"
|
||||
rel = rp._binary_rel("gh", target)
|
||||
binary = rt / rel
|
||||
binary.parent.mkdir(parents=True, exist_ok=True)
|
||||
binary.write_text("#!/bin/sh\n")
|
||||
rr.save_facts({"gh": rr.RuntimeFact(version="2.97.0", path=rel)}, rt)
|
||||
|
||||
with pytest.raises(rp.StaleManagedRuntimes) as excinfo:
|
||||
rp.require_current_runtimes(
|
||||
project_root=self._sealed(tmp_path / "sealed"),
|
||||
runtime_dir=rt,
|
||||
install_root=pins,
|
||||
)
|
||||
|
||||
message = str(excinfo.value)
|
||||
assert "nix" in message
|
||||
# Naming the versions is the point: "rebuild it" is unactionable
|
||||
# without knowing what drifted.
|
||||
assert "2.98.0" in message and "2.97.0" in message
|
||||
|
||||
def test_a_git_checkout_with_the_same_drift_does_not_raise(
|
||||
self, tmp_path, target
|
||||
):
|
||||
"""It provisions on demand; the next update fixes it."""
|
||||
pins = _pins_file(tmp_path / "repo", {
|
||||
"gh": {"version": "2.98.0", "files": {
|
||||
target: {"url": "https://example.invalid/gh.tar.gz", "sha256": "a" * 64}}},
|
||||
})
|
||||
checkout = tmp_path / "checkout"
|
||||
(checkout / ".git").mkdir(parents=True)
|
||||
|
||||
rp.require_current_runtimes(
|
||||
project_root=checkout, runtime_dir=tmp_path / "empty", install_root=pins
|
||||
)
|
||||
|
||||
def test_an_unprovisioned_sealed_install_refuses(self, tmp_path, target):
|
||||
"""Nothing installed at all is drift too — a sealed artifact is
|
||||
supposed to ship its tools already built."""
|
||||
pins = _pins_file(tmp_path / "repo", {
|
||||
"gh": {"version": "2.97.0", "files": {
|
||||
target: {"url": "https://example.invalid/gh.tar.gz", "sha256": "a" * 64}}},
|
||||
})
|
||||
|
||||
with pytest.raises(rp.StaleManagedRuntimes, match="nothing"):
|
||||
rp.require_current_runtimes(
|
||||
project_root=self._sealed(tmp_path / "sealed"),
|
||||
runtime_dir=tmp_path / "empty",
|
||||
install_root=pins,
|
||||
)
|
||||
|
||||
def test_a_recorded_but_vanished_binary_counts_as_stale(self, tmp_path, target):
|
||||
"""Every other reader treats recorded-but-missing as
|
||||
unprovisioned; the gate must not call it current."""
|
||||
pins = _pins_file(tmp_path / "repo", {
|
||||
"gh": {"version": "2.97.0", "files": {
|
||||
target: {"url": "https://example.invalid/gh.tar.gz", "sha256": "a" * 64}}},
|
||||
})
|
||||
rt = tmp_path / "rt"
|
||||
# A fact, but no file behind it.
|
||||
rr.save_facts(
|
||||
{"gh": rr.RuntimeFact(version="2.97.0", path=rp._binary_rel("gh", target))},
|
||||
rt,
|
||||
)
|
||||
|
||||
assert rp.stale_tools(runtime_dir=rt, install_root=pins) == {
|
||||
"gh": ("2.97.0", None)
|
||||
}
|
||||
|
||||
|
||||
class TestPinsShipWithTheCode:
|
||||
def test_a_sealed_venv_can_read_its_own_pin_table(self):
|
||||
"""`pip install .` lays out site-packages with no repo root, so
|
||||
the table is packaged inside hermes_cli too. Without it a nix /
|
||||
docker / desktop venv cannot read the pins it was built from —
|
||||
which is how this was found.
|
||||
"""
|
||||
import hermes_cli
|
||||
|
||||
packaged = Path(hermes_cli.__file__).resolve().parent / rr.PINS_FILENAME
|
||||
assert packaged.is_file(), (
|
||||
"hermes_cli/runtime-pins.json is missing — a sealed venv install "
|
||||
"would have no pin table"
|
||||
)
|
||||
assert json.loads(packaged.read_text(encoding="utf-8")) == json.loads(
|
||||
(Path(hermes_cli.__file__).resolve().parent.parent / rr.PINS_FILENAME)
|
||||
.read_text(encoding="utf-8")
|
||||
), "the packaged pin table drifted from the repo's"
|
||||
|
||||
|
||||
class TestLayout:
|
||||
def test_windows_and_posix_binaries_land_where_readers_expect(self):
|
||||
assert rp._binary_rel("node", "win32-x64") == "node/node.exe"
|
||||
|
|
|
|||
Loading…
Reference in New Issue