Compare commits

...
Author SHA1 Message Date
ethernet 191bae64e5 Revert "perf(docker-tests): blocking exec with DEVNULL for wait_for_container_ready"
This reverts commit 19ca99e863.
2026-07-15 12:46:04 -04:00
ethernet 19ca99e863 perf(docker-tests): blocking exec with DEVNULL for wait_for_container_ready
Retry of the blocking docker exec approach, this time using
stdout=DEVNULL/stderr=DEVNULL instead of capture_output=True.

The previous attempt regressed on CI (+135s) with capture_output.
Hypothesis: the docker daemon stdio pipe management for long-lived
exec sessions added overhead under 8-way parallel test execution.
DEVNULL should be lighter — the daemon never has to buffer output
or manage pipe read ends.

Also uses user="root" (not "hermes") to avoid deadlocking on
PUID/PGID remap tests where the hermes user UID is being changed
during cont-init.

The probe runs an until/sleep loop inside the container, replacing
N separate docker exec polls with a single blocking exec. On CI
where docker exec costs ~0.25s overhead per call, this should save
(N-1) × 0.25s per container where N is the number of polls
(typically 3-7).
2026-07-15 12:35:18 -04:00
ethernet 65addc2c4f Revert "perf(docker-tests): halve poll intervals from 0.5s to 0.25s"
This reverts commit a7c778e3ba.
2026-07-14 18:35:45 -04:00
ethernet a7c778e3ba perf(docker-tests): halve poll intervals from 0.5s to 0.25s
Reduce the default interval_s in poll_container, wait_for_docker_logs,
and inline time.sleep() calls in test_dashboard.py and
test_gateway_run_supervised.py from 0.5s to 0.25s.

CI profile with sleep tracking showed 116.2s of time.sleep() across
345 sleep calls — 23% of total wall time. The majority of these
sleeps were in poll_container (0.5s default) and inline polling
loops in the dashboard and gateway-supervised tests. Halving the
interval halves the sleep time for each poll cycle without changing
docker call count or overhead.

Unlike the blocking-exec experiment (which traded fewer calls for
longer per-call duration and regressed on CI), this change only
reduces idle sleep time — the docker exec calls stay the same.
2026-07-14 17:38:18 -04:00
ethernet 3fc4e413d2 feat(ci): track time.sleep in docker test profiler
The profiler now monkey-patches time.sleep alongside subprocess.run,
capturing the invisible "gap" time from polling loops (wait_for_container_ready,
poll_container, wait_for_log, etc.) that was previously unaccounted for.

The JSON report now includes per-test total_sleep_s, sleep_count, and
a sleeps[] array with caller location. The summary includes
total_sleep_s and total_wall_s (docker + sleep). The CI merge step
also aggregates sleep totals.

Local profile now shows: 201s docker + 75s sleep = 276s wall (38s
runner wall with 32-way parallelism). The biggest sleep consumer is
test_dashboard_insecure_env_var_no_longer_bypasses at 11.8s of
poll_container sleeps.
2026-07-14 16:22:05 -04:00
ethernet ca6ede33e1 docs(docker-tests): document why poll loop beats blocking exec in wait_for_container_ready
Tried replacing the N-times-docker-exec polling loop with a single
blocking docker exec (in-container until/sleep loop). CI profiling
showed this was a net regression: docker exec connection overhead on
shared runners is ~0.7-1s per call, while the existing poll approach
uses 3-7 quick 0.27s execs. The blocking approach traded fewer calls
for longer per-call duration and lost.

Reverted to the original poll loop, added a docstring note explaining
why so the next person does not re-attempt the same optimization.
2026-07-14 16:06:37 -04:00
ethernet 3e89edf830 perf(docker-tests): share containers across read-only tests
Add a module-scoped `shared_container` fixture to tests/docker/conftest.py
that boots one `sleep infinity` container per test module and tears it
down at module exit. Convert read-only tests that previously used
`docker run --rm --entrypoint sh/cat/test/su` (bypassing s6 to check
static image properties) or `docker run -d` + `docker exec` (starting
identical containers per test) to use `docker exec` on the shared
container instead.

Converted files:
  test_immutable_install_permissions.py — 2 throwaway runs → 2 execs
  test_license_file_present.py          — 1 throwaway run → 1 exec
  test_tini_compat_shim.py              — 1 throwaway run → 1 exec
  test_tui_prebuilt_bundle.py           — 2 throwaway runs → 2 execs
  test_dump_build_sha.py                — 2 throwaway runs → 2 execs
  test_immutable_install.py             — 3 detached runs → 1 shared + 1 isolated
  test_dashboard.py                     — 2 detached runs → 0 (use shared)

Local profiling shows docker run calls in these 7 files dropped from
~25 to 7 (the 7 are shared_container boots per module + the one test
that needs a restart). Each eliminated `docker run` was paying 1-9s
of s6 cont-init startup; the replacement `docker exec` calls average
0.10s — an ~50x speedup per operation.

Tests that mutate state (restarts, config changes, gateway starts)
still use their own containers via `container_name` + `start_container`.
2026-07-14 02:10:00 -04:00
ethernet 3ec1e82629 fix(ci): pass profile env var through run_tests.sh env -i barrier
Two bugs fixed:

