CI / checks (push) Skipped
CI / checks (pull_request) Failing after 55s
execution.SSHHost.execute is now the only way the agent runs anything (L13). Commands travel as a script on SSH's stdin, so the secrets handed to a call are never on a command line, and every value handed out is redacted from the output. Timeouts kill the remote process group, and an unreachable host is reported as such, not as a failed command. Workspace and GitRepo run on the build VM. The clone uses the repository's SSH URL, so no credential is stored in it (#89); fetches and pushes get the push key over stdin, in a short-lived ssh-agent, and PRs are still opened from the agent's host with its API token. The bench runs on the build host too. Tests reach "the build VM" through a throwaway local sshd (nisse_dev .testing); CI installs openssh-server. The live push test now pushes with the sandbox's deploy key (NISSE_PUSH_KEY). Fixes #89. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
102 lines
3.2 KiB
Python
102 lines
3.2 KiB
Python
"""Test support: a throwaway sshd standing in for a tool VM (L13).
|
|
|
|
Runs the system's sshd as the current user, on a free local port, with a
|
|
host key and client key made for the occasion. Tests then reach "the build
|
|
VM" over real SSH, so the code path under test is the production path.
|
|
"""
|
|
|
|
import getpass
|
|
import os
|
|
import socket
|
|
import subprocess
|
|
import time
|
|
from collections.abc import Iterator
|
|
from contextlib import contextmanager
|
|
from pathlib import Path
|
|
|
|
from nisse_dev.execution import SSHHost
|
|
|
|
SSHD = "/usr/sbin/sshd"
|
|
|
|
|
|
def _free_port() -> int:
|
|
with socket.socket() as sock:
|
|
sock.bind(("127.0.0.1", 0))
|
|
return int(sock.getsockname()[1])
|
|
|
|
|
|
def _keygen(path: Path) -> None:
|
|
subprocess.run(
|
|
["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(path)], check=True
|
|
)
|
|
|
|
|
|
@contextmanager
|
|
def local_sshd(root: Path) -> Iterator[SSHHost]:
|
|
"""An SSHHost for a private sshd on 127.0.0.1, torn down afterwards."""
|
|
root.mkdir(parents=True, exist_ok=True)
|
|
host_key, client_key = root / "host_key", root / "client_key"
|
|
_keygen(host_key)
|
|
_keygen(client_key)
|
|
authorized = root / "authorized_keys"
|
|
authorized.write_text(client_key.with_suffix(".pub").read_text())
|
|
authorized.chmod(0o600)
|
|
port = _free_port()
|
|
config = root / "sshd_config"
|
|
config.write_text(
|
|
"\n".join(
|
|
[
|
|
f"Port {port}",
|
|
"ListenAddress 127.0.0.1",
|
|
f"HostKey {host_key}",
|
|
f"AuthorizedKeysFile {authorized}",
|
|
f"PidFile {root / 'sshd.pid'}",
|
|
"PasswordAuthentication no",
|
|
"KbdInteractiveAuthentication no",
|
|
"PubkeyAuthentication yes",
|
|
"PermitRootLogin prohibit-password",
|
|
"StrictModes no",
|
|
"UsePAM no",
|
|
"MaxSessions 50",
|
|
"MaxStartups 50",
|
|
# Stands in for a provisioned build VM: sessions see the test
|
|
# runner's PATH, so its Python and tools are "installed".
|
|
f"SetEnv PATH={os.environ.get('PATH', '/usr/bin:/bin')}",
|
|
"",
|
|
]
|
|
)
|
|
)
|
|
process = subprocess.Popen(
|
|
[SSHD, "-D", "-e", "-f", str(config)],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.PIPE,
|
|
)
|
|
user = getpass.getuser()
|
|
host = SSHHost(
|
|
"127.0.0.1", key=client_key, state_dir=root / "client", user=user, port=port
|
|
)
|
|
try:
|
|
deadline = time.monotonic() + 10
|
|
while True:
|
|
if process.poll() is not None:
|
|
raise RuntimeError(
|
|
"sshd exited: " + (process.stderr.read() or b"").decode()
|
|
if process.stderr
|
|
else "sshd exited"
|
|
)
|
|
try:
|
|
with socket.create_connection(("127.0.0.1", port), timeout=0.5):
|
|
break
|
|
except OSError:
|
|
if time.monotonic() > deadline:
|
|
raise RuntimeError("sshd did not start") from None
|
|
time.sleep(0.05)
|
|
yield host
|
|
finally:
|
|
host.close()
|
|
process.terminate()
|
|
try:
|
|
process.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
process.kill()
|