fix(docker): support WebUI installs from read-only sources (#48541)
parent
d2c53ff558
commit
36851fa576
|
|
@ -102,6 +102,3 @@ acp_registry/
|
||||||
.gitattributes
|
.gitattributes
|
||||||
.hadolint.yaml
|
.hadolint.yaml
|
||||||
.mailmap
|
.mailmap
|
||||||
|
|
||||||
# Top-level LICENSE (not matched by *.md); not needed inside the container
|
|
||||||
LICENSE
|
|
||||||
|
|
|
||||||
59
setup.py
59
setup.py
|
|
@ -2,13 +2,68 @@ from __future__ import annotations
|
||||||
|
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
|
||||||
from setuptools import setup
|
from setuptools import setup
|
||||||
|
from setuptools.command.build import build as _build
|
||||||
|
from setuptools.command.egg_info import egg_info as _egg_info
|
||||||
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).parent.resolve()
|
REPO_ROOT = Path(__file__).parent.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def _source_tree_is_writable() -> bool:
|
||||||
|
probe = REPO_ROOT / ".setuptools-write-probe"
|
||||||
|
try:
|
||||||
|
with probe.open("w", encoding="utf-8") as handle:
|
||||||
|
handle.write("")
|
||||||
|
probe.unlink()
|
||||||
|
except OSError:
|
||||||
|
try:
|
||||||
|
probe.unlink(missing_ok=True)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _temporary_build_dir(kind: str) -> str:
|
||||||
|
return tempfile.mkdtemp(prefix=f"hermes-agent-{kind}-")
|
||||||
|
|
||||||
|
|
||||||
|
def _would_write_under_source(path_value: str | None) -> bool:
|
||||||
|
if path_value is None:
|
||||||
|
return True
|
||||||
|
path = Path(path_value)
|
||||||
|
if not path.is_absolute():
|
||||||
|
path = REPO_ROOT / path
|
||||||
|
try:
|
||||||
|
path.resolve().relative_to(REPO_ROOT)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class ReadOnlySourceBuild(_build):
|
||||||
|
def finalize_options(self) -> None:
|
||||||
|
if (
|
||||||
|
not _source_tree_is_writable()
|
||||||
|
and _would_write_under_source(self.build_base)
|
||||||
|
):
|
||||||
|
self.build_base = _temporary_build_dir("build")
|
||||||
|
super().finalize_options()
|
||||||
|
|
||||||
|
|
||||||
|
class ReadOnlySourceEggInfo(_egg_info):
|
||||||
|
def finalize_options(self) -> None:
|
||||||
|
if (
|
||||||
|
not _source_tree_is_writable()
|
||||||
|
and _would_write_under_source(self.egg_base)
|
||||||
|
):
|
||||||
|
self.egg_base = _temporary_build_dir("egg-info")
|
||||||
|
super().finalize_options()
|
||||||
|
|
||||||
|
|
||||||
def _data_file_tree(root_name: str) -> list[tuple[str, list[str]]]:
|
def _data_file_tree(root_name: str) -> list[tuple[str, list[str]]]:
|
||||||
root = REPO_ROOT / root_name
|
root = REPO_ROOT / root_name
|
||||||
grouped: defaultdict[str, list[str]] = defaultdict(list)
|
grouped: defaultdict[str, list[str]] = defaultdict(list)
|
||||||
|
|
@ -21,6 +76,10 @@ def _data_file_tree(root_name: str) -> list[tuple[str, list[str]]]:
|
||||||
|
|
||||||
|
|
||||||
setup(
|
setup(
|
||||||
|
cmdclass={
|
||||||
|
"build": ReadOnlySourceBuild,
|
||||||
|
"egg_info": ReadOnlySourceEggInfo,
|
||||||
|
},
|
||||||
data_files=[
|
data_files=[
|
||||||
*_data_file_tree("skills"),
|
*_data_file_tree("skills"),
|
||||||
*_data_file_tree("optional-skills"),
|
*_data_file_tree("optional-skills"),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
"""Guards for the multi-container Hermes WebUI install surface."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import runpy
|
||||||
|
|
||||||
|
from setuptools import Distribution
|
||||||
|
import setuptools
|
||||||
|
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
def _is_under(path: str, root: Path) -> bool:
|
||||||
|
try:
|
||||||
|
Path(path).resolve().relative_to(root.resolve())
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def test_docker_context_includes_license_file() -> None:
|
||||||
|
"""PEP 639 license-files metadata must resolve inside the Docker image."""
|
||||||
|
dockerignore = (REPO_ROOT / ".dockerignore").read_text(encoding="utf-8")
|
||||||
|
active_lines = [
|
||||||
|
line.strip()
|
||||||
|
for line in dockerignore.splitlines()
|
||||||
|
if line.strip() and not line.lstrip().startswith("#")
|
||||||
|
]
|
||||||
|
|
||||||
|
assert "LICENSE" not in active_lines
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_uses_temporary_outputs_when_source_tree_is_read_only(
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
"""WebUI installs from read-only /opt/hermes must not write build metadata."""
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
|
def capture_setup(**kwargs: object) -> None:
|
||||||
|
captured.update(kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(setuptools, "setup", capture_setup)
|
||||||
|
namespace = runpy.run_path(str(REPO_ROOT / "setup.py"))
|
||||||
|
|
||||||
|
cmdclass = captured["cmdclass"]
|
||||||
|
monkeypatch.setitem(
|
||||||
|
cmdclass["build"].finalize_options.__globals__,
|
||||||
|
"_source_tree_is_writable",
|
||||||
|
lambda: False,
|
||||||
|
)
|
||||||
|
monkeypatch.setitem(
|
||||||
|
cmdclass["egg_info"].finalize_options.__globals__,
|
||||||
|
"_source_tree_is_writable",
|
||||||
|
lambda: False,
|
||||||
|
)
|
||||||
|
|
||||||
|
build_cmd = cmdclass["build"](Distribution())
|
||||||
|
build_cmd.initialize_options()
|
||||||
|
build_cmd.finalize_options()
|
||||||
|
assert not _is_under(build_cmd.build_base, REPO_ROOT)
|
||||||
|
assert Path(build_cmd.build_base).name.startswith("hermes-agent-build")
|
||||||
|
|
||||||
|
source_relative_build = cmdclass["build"](Distribution())
|
||||||
|
source_relative_build.initialize_options()
|
||||||
|
source_relative_build.build_base = "nested/build"
|
||||||
|
source_relative_build.finalize_options()
|
||||||
|
assert not _is_under(source_relative_build.build_base, REPO_ROOT)
|
||||||
|
assert Path(source_relative_build.build_base).name.startswith("hermes-agent-build")
|
||||||
|
|
||||||
|
egg_info_cmd = cmdclass["egg_info"](Distribution())
|
||||||
|
egg_info_cmd.initialize_options()
|
||||||
|
egg_info_cmd.finalize_options()
|
||||||
|
assert egg_info_cmd.egg_base is not None
|
||||||
|
assert not _is_under(egg_info_cmd.egg_base, REPO_ROOT)
|
||||||
|
assert Path(egg_info_cmd.egg_base).name.startswith("hermes-agent-egg-info")
|
||||||
|
|
||||||
|
source_relative_egg_info = cmdclass["egg_info"](Distribution())
|
||||||
|
source_relative_egg_info.initialize_options()
|
||||||
|
source_relative_egg_info.egg_base = "."
|
||||||
|
source_relative_egg_info.finalize_options()
|
||||||
|
assert source_relative_egg_info.egg_base is not None
|
||||||
|
assert not _is_under(source_relative_egg_info.egg_base, REPO_ROOT)
|
||||||
|
assert Path(source_relative_egg_info.egg_base).name.startswith(
|
||||||
|
"hermes-agent-egg-info"
|
||||||
|
)
|
||||||
Loading…
Reference in New Issue