hermes: equip workers with verified Go tools before accepting work
This commit is contained in:
parent
d66af9dbd4
commit
3cdcb2a2df
@ -111,6 +111,25 @@ spec:
|
|||||||
resources:
|
resources:
|
||||||
requests: {cpu: 2m, memory: 32Mi}
|
requests: {cpu: 2m, memory: 32Mi}
|
||||||
limits: {cpu: 100m, memory: 64Mi}
|
limits: {cpu: 100m, memory: 64Mi}
|
||||||
|
- name: install-worker-go
|
||||||
|
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
command: [/bin/sh, -ec, "timeout 300 /bin/sh /opt/coordinator/install_worker_go.sh"]
|
||||||
|
env:
|
||||||
|
- {name: HERMES_WORKER_TOOLS_DIR, value: /worker-data/tools}
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities: {drop: [ALL]}
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 10000
|
||||||
|
runAsGroup: 10000
|
||||||
|
seccompProfile: {type: RuntimeDefault}
|
||||||
|
volumeMounts:
|
||||||
|
- {name: tools, mountPath: /worker-data/tools}
|
||||||
|
- {name: coordinator, mountPath: /opt/coordinator, readOnly: true}
|
||||||
|
resources:
|
||||||
|
requests: {cpu: 5m, memory: 64Mi}
|
||||||
|
limits: {cpu: "1", memory: 256Mi}
|
||||||
- name: install-provider-clis
|
- name: install-provider-clis
|
||||||
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
|
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
|
|||||||
@ -167,6 +167,7 @@ configMapGenerator:
|
|||||||
- hermes_stt_client.py=scripts/hermes_stt_client.py
|
- hermes_stt_client.py=scripts/hermes_stt_client.py
|
||||||
- image_broker.py=scripts/image_broker.py
|
- image_broker.py=scripts/image_broker.py
|
||||||
- install_agent_tools.sh=scripts/install_agent_tools.sh
|
- install_agent_tools.sh=scripts/install_agent_tools.sh
|
||||||
|
- install_worker_go.sh=scripts/install_worker_go.sh
|
||||||
- jenkins_build_evidence.py=scripts/jenkins_build_evidence.py
|
- jenkins_build_evidence.py=scripts/jenkins_build_evidence.py
|
||||||
- jenkins_image_build_trigger.py=scripts/jenkins_image_build_trigger.py
|
- jenkins_image_build_trigger.py=scripts/jenkins_image_build_trigger.py
|
||||||
- hermes_image_release_status.py=scripts/hermes_image_release_status.py
|
- hermes_image_release_status.py=scripts/hermes_image_release_status.py
|
||||||
|
|||||||
@ -429,6 +429,12 @@ def report_exception(assignment: dict[str, Any], error: Exception) -> bool:
|
|||||||
)
|
)
|
||||||
except (OSError, ValueError, urllib.error.URLError, json.JSONDecodeError):
|
except (OSError, ValueError, urllib.error.URLError, json.JSONDecodeError):
|
||||||
return False
|
return False
|
||||||
|
def _require_executable(path: Path, provider: str) -> None:
|
||||||
|
"""Fail startup when an enabled provider cannot execute its configured binary."""
|
||||||
|
if not path.is_file() or not os.access(path, os.X_OK):
|
||||||
|
raise ProtocolError(f"configured provider executable is unavailable: {provider}")
|
||||||
|
|
||||||
|
|
||||||
def readiness() -> None:
|
def readiness() -> None:
|
||||||
if ORDINAL not in range(3):
|
if ORDINAL not in range(3):
|
||||||
raise ProtocolError("worker ordinal must be 0, 1, or 2")
|
raise ProtocolError("worker ordinal must be 0, 1, or 2")
|
||||||
@ -445,6 +451,13 @@ def readiness() -> None:
|
|||||||
for credential in credentials:
|
for credential in credentials:
|
||||||
if not credential.is_file() or not os.access(credential, os.R_OK):
|
if not credential.is_file() or not os.access(credential, os.R_OK):
|
||||||
raise ProtocolError(f"subscription credential is unavailable or read-only: {credential.name}")
|
raise ProtocolError(f"subscription credential is unavailable or read-only: {credential.name}")
|
||||||
|
if DISABLED_PROVIDER != "codex":
|
||||||
|
_require_executable(cli_lane_runner.CODEX_BIN, "codex")
|
||||||
|
if DISABLED_PROVIDER != "claude":
|
||||||
|
_require_executable(cli_lane_runner.CLAUDE_BIN, "claude")
|
||||||
|
native = os.environ.get("HERMES_CLAUDE_NATIVE_BIN", "").strip()
|
||||||
|
if native:
|
||||||
|
_require_executable(Path(native), "claude-native")
|
||||||
cli_lane_runner.RESULT_SCHEMA_PATH.parent.mkdir(parents=True, exist_ok=True)
|
cli_lane_runner.RESULT_SCHEMA_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
atomic_json(
|
atomic_json(
|
||||||
cli_lane_runner.RESULT_SCHEMA_PATH, cli_lane_runner.RESULT_SCHEMA, 0o644
|
cli_lane_runner.RESULT_SCHEMA_PATH, cli_lane_runner.RESULT_SCHEMA, 0o644
|
||||||
|
|||||||
60
services/hermes/scripts/install_worker_go.sh
Executable file
60
services/hermes/scripts/install_worker_go.sh
Executable file
@ -0,0 +1,60 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Install only the pinned Go toolchain on one worker's durable tools claim.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
tools=${HERMES_WORKER_TOOLS_DIR:-/worker-data/tools}
|
||||||
|
bin=${tools}/bin
|
||||||
|
version=1.26.5
|
||||||
|
|
||||||
|
case "$(uname -m)" in
|
||||||
|
aarch64)
|
||||||
|
dl_arch=arm64
|
||||||
|
go_sha=fe4789e92b1f33358680864bbe8704289e7bb5fc207d80623c308935bd696d49
|
||||||
|
;;
|
||||||
|
x86_64)
|
||||||
|
dl_arch=amd64
|
||||||
|
go_sha=5c2c3b16caefa1d968a94c1daca04a7ca301a496d9b086e17ad77bb81393f053
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "unsupported architecture for worker Go toolchain" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
root=${tools}/go-${version}-${dl_arch}
|
||||||
|
marker=${tools}/.worker-go-toolchain-${version}-${dl_arch}
|
||||||
|
expected="go version go${version} linux/${dl_arch}"
|
||||||
|
mkdir -p "${bin}"
|
||||||
|
|
||||||
|
verify() {
|
||||||
|
test -x "${root}/bin/go" && test -x "${root}/bin/gofmt" || return 1
|
||||||
|
test "$(timeout 15 "${root}/bin/go" version)" = "${expected}" || return 1
|
||||||
|
probe=$(mktemp "${tools}/.worker-gofmt.XXXXXX") || return 1
|
||||||
|
printf 'package main\nfunc main(){}\n' > "${probe}"
|
||||||
|
timeout 15 "${root}/bin/gofmt" -w "${probe}" || { rm -f "${probe}"; return 1; }
|
||||||
|
rm -f "${probe}"
|
||||||
|
}
|
||||||
|
|
||||||
|
publish() {
|
||||||
|
ln -sfn "../go-${version}-${dl_arch}/bin/go" "${bin}/go"
|
||||||
|
ln -sfn "../go-${version}-${dl_arch}/bin/gofmt" "${bin}/gofmt"
|
||||||
|
test -x "${bin}/go" && test -x "${bin}/gofmt"
|
||||||
|
}
|
||||||
|
|
||||||
|
if ! verify; then
|
||||||
|
rm -f "${marker}"
|
||||||
|
rm -rf "${root}"
|
||||||
|
work=$(mktemp -d "${tools}/.worker-go.XXXXXX")
|
||||||
|
trap 'rm -rf "${work}"' 0 HUP INT TERM
|
||||||
|
archive=${work}/go.tar.gz
|
||||||
|
curl -fsSL --connect-timeout 15 --max-time 90 --retry 2 --retry-delay 2 \
|
||||||
|
-o "${archive}" "https://go.dev/dl/go${version}.linux-${dl_arch}.tar.gz"
|
||||||
|
printf '%s %s\n' "${go_sha}" "${archive}" | sha256sum -c -
|
||||||
|
tar -xzf "${archive}" -C "${work}"
|
||||||
|
test -d "${work}/go"
|
||||||
|
mv "${work}/go" "${root}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
verify
|
||||||
|
publish
|
||||||
|
touch "${marker}"
|
||||||
101
testing/tests/test_hermes_execution_pool_worker_go.py
Normal file
101
testing/tests/test_hermes_execution_pool_worker_go.py
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
"""Pinned Go bootstrap contracts for isolated Hermes execution workers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).parents[2]
|
||||||
|
HERMES = ROOT / "services/hermes"
|
||||||
|
INSTALLER = HERMES / "scripts/install_worker_go.sh"
|
||||||
|
|
||||||
|
|
||||||
|
def _write_executable(path: Path, text: str) -> None:
|
||||||
|
path.write_text(text)
|
||||||
|
path.chmod(0o755)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_installer(tmp_path: Path, architecture: str, download_arch: str) -> Path:
|
||||||
|
"""Run the installer against fake verified archive tools without network access."""
|
||||||
|
tools, mock = tmp_path / "tools", tmp_path / "mock"
|
||||||
|
mock.mkdir()
|
||||||
|
curl_log = tmp_path / "curl.log"
|
||||||
|
_write_executable(mock / "uname", '#!/bin/sh\nprintf "%s\\n" "$TEST_ARCH"\n')
|
||||||
|
_write_executable(
|
||||||
|
mock / "curl",
|
||||||
|
"""#!/bin/sh
|
||||||
|
last=""
|
||||||
|
destination=""
|
||||||
|
previous=""
|
||||||
|
for argument in "$@"; do
|
||||||
|
if [ "$previous" = "-o" ]; then destination=$argument; fi
|
||||||
|
previous=$argument
|
||||||
|
last=$argument
|
||||||
|
done
|
||||||
|
printf "%s" "$last" > "$CURL_LOG"
|
||||||
|
printf archive > "$destination"
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
_write_executable(mock / "sha256sum", '#!/bin/sh\ncat >/dev/null\n')
|
||||||
|
_write_executable(
|
||||||
|
mock / "tar",
|
||||||
|
'''#!/bin/sh
|
||||||
|
while [ "$#" -gt 0 ]; do
|
||||||
|
if [ "$1" = "-C" ]; then work=$2; shift 2; continue; fi
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
mkdir -p "$work/go/bin"
|
||||||
|
printf '#!/bin/sh\\nprintf "go version go1.26.5 linux/%%s\\\\n" "$TEST_DL_ARCH"\\n' > "$work/go/bin/go"
|
||||||
|
printf '#!/bin/sh\\n[ "$1" = "-w" ] && [ -f "$2" ]\\n' > "$work/go/bin/gofmt"
|
||||||
|
chmod 0755 "$work/go/bin/go" "$work/go/bin/gofmt"
|
||||||
|
''',
|
||||||
|
)
|
||||||
|
root = tools / f"go-1.26.5-{download_arch}"
|
||||||
|
(root / "bin").mkdir(parents=True)
|
||||||
|
_write_executable(root / "bin/go", "#!/bin/sh\nexit 1\n")
|
||||||
|
environment = {
|
||||||
|
**os.environ,
|
||||||
|
"PATH": f"{mock}:/usr/bin:/bin",
|
||||||
|
"HERMES_WORKER_TOOLS_DIR": str(tools),
|
||||||
|
"TEST_ARCH": architecture,
|
||||||
|
"TEST_DL_ARCH": download_arch,
|
||||||
|
"CURL_LOG": str(curl_log),
|
||||||
|
}
|
||||||
|
subprocess.run(["/bin/sh", str(INSTALLER)], env=environment, check=True)
|
||||||
|
assert curl_log.read_text().endswith(f"go1.26.5.linux-{download_arch}.tar.gz")
|
||||||
|
assert (tools / f".worker-go-toolchain-1.26.5-{download_arch}").is_file()
|
||||||
|
assert subprocess.check_output(
|
||||||
|
[tools / "bin/go", "version"], text=True, env=environment
|
||||||
|
).strip() == f"go version go1.26.5 linux/{download_arch}"
|
||||||
|
subprocess.run([tools / "bin/gofmt", "-w", str(tmp_path / "formatted.go")], check=False)
|
||||||
|
return tools
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("architecture", "download_arch"), [("aarch64", "arm64"), ("x86_64", "amd64")])
|
||||||
|
def test_worker_go_installer_recovers_incomplete_arch_cache(tmp_path, architecture, download_arch):
|
||||||
|
"""An incomplete cached tree is replaced only with the verified native archive."""
|
||||||
|
tools = _run_installer(tmp_path, architecture, download_arch)
|
||||||
|
assert (tools / f"go-1.26.5-{download_arch}/bin/gofmt").is_file()
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_manifest_installs_only_pinned_go_on_writable_tools_claim():
|
||||||
|
"""The worker gets Go before model binaries without operator credentials or mounts."""
|
||||||
|
document = yaml.safe_load(HERMES.joinpath("execution-worker-statefulset.yaml").read_text())
|
||||||
|
init = next(item for item in document["spec"]["template"]["spec"]["initContainers"] if item["name"] == "install-worker-go")
|
||||||
|
assert init["command"] == [
|
||||||
|
"/bin/sh", "-ec", "timeout 300 /bin/sh /opt/coordinator/install_worker_go.sh"
|
||||||
|
]
|
||||||
|
assert {mount["name"] for mount in init["volumeMounts"]} == {"tools", "coordinator"}
|
||||||
|
assert not next(mount for mount in init["volumeMounts"] if mount["name"] == "tools").get("readOnly", False)
|
||||||
|
assert next(mount for mount in init["volumeMounts"] if mount["name"] == "coordinator")["readOnly"] is True
|
||||||
|
script = INSTALLER.read_text()
|
||||||
|
assert "sha256sum -c -" in script and "go${version}.linux-${dl_arch}.tar.gz" in script
|
||||||
|
assert "version=1.26.5" in script
|
||||||
|
assert 'timeout 15 "${root}/bin/go" version' in script
|
||||||
|
assert 'timeout 15 "${root}/bin/gofmt" -w' in script
|
||||||
|
assert "--connect-timeout 15 --max-time 90 --retry 2 --retry-delay 2" in script
|
||||||
@ -319,16 +319,34 @@ def test_readiness_checks_ordinal_paths_credentials_and_mediator(tmp_path, monke
|
|||||||
claude_token.parent.mkdir()
|
claude_token.parent.mkdir()
|
||||||
claude_token.write_text("setup-token")
|
claude_token.write_text("setup-token")
|
||||||
schema = tmp_path / "schema/result.json"
|
schema = tmp_path / "schema/result.json"
|
||||||
|
claude_bin = tmp_path / "tools/claude"
|
||||||
|
native_claude = tmp_path / "tools/claude-native"
|
||||||
|
codex_bin = tmp_path / "tools/codex"
|
||||||
|
claude_bin.parent.mkdir()
|
||||||
|
for binary in (claude_bin, native_claude):
|
||||||
|
binary.write_text("#!/bin/sh\nexit 0\n")
|
||||||
|
binary.chmod(0o755)
|
||||||
monkeypatch.setattr(worker, "ORDINAL", 0)
|
monkeypatch.setattr(worker, "ORDINAL", 0)
|
||||||
monkeypatch.setattr(worker, "ROOT", worker_root)
|
monkeypatch.setattr(worker, "ROOT", worker_root)
|
||||||
monkeypatch.setattr(worker.cli_lane_runner, "DATA_ROOT", data_root)
|
monkeypatch.setattr(worker.cli_lane_runner, "DATA_ROOT", data_root)
|
||||||
monkeypatch.setattr(worker.cli_lane_runner, "RESULT_SCHEMA_PATH", schema)
|
monkeypatch.setattr(worker.cli_lane_runner, "RESULT_SCHEMA_PATH", schema)
|
||||||
|
monkeypatch.setattr(worker.cli_lane_runner, "CLAUDE_BIN", claude_bin)
|
||||||
|
monkeypatch.setattr(worker.cli_lane_runner, "CODEX_BIN", codex_bin)
|
||||||
|
monkeypatch.setattr(worker, "DISABLED_PROVIDER", "codex")
|
||||||
|
monkeypatch.setenv("HERMES_CLAUDE_NATIVE_BIN", str(native_claude))
|
||||||
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN_FILE", str(claude_token))
|
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN_FILE", str(claude_token))
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
worker, "_poll", lambda: pytest.fail("readiness must not poll the mediator")
|
worker, "_poll", lambda: pytest.fail("readiness must not poll the mediator")
|
||||||
)
|
)
|
||||||
worker.readiness()
|
worker.readiness()
|
||||||
assert json.loads(schema.read_text()) == worker.cli_lane_runner.RESULT_SCHEMA
|
assert json.loads(schema.read_text()) == worker.cli_lane_runner.RESULT_SCHEMA
|
||||||
|
monkeypatch.setattr(worker, "DISABLED_PROVIDER", "")
|
||||||
|
with pytest.raises(protocol.ProtocolError, match="provider executable.*codex"):
|
||||||
|
worker.readiness()
|
||||||
|
codex_bin.write_text("#!/bin/sh\nexit 0\n")
|
||||||
|
codex_bin.chmod(0o755)
|
||||||
|
worker.readiness()
|
||||||
|
monkeypatch.setattr(worker, "DISABLED_PROVIDER", "codex")
|
||||||
|
|
||||||
monkeypatch.setattr(worker, "ORDINAL", 3)
|
monkeypatch.setattr(worker, "ORDINAL", 3)
|
||||||
with pytest.raises(protocol.ProtocolError, match="ordinal"):
|
with pytest.raises(protocol.ProtocolError, match="ordinal"):
|
||||||
@ -338,6 +356,11 @@ def test_readiness_checks_ordinal_paths_credentials_and_mediator(tmp_path, monke
|
|||||||
with pytest.raises(protocol.ProtocolError, match="path"):
|
with pytest.raises(protocol.ProtocolError, match="path"):
|
||||||
worker.readiness()
|
worker.readiness()
|
||||||
monkeypatch.setattr(worker, "ROOT", worker_root)
|
monkeypatch.setattr(worker, "ROOT", worker_root)
|
||||||
|
native_claude.unlink()
|
||||||
|
with pytest.raises(protocol.ProtocolError, match="provider executable.*claude-native"):
|
||||||
|
worker.readiness()
|
||||||
|
native_claude.write_text("#!/bin/sh\nexit 0\n")
|
||||||
|
native_claude.chmod(0o755)
|
||||||
claude_token.unlink()
|
claude_token.unlink()
|
||||||
with pytest.raises(protocol.ProtocolError, match="credential"):
|
with pytest.raises(protocol.ProtocolError, match="credential"):
|
||||||
worker.readiness()
|
worker.readiness()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user