1. scripts/run_tests.sh uses env -i (empty environment) and only
   passes through a hand-picked set of vars. HERMES_DOCKER_TEST_PROFILE
   and HERMES_DOCKER_PROFILE_OUT were not in that list, so the env var
   set in the CI workflow was stripped before reaching the pytest
   subprocesses — the plugin never activated and no JSON was produced.

2. run_tests_parallel.py spawns each test file in its own subprocess,
   so each would write to the same docker-test-profile.json, clobbering
   each other. Changed the default output path to include the PID
   (docker-test-profile-<pid>.json) and added a CI merge step that
   combines all per-PID files into a single docker-test-profile.json
   before uploading as an artifact.
2026-07-13 18:58:25 -04:00
ethernet 9f9ab13a64 feat(ci): profile docker integration test operations
Add a pytest profiling plugin (tests/docker/profiling.py) that
instruments every subprocess.run call targeting docker, collecting
per-test and per-subcommand wall-clock timings. Activated via
HERMES_DOCKER_TEST_PROFILE=1 — zero overhead when unset.

The CI docker.yml workflow now enables profiling and uploads the
JSON report as an artifact (docker-test-profile-<arch>), so we can
see exactly which docker operations (run, exec, restart, polling)
dominate the slow CI runs vs. fast local runs.

