Verified with the coordinated workspace changes by devkit full run 2026-09-08T225814-186389-0000-3e3ed7cd (all seven phases passed). This shared UI pass does not mark the individual module reviews complete.
272 lines
9.8 KiB
Python
Executable File
272 lines
9.8 KiB
Python
Executable File
"""Resolve local tools once, without installation, network access or server startup."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
from itertools import islice
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import stat
|
|
import subprocess
|
|
import sys
|
|
|
|
from .common import digest
|
|
from .workspace import Project
|
|
from .process import require_capture
|
|
|
|
MAX_DISCOVERY_CHILDREN = 4096
|
|
|
|
|
|
def resolve_tools(workspace_root: Path, project: Project) -> dict[str, str]:
|
|
configured = project.config.get("tools", {})
|
|
if not isinstance(configured, dict) or set(configured) - {"python", "node", "npm"}:
|
|
raise ValueError("Project tools must configure only python, node and npm")
|
|
tools = {}
|
|
python_env = (
|
|
Path(
|
|
os.environ.get("GOVOPLAN_VENV_ROOT", str(workspace_root / "govoplan/.venv"))
|
|
)
|
|
/ "bin/python"
|
|
)
|
|
for name in ("python", "node", "npm"):
|
|
default = (
|
|
str(python_env)
|
|
if name == "python" and python_env.is_file()
|
|
else sys.executable
|
|
if name == "python"
|
|
else name
|
|
)
|
|
value = configured.get(name) or os.environ.get(name.upper()) or default
|
|
if not isinstance(value, str) or not value or "\0" in value:
|
|
raise ValueError(f"Invalid executable configuration for {name}")
|
|
executable = shutil.which(value)
|
|
if executable:
|
|
# Keep venv executable symlinks: resolving them loses Python's environment.
|
|
tools[name] = os.path.abspath(executable)
|
|
else:
|
|
tools[name] = value
|
|
return tools
|
|
|
|
|
|
def execution_environment(
|
|
workspace_root: Path, project: Project, tools: dict[str, str]
|
|
) -> dict[str, str]:
|
|
env = dict(os.environ)
|
|
if (
|
|
project.config.get("organization") == "GovOPlaN"
|
|
and "schema_version" not in project.config
|
|
):
|
|
# Managed GovOPlaN checks must not inherit another checkout's scope.
|
|
env["GOVOPLAN_WORKSPACE_ROOT"] = str(workspace_root.resolve())
|
|
core = next(
|
|
repo.path for repo in project.repositories if repo.name == "govoplan-core"
|
|
)
|
|
env["GOVOPLAN_CORE_ROOT"] = str(core)
|
|
env["GOVOPLAN_CORE_SOURCE_ROOT"] = str(core)
|
|
directories = [
|
|
str(Path(tools[name]).parent)
|
|
for name in ("python", "node", "npm")
|
|
if Path(tools[name]).is_absolute()
|
|
]
|
|
core_bins = workspace_root / "govoplan-core/webui/node_modules/.bin"
|
|
if core_bins.is_dir():
|
|
directories.insert(0, str(core_bins))
|
|
env["PATH"] = os.pathsep.join([*directories, env.get("PATH", "")])
|
|
env.update({name.upper(): value for name, value in tools.items()})
|
|
sources = [
|
|
str(repo.path / "src")
|
|
for repo in project.repositories
|
|
if (repo.path / "src").is_dir()
|
|
]
|
|
if sources:
|
|
env["PYTHONPATH"] = os.pathsep.join(
|
|
sources + ([env["PYTHONPATH"]] if env.get("PYTHONPATH") else [])
|
|
)
|
|
env["GIT_OPTIONAL_LOCKS"] = "0"
|
|
env["PYTHONDONTWRITEBYTECODE"] = "1"
|
|
return env
|
|
|
|
|
|
def tool_version(executable: str, env: dict[str, str]) -> str:
|
|
try:
|
|
result = require_capture(
|
|
[executable, "--version"],
|
|
timeout=15,
|
|
max_stdout=4096,
|
|
max_stderr=4096,
|
|
env=env,
|
|
)
|
|
return (
|
|
(result.stdout or result.stderr).decode(errors="replace").strip()[:256]
|
|
if result.returncode == 0
|
|
else "unavailable"
|
|
)
|
|
except (OSError, subprocess.TimeoutExpired, ValueError):
|
|
return "unavailable"
|
|
|
|
|
|
def _file_identity(metadata: os.stat_result) -> tuple[int, ...]:
|
|
return (
|
|
metadata.st_dev,
|
|
metadata.st_ino,
|
|
metadata.st_mode,
|
|
metadata.st_size,
|
|
metadata.st_ctime_ns,
|
|
metadata.st_mtime_ns,
|
|
)
|
|
|
|
|
|
def _environment_file_hash(path: Path, maximum: int) -> str | None:
|
|
"""Read a stable, bounded regular target without changing executable spelling.
|
|
|
|
Venv executables and package directories may legitimately be symlinks. Only
|
|
the read target is resolved; both names and the open descriptor are verified
|
|
again afterwards. Missing optional metadata stays absent, but a present
|
|
malformed or concurrently changing entry cannot certify an environment.
|
|
"""
|
|
try:
|
|
original = path.lstat()
|
|
except FileNotFoundError:
|
|
return None
|
|
try:
|
|
target = path.resolve(strict=True)
|
|
target_metadata = target.lstat()
|
|
descriptor = os.open(
|
|
target,
|
|
os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0),
|
|
)
|
|
with os.fdopen(descriptor, "rb") as handle:
|
|
before = os.fstat(handle.fileno())
|
|
if not stat.S_ISREG(before.st_mode) or before.st_size > maximum:
|
|
raise ValueError("Environment input must be a bounded regular file")
|
|
identity = _file_identity(before)
|
|
if identity != _file_identity(target_metadata):
|
|
raise ValueError("Environment input changed before fingerprinting")
|
|
hasher, size = hashlib.sha256(), 0
|
|
for chunk in iter(
|
|
lambda: handle.read(min(1024 * 1024, maximum - size + 1)), b""
|
|
):
|
|
size += len(chunk)
|
|
if size > maximum:
|
|
raise ValueError("Environment input grew beyond its size bound")
|
|
hasher.update(chunk)
|
|
if (
|
|
_file_identity(os.fstat(handle.fileno())) != identity
|
|
or _file_identity(target.lstat()) != identity
|
|
or _file_identity(path.lstat()) != _file_identity(original)
|
|
or path.resolve(strict=True) != target
|
|
):
|
|
raise ValueError("Environment input changed during fingerprinting")
|
|
return hasher.hexdigest()
|
|
except (OSError, RuntimeError) as exc:
|
|
raise ValueError("Environment input cannot be fingerprinted safely") from exc
|
|
|
|
|
|
def _discovery_path_shape(path: Path) -> dict:
|
|
"""Directory membership, not timestamps changed by ordinary build outputs."""
|
|
try:
|
|
metadata = path.lstat()
|
|
except (FileNotFoundError, NotADirectoryError):
|
|
return {"kind": "missing"}
|
|
try:
|
|
resolved = path.resolve()
|
|
try:
|
|
target_kind = stat.S_IFMT(resolved.lstat().st_mode)
|
|
except (FileNotFoundError, NotADirectoryError):
|
|
target_kind = "missing"
|
|
if target_kind == stat.S_IFLNK:
|
|
# Newer pathlib versions can retain a loop with strict=False.
|
|
raise ValueError("Native source discovery cannot be resolved safely")
|
|
return {
|
|
"kind": stat.S_IFMT(metadata.st_mode),
|
|
"resolved": str(resolved),
|
|
"target_kind": target_kind,
|
|
}
|
|
except (OSError, RuntimeError) as exc:
|
|
raise ValueError("Native source discovery cannot be resolved safely") from exc
|
|
|
|
|
|
def _native_discovery_fingerprint(workspace_root: Path, project: Project) -> str:
|
|
"""Bind glob discovery and registered ownership omitted by narrow Git scopes."""
|
|
children = list(islice(workspace_root.iterdir(), MAX_DISCOVERY_CHILDREN + 1))
|
|
if len(children) > MAX_DISCOVERY_CHILDREN:
|
|
raise ValueError("Native source discovery exceeds its bounded ownership audit")
|
|
|
|
def shape(path):
|
|
return {
|
|
name: _discovery_path_shape(path / name if name else path)
|
|
for name in ("", "src", "webui")
|
|
}
|
|
|
|
return digest(
|
|
{
|
|
"version": 1,
|
|
"siblings": [
|
|
{"name": child.name, "shape": shape(child)}
|
|
for child in sorted(children)
|
|
if child.name.startswith("govoplan")
|
|
],
|
|
"registered": [
|
|
{"name": repo.name, "path": str(repo.path), "shape": shape(repo.path)}
|
|
for repo in sorted(project.repositories, key=lambda item: item.name)
|
|
],
|
|
}
|
|
)
|
|
|
|
|
|
def environment_fingerprint(
|
|
workspace_root: Path, project: Project, tools: dict[str, str], env: dict[str, str]
|
|
) -> str:
|
|
identity = {
|
|
"environment": {
|
|
key: value
|
|
for key, value in env.items()
|
|
if key not in {"_", "SHLVL", "PWD", "OLDPWD"}
|
|
},
|
|
"tools": {},
|
|
}
|
|
if (
|
|
project.config.get("organization") == "GovOPlaN"
|
|
and "schema_version" not in project.config
|
|
):
|
|
identity["native_source_discovery"] = _native_discovery_fingerprint(
|
|
workspace_root, project
|
|
)
|
|
for name, executable in tools.items():
|
|
binary_hash = _environment_file_hash(Path(executable), 512 * 1024 * 1024)
|
|
identity["tools"][name] = {
|
|
"path": executable,
|
|
"version": tool_version(executable, env),
|
|
"sha256": binary_hash,
|
|
}
|
|
installed = {}
|
|
for repo in project.repositories:
|
|
for suffix in (
|
|
"node_modules/.package-lock.json",
|
|
"webui/node_modules/.package-lock.json",
|
|
".venv/pyvenv.cfg",
|
|
):
|
|
path = repo.path / suffix
|
|
value = _environment_file_hash(path, 32 * 1024 * 1024)
|
|
if value is not None:
|
|
installed[str(path)] = value
|
|
try:
|
|
result = require_capture(
|
|
[
|
|
tools["python"],
|
|
"-c",
|
|
"import importlib.metadata,json; print(json.dumps(sorted((d.metadata['Name'],d.version) for d in importlib.metadata.distributions())))",
|
|
],
|
|
timeout=30,
|
|
max_stdout=1024 * 1024,
|
|
env=env,
|
|
)
|
|
if result.returncode:
|
|
raise ValueError("Cannot fingerprint installed Python distributions")
|
|
installed["python_distributions"] = hashlib.sha256(result.stdout).hexdigest()
|
|
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
raise ValueError("Cannot fingerprint Python environment") from exc
|
|
identity["installed"] = installed
|
|
return digest(identity)
|