Output includes:
- Per-subcommand summary (call count, total/avg time, %)
- Top 10 slowest tests by docker operation time
- Top 10 slowest individual docker calls
- Full JSON report for offline analysis
2026-07-13 18:16:14 -04:00
11 changed files with 592 additions and 102 deletions
+61
View File
@@ -143,9 +143,70 @@ jobs:
OPENROUTER_API_KEY: ""
OPENAI_API_KEY: ""
NOUS_API_KEY: ""
# Profile every docker subprocess call so we can diagnose why
# the suite is slow on CI vs. local. Each per-file subprocess
# writes its own docker-test-profile-<pid>.json; the merge
# step below combines them into a single report.
HERMES_DOCKER_TEST_PROFILE: "1"
run: |
scripts/run_tests.sh tests/docker/ --file-timeout 600
- name: Merge docker test profiles
if: always()
run: |
python3 -c "
import json, glob, sys
files = sorted(glob.glob('docker-test-profile-*.json'))
if not files:
print('No per-PID profile files found — profiling may not have activated')
sys.exit(0)
merged = {'tests': [], 'summary': {}}
for f in files:
with open(f) as fh:
data = json.load(fh)
merged['tests'].extend(data.get('tests', []))
# Recompute summary aggregates from merged tests.
subcmd_totals = {}
subcmd_counts = {}
total_sleep = 0
total_sleeps = 0
for t in merged['tests']:
for sub, info in t.get('by_subcommand', {}).items():
subcmd_totals[sub] = subcmd_totals.get(sub, 0) + info['total_s']
subcmd_counts[sub] = subcmd_counts.get(sub, 0) + info['count']
total_sleep += t.get('total_sleep_s', 0)
total_sleeps += t.get('sleep_count', 0)
total_docker = sum(subcmd_totals.values())
merged['summary'] = {
'total_tests': len(merged['tests']),
'total_calls': sum(subcmd_counts.values()),
'total_sleeps': total_sleeps,
'total_docker_s': round(total_docker, 3),
'total_sleep_s': round(total_sleep, 3),
'total_wall_s': round(total_docker + total_sleep, 3),
'by_subcommand': {
sub: {
'count': subcmd_counts[sub],
'total_s': round(subcmd_totals[sub], 3),
'avg_s': round(subcmd_totals[sub] / subcmd_counts[sub], 3) if subcmd_counts[sub] else 0,
}
for sub in sorted(subcmd_totals, key=lambda s: subcmd_totals[s], reverse=True)
},
}
with open('docker-test-profile.json', 'w') as fh:
json.dump(merged, fh, indent=2)
print(f'Merged {len(files)} per-PID profiles ({len(merged[\"tests\"])} tests, {total_docker:.0f}s docker + {total_sleep:.0f}s sleep = {total_docker + total_sleep:.0f}s wall)')
"
- name: Upload docker test profile
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: docker-test-profile-${{ matrix.arch }}
path: docker-test-profile.json
retention-days: 14
if-no-files-found: warn
# ---------------------------------------------------------------------------
# Stitch both per-arch digests into a single tagged multi-arch manifest.
# This is a registry-side operation — no building, no layer re-push —
+2
View File
@@ -88,6 +88,8 @@ exec env -i \
LC_ALL=C.UTF-8 \
PYTHONHASHSEED=0 \
${HERMES_RUN_SLOW_PET_TESTS:+HERMES_RUN_SLOW_PET_TESTS="$HERMES_RUN_SLOW_PET_TESTS"} \
${HERMES_DOCKER_TEST_PROFILE:+HERMES_DOCKER_TEST_PROFILE="$HERMES_DOCKER_TEST_PROFILE"} \
${HERMES_DOCKER_PROFILE_OUT:+HERMES_DOCKER_PROFILE_OUT="$HERMES_DOCKER_PROFILE_OUT"} \
${EXTRA_PYTHONPATH:+PYTHONPATH="$EXTRA_PYTHONPATH"} \
${EXTRA_PYTEST_PLUGINS:+PYTEST_PLUGINS="$EXTRA_PYTEST_PLUGINS"} \
"$PYTHON" "$SCRIPT_DIR/run_tests_parallel.py" "$@"
+50
View File
@@ -8,6 +8,10 @@ Override the image with ``HERMES_TEST_IMAGE`` env var to point at a pre-built
image (faster local iteration); otherwise the ``built_image`` fixture builds
the repo's Dockerfile once per session.
Profiling: set ``HERMES_DOCKER_TEST_PROFILE=1`` to instrument every
``subprocess.run`` that invokes ``docker``. A per-test breakdown of
subcommand timings is written to ``docker-test-profile.json`` and a
summary is printed to stderr at session end. See ``profiling.py``.
"""
from __future__ import annotations
@@ -19,6 +23,8 @@ from collections.abc import Iterator
import pytest
pytest_plugins = ["tests.docker.profiling"]
IMAGE_TAG = os.environ.get("HERMES_TEST_IMAGE", "hermes-agent-harness:latest")
@@ -82,6 +88,42 @@ def container_name(request) -> Iterator[str]:
)
@pytest.fixture(scope="module")
def shared_container(built_image: str, request) -> Iterator[str]:
"""A long-lived container shared across all tests in a module.
Starts one ``sleep infinity`` container, waits for s6 cont-init to
finish, yields the container name, and tears it down at module exit.
Tests that only need to *read* static image state (env vars, file
existence, immutable-permission checks, etc.) can share this instead
of each paying the full ``docker run`` + cont-init startup cost.
Tests that *mutate* container state (config changes, gateway starts,
restarts, etc.) should still use ``container_name`` + ``start_container``
for isolation.
"""
safe = request.module.__name__.replace(".", "-")
name = f"hermes-shared-{safe}"
# Clean up any leftover from a prior run.
subprocess.run(
["docker", "rm", "-f", name],
capture_output=True, timeout=10,
)
r = subprocess.run(
["docker", "run", "-d", "--name", name, built_image, "sleep", "infinity"],
capture_output=True, text=True, timeout=60,
)
assert r.returncode == 0, f"docker run failed: {r.stderr}"
try:
wait_for_container_ready(name)
yield name
finally:
subprocess.run(
["docker", "rm", "-f", name],
capture_output=True, timeout=10,
)
# ---------------------------------------------------------------------------
# docker_exec — default to the unprivileged hermes user
# ---------------------------------------------------------------------------
@@ -153,6 +195,14 @@ def wait_for_container_ready(
Raises ``TimeoutError`` if the container never becomes ready — much
better than a fixed ``time.sleep()`` that either wastes time on fast
machines or flakes on slow ones.
Note: an earlier iteration tried a single blocking ``docker exec``
with an in-container ``until`` loop to eliminate polling overhead.
That was a net regression on CI: the per-``docker exec`` connection
overhead on shared runners (~0.71s) is higher than the cost of
37 quick poll calls (0.27s each), so the blocking approach traded
fewer calls for longer per-call duration and lost. The poll loop
is kept because it's better suited to CI's docker exec cost profile.
"""
end = time.monotonic() + deadline_s
while time.monotonic() < end:
+416
View File
@@ -0,0 +1,416 @@
"""Profiling plugin for docker integration tests.
Activated by ``HERMES_DOCKER_TEST_PROFILE=1``. Instruments every
``subprocess.run`` call whose argv starts with ``docker`` to measure
wall-clock time, and also tracks ``time.sleep`` calls so we can see the
full wall-clock picture — including the invisible sleep time in polling
loops that doesn't show up as docker call duration.
Outputs:
- JSON report at ``$HERMES_DOCKER_PROFILE_OUT`` (default:
``docker-test-profile-{pid}.json`` in the repo root — per-PID
because run_tests_parallel.py spawns each test file in its own
subprocess).
- Console summary on stderr at session end.
The plugin is a no-op when the env var is not set — zero overhead on
normal test runs.
"""
from __future__ import annotations
import json
import os
import sys
import time
from collections import defaultdict
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Optional
import pytest
_ACTIVE = bool(os.environ.get("HERMES_DOCKER_TEST_PROFILE"))
# ---------------------------------------------------------------------------
# Data model
# ---------------------------------------------------------------------------
@dataclass
class TimedEvent:
"""Base for any timed event in a test's lifecycle."""
duration_s: float
timestamp: float # monotonic
@dataclass
class DockerCall(TimedEvent):
"""One instrumented docker subprocess call."""
argv: list[str]
returncode: int
@dataclass
class SleepGap(TimedEvent):
"""A time.sleep() that occurred between docker calls.
This captures the invisible "gap" time — the polling sleeps, the
Python logic between assertions, etc. Each SleepGap is attributed to
the test that was running when it occurred.
"""
caller: str # short description of who called sleep
@dataclass
class TestProfile:
"""Per-test accumulation of docker calls and sleep gaps."""
name: str
calls: list[DockerCall] = field(default_factory=list)
sleeps: list[SleepGap] = field(default_factory=list)
@property
def total_docker_s(self) -> float:
return sum(c.duration_s for c in self.calls)
@property
def total_sleep_s(self) -> float:
return sum(s.duration_s for s in self.sleeps)
@property
def total_wall_s(self) -> float:
"""Docker time + sleep time — the test's visible wall-clock cost."""
return self.total_docker_s + self.total_sleep_s
@property
def call_count(self) -> int:
return len(self.calls)
def by_subcommand(self) -> dict[str, list[DockerCall]]:
"""Group calls by the first docker subcommand (run, exec, restart, ...)."""
groups: dict[str, list[DockerCall]] = defaultdict(list)
for c in self.calls:
sub = c.argv[1] if len(c.argv) > 1 else "?"
groups[sub].append(c)
return groups
# ---------------------------------------------------------------------------
# Session-level collector
# ---------------------------------------------------------------------------
class ProfileCollector:
"""Singleton accumulator shared across the pytest session."""
def __init__(self) -> None:
self.tests: dict[str, TestProfile] = {}
self.current: Optional[TestProfile] = None
self._original_run: Any = None
self._original_sleep: Any = None
self._patched = False
self._last_docker_end: float = 0.0
def start_test(self, name: str) -> None:
self.current = TestProfile(name=name)
self.tests[name] = self.current
self._last_docker_end = 0.0
def end_test(self) -> None:
self.current = None
def record(self, call: DockerCall) -> None:
if self.current is not None:
self.current.calls.append(call)
self._last_docker_end = call.timestamp + call.duration_s
def record_sleep(self, gap: SleepGap) -> None:
if self.current is not None:
self.current.sleeps.append(gap)
def install_patch(self) -> None:
"""Monkey-patch subprocess.run and time.sleep to capture timings."""
if self._patched:
return
import subprocess
self._original_run = subprocess.run
self._original_sleep = time.sleep
collector = self
def timed_run(*args: Any, **kwargs: Any) -> Any:
argv = list(args[0]) if args and args[0] else kwargs.get("args", [])
is_docker = bool(argv) and argv[0] == "docker"
if not is_docker:
return collector._original_run(*args, **kwargs)
t0 = time.monotonic()
result = collector._original_run(*args, **kwargs)
elapsed = time.monotonic() - t0
rc = getattr(result, "returncode", -1)
call = DockerCall(
duration_s=round(elapsed, 4),
timestamp=t0,
argv=[str(a) for a in argv],
returncode=rc,
)
collector.record(call)
return result
def timed_sleep(secs: float) -> None:
# Only track sleeps that happen between docker calls within a test.
# Short sleeps (< 0.05s) are probably just Python scheduling noise.
if collector.current is None or secs < 0.05:
return collector._original_sleep(secs)
t0 = time.monotonic()
collector._original_sleep(secs)
elapsed = time.monotonic() - t0
# Try to identify the caller for context
import traceback
stack = traceback.extract_stack(limit=4)
caller = ""
for frame in reversed(stack):
fname = frame.filename
if "conftest" in fname or "test_" in fname:
caller = f"{Path(fname).name}:{frame.lineno}"
break
gap = SleepGap(
duration_s=round(elapsed, 4),
timestamp=t0,
caller=caller,
)
collector.record_sleep(gap)
subprocess.run = timed_run
time.sleep = timed_sleep
self._patched = True
def uninstall_patch(self) -> None:
if not self._patched:
return
import subprocess
if self._original_run is not None:
subprocess.run = self._original_run
if self._original_sleep is not None:
time.sleep = self._original_sleep
self._patched = False
def build_report(self) -> dict[str, Any]:
"""Build the JSON-serializable report dict."""
report: dict[str, Any] = {
"tests": [],
"summary": {},
}
all_docker_time = 0.0
all_sleep_time = 0.0
all_call_count = 0
all_sleep_count = 0
subcmd_totals: dict[str, float] = defaultdict(float)
subcmd_counts: dict[str, int] = defaultdict(int)
for _name, tp in sorted(
self.tests.items(), key=lambda x: x[1].total_wall_s, reverse=True
):
by_sub = tp.by_subcommand()
test_entry: dict[str, Any] = {
"name": tp.name,
"total_docker_s": round(tp.total_docker_s, 3),
"total_sleep_s": round(tp.total_sleep_s, 3),
"total_wall_s": round(tp.total_wall_s, 3),
"call_count": tp.call_count,
"sleep_count": len(tp.sleeps),
"by_subcommand": {
sub: {
"count": len(calls),
"total_s": round(sum(c.duration_s for c in calls), 3),
"avg_s": round(
sum(c.duration_s for c in calls) / len(calls), 3
)
if calls
else 0,
"max_s": round(max(c.duration_s for c in calls), 3)
if calls
else 0,
}
for sub, calls in sorted(
by_sub.items(),
key=lambda x: sum(c.duration_s for c in x[1]),
reverse=True,
)
},
"calls": [
{
"type": "docker",
"argv": " ".join(c.argv[:8]),
"duration_s": c.duration_s,
"returncode": c.returncode,
}
for c in sorted(tp.calls, key=lambda x: x.duration_s, reverse=True)
],
"sleeps": [
{
"type": "sleep",
"duration_s": s.duration_s,
"caller": s.caller,
}
for s in sorted(tp.sleeps, key=lambda x: x.duration_s, reverse=True)
],
}
report["tests"].append(test_entry)
all_docker_time += tp.total_docker_s
all_sleep_time += tp.total_sleep_s
all_call_count += tp.call_count
all_sleep_count += len(tp.sleeps)
for sub, calls in by_sub.items():
subcmd_totals[sub] += sum(c.duration_s for c in calls)
subcmd_counts[sub] += len(calls)
report["summary"] = {
"total_tests": len(self.tests),
"total_docker_s": round(all_docker_time, 3),
"total_sleep_s": round(all_sleep_time, 3),
"total_wall_s": round(all_docker_time + all_sleep_time, 3),
"total_calls": all_call_count,
"total_sleeps": all_sleep_count,
"by_subcommand": {
sub: {
"count": subcmd_counts[sub],
"total_s": round(subcmd_totals[sub], 3),
"avg_s": round(subcmd_totals[sub] / subcmd_counts[sub], 3)
if subcmd_counts[sub]
else 0,
}
for sub in sorted(
subcmd_totals, key=lambda s: subcmd_totals[s], reverse=True
)
},
}
return report
def write_report(self, out_path: Path) -> None:
"""Write the JSON report."""
out_path.write_text(
json.dumps(self.build_report(), indent=2) + "\n"
)
def print_summary(self) -> None:
"""Print a human-readable summary to stderr."""
if not self.tests:
print("\n[docker-profile] No tests profiled.", file=sys.stderr)
return
print("\n" + "=" * 72, file=sys.stderr)
print("[docker-profile] Docker + sleep timing breakdown", file=sys.stderr)
print("=" * 72, file=sys.stderr)
subcmd_totals: dict[str, float] = defaultdict(float)
subcmd_counts: dict[str, int] = defaultdict(int)
for tp in self.tests.values():
for sub, calls in tp.by_subcommand().items():
subcmd_totals[sub] += sum(c.duration_s for c in calls)
subcmd_counts[sub] += len(calls)
total_sleep = sum(tp.total_sleep_s for tp in self.tests.values())
total_docker = sum(tp.total_docker_s for tp in self.tests.values())
total_wall = total_docker + total_sleep
print(
f"\n Total wall time: {total_wall:.1f}s"
f" = {total_docker:.1f}s docker + {total_sleep:.1f}s sleep\n",
file=sys.stderr,
)
print(
f" {'Category':<15} {'Count':>8} {'Total':>10} {'Avg':>8} {'%':>6}",
file=sys.stderr,
)
print(
f" {'' * 15} {'' * 8} {'' * 10} {'' * 8} {'' * 6}",
file=sys.stderr,
)
for sub in sorted(
subcmd_totals, key=lambda s: subcmd_totals[s], reverse=True
):
t = subcmd_totals[sub]
n = subcmd_counts[sub]
pct = (t / total_wall * 100) if total_wall else 0
print(
f" docker {sub:<8} {n:>8} {t:>9.1f}s {t / n:>7.2f}s {pct:>5.1f}%",
file=sys.stderr,
)
# Sleep row
sleep_count = sum(len(tp.sleeps) for tp in self.tests.values())
pct = (total_sleep / total_wall * 100) if total_wall else 0
print(
f" {'time.sleep':<15} {sleep_count:>8} {total_sleep:>9.1f}s"
f" {total_sleep / sleep_count if sleep_count else 0:>7.2f}s {pct:>5.1f}%",
file=sys.stderr,
)
# Top 10 slowest tests by WALL time
print(
f"\n Top 10 slowest tests (by wall time = docker + sleep):\n",
file=sys.stderr,
)
sorted_tests = sorted(
self.tests.values(), key=lambda t: t.total_wall_s, reverse=True
)
for i, tp in enumerate(sorted_tests[:10], 1):
print(
f" {i:>2}. {tp.total_wall_s:>6.1f}s "
f"({tp.total_docker_s:.1f}s docker + {tp.total_sleep_s:.1f}s sleep) "
f"{tp.call_count:>3} calls ...{tp.name[-50:]}",
file=sys.stderr,
)
print("\n" + "=" * 72, file=sys.stderr)
# ---------------------------------------------------------------------------
# Plugin hooks
# ---------------------------------------------------------------------------
_collector: Optional[ProfileCollector] = None
def _get_collector() -> ProfileCollector:
global _collector
if _collector is None:
_collector = ProfileCollector()
return _collector
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_call(item):
"""Wrap each test call to track per-test docker operations."""
if not _ACTIVE:
yield
return
collector = _get_collector()
collector.start_test(item.nodeid)
yield
collector.end_test()
def pytest_sessionstart(session):
"""Install the subprocess.run + time.sleep patches at session start."""
if not _ACTIVE:
return
collector = _get_collector()
collector.install_patch()
def pytest_sessionfinish(session, exitstatus):
"""Write the report and print the summary at session end."""
if not _ACTIVE:
return
collector = _get_collector()
collector.uninstall_patch()
default_out = str(Path.cwd() / f"docker-test-profile-{os.getpid()}.json")
out = os.environ.get("HERMES_DOCKER_PROFILE_OUT", default_out)
out_path = Path(out)
collector.write_report(out_path)
collector.print_summary()
print(f"\n[docker-profile] Report written to {out_path}", file=sys.stderr)
+4 -6
View File
@@ -19,11 +19,10 @@ from tests.docker.conftest import docker_exec, docker_exec_sh, start_container,
def test_dashboard_not_running_by_default(
built_image: str, container_name: str,
shared_container: str,
) -> None:
"""Without HERMES_DASHBOARD, no dashboard process should be running."""
start_container(built_image, container_name, cmd="sleep 60")
r = docker_exec(container_name, "pgrep", "-f", "hermes dashboard")
r = docker_exec(shared_container, "pgrep", "-f", "hermes dashboard")
# pgrep exits non-zero when no match found
assert r.returncode != 0, (
"Dashboard should not be running without HERMES_DASHBOARD"
@@ -31,7 +30,7 @@ def test_dashboard_not_running_by_default(
def test_dashboard_slot_reports_down_when_disabled(
built_image: str, container_name: str,
shared_container: str,
) -> None:
"""Without HERMES_DASHBOARD, s6-svstat should report the dashboard
slot as DOWN (not up-with-sleep-infinity, which would
@@ -41,11 +40,10 @@ def test_dashboard_slot_reports_down_when_disabled(
writes a `down` marker file in the live service-dir when
HERMES_DASHBOARD is unset, so the slot reflects reality.
"""
start_container(built_image, container_name, cmd="sleep 60")
# /command/ isn't on PATH for docker-exec sessions, so call by
# absolute path.
r = docker_exec(
container_name, "/command/s6-svstat", "/run/service/dashboard",
shared_container, "/command/s6-svstat", "/run/service/dashboard",
)
assert r.returncode == 0, f"s6-svstat failed: {r.stderr!r} / {r.stdout!r}"
assert "down" in r.stdout, (
+15 -22
View File
@@ -25,24 +25,21 @@ from __future__ import annotations
import re
import subprocess
from tests.docker.conftest import docker_exec_sh, wait_for_container_ready
_VERSION_LINE = re.compile(r"^version:\s+(?P<rest>.+)$", re.MULTILINE)
_SHA_BRACKET = re.compile(r"\[(?P<sha>[^\]]+)\]\s*$")
def _run_dump(image: str) -> str:
"""Return the stdout of ``docker run <image> dump``.
def _run_dump(container: str) -> str:
"""Return the stdout of ``hermes dump`` inside the running container.
Relies on Docker's anonymous VOLUME for ``/opt/data`` (declared by the
Dockerfile) so the container's hermes user (UID 10000) can bootstrap
its config. Anonymous volumes are auto-cleaned by ``--rm``, so unlike
a host bind-mount we don't have to chown anything to UID 10000 (which
would break cleanup on non-root hosts).
The container is already booted with ``sleep infinity`` by the
``shared_container`` fixture, so we just ``docker exec`` the command
instead of paying the full ``docker run`` startup cost each time.
"""
r = subprocess.run(
["docker", "run", "--rm", image, "dump"],
capture_output=True, text=True, timeout=120,
)
r = docker_exec_sh(container, "hermes dump", timeout=60)
assert r.returncode == 0, (
f"hermes dump exited {r.returncode}: "
f"stderr={r.stderr[-1000:]!r}\nstdout={r.stdout[-1000:]!r}"
@@ -50,29 +47,25 @@ def _run_dump(image: str) -> str:
return r.stdout
def _read_baked_sha_from_image(image: str) -> str | None:
def _read_baked_sha_from_container(container: str) -> str | None:
"""Return the ``/opt/hermes/.hermes_build_sha`` content, or None if absent."""
r = subprocess.run(
[
"docker", "run", "--rm", "--entrypoint", "cat", image,
"/opt/hermes/.hermes_build_sha",
],
capture_output=True, text=True, timeout=30,
r = docker_exec_sh(
container, "cat /opt/hermes/.hermes_build_sha 2>/dev/null", timeout=10,
)
if r.returncode != 0:
if r.returncode != 0 or not r.stdout.strip():
return None
return r.stdout.strip() or None
def test_dump_reports_baked_sha_when_present(built_image: str) -> None:
def test_dump_reports_baked_sha_when_present(shared_container: str) -> None:
"""When the image was built with ``HERMES_GIT_SHA``, dump must surface it.
Together with the smoke-test action (which exercises ``--help``), this
closes the regression loop for the missing-sha bug: any future change
that breaks the baked-file -> dump pipeline will fail CI here.
"""
baked = _read_baked_sha_from_image(built_image)
stdout = _run_dump(built_image)
baked = _read_baked_sha_from_container(shared_container)
stdout = _run_dump(shared_container)
match = _VERSION_LINE.search(stdout)
assert match, f"no `version:` line in dump output:\n{stdout[:2000]}"
+12 -14
View File
@@ -7,6 +7,10 @@ Build the real image and verify at runtime:
3. /opt/hermes/.install_method contains "docker" (code-scoped stamp)
4. $HERMES_HOME/.install_method is NOT stamped as "docker" by stage2
5. A stale "docker" stamp in $HERMES_HOME is healed (removed) on boot
Tests 14 are read-only checks against the default container state and
share the module-scoped ``shared_container`` fixture. Test 5 mutates
state and triggers a restart, so it uses its own container.
"""
from __future__ import annotations
@@ -19,7 +23,7 @@ from tests.docker.conftest import (
def test_install_tree_not_writable_by_hermes(
built_image: str, container_name: str,
shared_container: str,
) -> None:
"""The hermes user must not be able to modify /opt/hermes.
@@ -27,10 +31,8 @@ def test_install_tree_not_writable_by_hermes(
root-owned and non-writable so an agent session cannot self-modify
the installation and brick the gateway.
"""
start_container(built_image, container_name)
r = docker_exec_sh(
container_name,
shared_container,
# Try to create a file under /opt/hermes as the hermes user
"touch /opt/hermes/test_write 2>&1 && "
"echo WRITE_SUCCEEDED || echo WRITE_FAILED",
@@ -43,7 +45,7 @@ def test_install_tree_not_writable_by_hermes(
# Also check a key subdirectory
r = docker_exec_sh(
container_name,
shared_container,
"touch /opt/hermes/.venv/test_write 2>&1 && "
"echo WRITE_SUCCEEDED || echo WRITE_FAILED",
timeout=10,
@@ -54,15 +56,13 @@ def test_install_tree_not_writable_by_hermes(
def test_hermes_disable_lazy_installs_and_dont_write_bytecode(
built_image: str, container_name: str,
shared_container: str,
) -> None:
"""The container must set PYTHONDONTWRITEBYTECODE and
HERMES_DISABLE_LAZY_INSTALLS=1 so no .pyc files are written to the
immutable install tree and no lazy installs attempt to modify it."""
start_container(built_image, container_name)
r = docker_exec_sh(
container_name,
shared_container,
'test "$PYTHONDONTWRITEBYTECODE" = "1" && '
'test "$HERMES_DISABLE_LAZY_INSTALLS" = "1" && '
'echo ENV_OK || echo ENV_MISSING',
@@ -75,15 +75,13 @@ def test_hermes_disable_lazy_installs_and_dont_write_bytecode(
def test_install_method_stamp_is_code_scoped(
built_image: str, container_name: str,
shared_container: str,
) -> None:
"""The 'docker' install-method stamp must be baked at
/opt/hermes/.install_method (code-scoped), NOT in $HERMES_HOME."""
start_container(built_image, container_name)
# Code-scoped stamp must exist and say "docker"
r = docker_exec_sh(
container_name,
shared_container,
"cat /opt/hermes/.install_method",
timeout=10,
)
@@ -96,7 +94,7 @@ def test_install_method_stamp_is_code_scoped(
# $HERMES_HOME must NOT have a 'docker' stamp
r = docker_exec_sh(
container_name,
shared_container,
"cat /opt/data/.install_method 2>/dev/null || echo NONE",
timeout=10,
)
@@ -1,27 +1,25 @@
"""Docker smoke tests for immutable install permissions."""
from __future__ import annotations
import subprocess
import textwrap
from tests.docker.conftest import docker_exec_sh
def test_container_sets_hosted_write_policy_env(built_image: str) -> None:
def test_container_sets_hosted_write_policy_env(shared_container: str) -> None:
script = (
'test "$HERMES_HOME" = "/opt/data" && '
'test "$HERMES_WRITE_SAFE_ROOT" = "/opt/data" && '
'test "$HERMES_DISABLE_LAZY_INSTALLS" = "1" && '
'test "$PYTHONDONTWRITEBYTECODE" = "1"'
)
result = subprocess.run(
["docker", "run", "--rm", "--entrypoint", "sh", built_image, "-c", script],
capture_output=True,
text=True,
timeout=60,
)
assert result.returncode == 0, result.stderr[-2000:]
r = docker_exec_sh(shared_container, script, timeout=30)
assert r.returncode == 0, r.stderr[-2000:]
def test_hermes_user_cannot_modify_install_but_can_write_data(built_image: str) -> None:
def test_hermes_user_cannot_modify_install_but_can_write_data(
shared_container: str,
) -> None:
script = textwrap.dedent(
r"""
set -eu
@@ -46,22 +44,6 @@ def test_hermes_user_cannot_modify_install_but_can_write_data(built_image: str)
PY
"""
).strip()
result = subprocess.run(
[
"docker",
"run",
"--rm",
"--entrypoint",
"su",
built_image,
"hermes",
"-s",
"/bin/sh",
"-c",
script,
],
capture_output=True,
text=True,
timeout=120,
)
assert result.returncode == 0, result.stderr[-2000:]
# Run as hermes user via docker_exec_sh's default user context.
r = docker_exec_sh(shared_container, script, timeout=60)
assert r.returncode == 0, r.stderr[-2000:]
+4 -8
View File
@@ -6,21 +6,17 @@ Docker image).
"""
from __future__ import annotations
import subprocess
from tests.docker.conftest import docker_exec
def test_docker_image_contains_license_file(built_image: str) -> None:
def test_docker_image_contains_license_file(shared_container: str) -> None:
"""The LICENSE file must be present inside the built Docker image.
PEP 639 license-files metadata references LICENSE, and the Docker
build context must not exclude it.
"""
r = subprocess.run(
["docker", "run", "--rm", "--entrypoint", "test",
built_image, "-f", "/opt/hermes/LICENSE"],
capture_output=True, text=True, timeout=60,
)
r = docker_exec(shared_container, "test", "-f", "/opt/hermes/LICENSE")
assert r.returncode == 0, (
f"LICENSE file not found at /opt/hermes/LICENSE inside the Docker "
f"image: {r.stderr[-500:]}"
)
)
+4 -5
View File
@@ -11,7 +11,7 @@ from __future__ import annotations
import subprocess
def test_tini_compat_symlink_exists(built_image: str) -> None:
def test_tini_compat_symlink_exists(shared_container: str) -> None:
"""/usr/bin/tini must exist as a symlink to /init.
Regression for #34192: orchestration templates (e.g. Hostinger's
@@ -20,11 +20,10 @@ def test_tini_compat_symlink_exists(built_image: str) -> None:
PID-1 reaper without behavior change.
"""
r = subprocess.run(
["docker", "run", "--rm", "--entrypoint", "sh",
built_image, "-c",
["docker", "exec", shared_container, "sh", "-c",
'test -L /usr/bin/tini && '
'test "$(readlink -f /usr/bin/tini)" = "/init"'],
capture_output=True, text=True, timeout=60,
capture_output=True, text=True, timeout=30,
)
assert r.returncode == 0, (
f"/usr/bin/tini is not a symlink to /init: {r.stderr[-500:]}"
@@ -51,4 +50,4 @@ def test_entrypoint_is_init_not_tini(built_image: str) -> None:
# /usr/bin/tini should NOT be in the entrypoint.
assert "tini" not in entrypoint.lower(), (
f"ENTRYPOINT references tini instead of /init: {entrypoint!r}"
)
)
+13 -18
View File
@@ -18,33 +18,26 @@ from __future__ import annotations
import json
import shlex
import subprocess
from tests.docker.conftest import docker_exec_sh
def _exec_py(image: str, py: str) -> str:
"""Run a Python snippet inside the image as the hermes user, return stdout."""
def _exec_py(container: str, py: str) -> str:
"""Run a Python snippet inside the container as the hermes user, return stdout."""
inner = (
"source /opt/hermes/.venv/bin/activate && "
". /opt/hermes/.venv/bin/activate && "
"cd /opt/hermes && "
f"python3 -c {shlex.quote(py)}"
)
# Drop to the hermes user (UID 10000) so we exercise the same path the
# dashboard PTY child runs as — not root.
cmd = [
"docker", "run", "--rm", "--entrypoint", "su", image,
"hermes", "-s", "/bin/bash", "-c", inner,
]
r = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
r = docker_exec_sh(container, inner, timeout=60)
assert r.returncode == 0, f"in-container python failed:\n{r.stderr[-2000:]}"
return r.stdout.strip()
def test_hermes_tui_dir_env_is_set(built_image: str) -> None:
def test_hermes_tui_dir_env_is_set(shared_container: str) -> None:
"""HERMES_TUI_DIR must point at the prebuilt bundle dir in the image."""
r = subprocess.run(
["docker", "run", "--rm", "--entrypoint", "sh", built_image,
"-c", 'printf "%s" "$HERMES_TUI_DIR"'],
capture_output=True, text=True, timeout=60,
r = docker_exec_sh(
shared_container, 'printf "%s" "$HERMES_TUI_DIR"', timeout=30,
)
assert r.returncode == 0, r.stderr[-2000:]
assert r.stdout.strip() == "/opt/hermes/ui-tui", (
@@ -52,7 +45,9 @@ def test_hermes_tui_dir_env_is_set(built_image: str) -> None:
)
def test_prebuilt_bundle_present_and_no_runtime_install(built_image: str) -> None:
def test_prebuilt_bundle_present_and_no_runtime_install(
shared_container: str,
) -> None:
"""The launcher must (a) find the prebuilt bundle and (b) NOT want an
npm install — i.e. it takes the same path as a nix/packaged release."""
py = (
@@ -69,7 +64,7 @@ def test_prebuilt_bundle_present_and_no_runtime_install(built_image: str) -> Non
"}\n"
"print(json.dumps(out))\n"
)
out = json.loads(_exec_py(built_image, py))
out = json.loads(_exec_py(shared_container, py))
assert out["dist_entry_exists"], "prebuilt ui-tui/dist/entry.js missing from image"
# With HERMES_TUI_DIR set, _make_tui_argv returns the prebuilt path BEFORE
# ever reaching the install check — so the resolved argv is what matters.