660 lines
24 KiB
Python
660 lines
24 KiB
Python
"""Unit: smolmachines provisioning helpers (PRD 0023 chunks 4a + 4d).
|
|
|
|
Tests mock `bottle.exec` / `bottle.cp_in` and assert on the
|
|
dispatched script shape. The real round-trip lives in the chunk-4
|
|
integration smoke."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import subprocess
|
|
import tempfile
|
|
import unittest
|
|
from contextlib import ExitStack
|
|
from dataclasses import replace
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from bot_bottle.agent_provider import (
|
|
AgentProvider,
|
|
AgentProviderRuntime,
|
|
AgentProvisionCommand,
|
|
AgentProvisionDir,
|
|
AgentProvisionFile,
|
|
AgentProvisionPlan,
|
|
)
|
|
from bot_bottle.backend import Bottle, BottleSpec, ExecResult
|
|
from bot_bottle.backend.smolmachines.bottle import SmolmachinesBottle
|
|
from bot_bottle.backend.smolmachines.bottle_plan import (
|
|
SmolmachinesBottlePlan,
|
|
)
|
|
from bot_bottle.backend.smolmachines import launch as _launch
|
|
from bot_bottle.backend.smolmachines import port_forward as _forward
|
|
from bot_bottle.backend.smolmachines.launch import _bundle_launch_spec
|
|
from bot_bottle.backend.util import AGENT_CA_PATH
|
|
from bot_bottle.egress import EgressPlan, EgressRoute
|
|
from bot_bottle.git_gate import GitGatePlan, GitGateUpstream
|
|
from bot_bottle.manifest import ManifestGitEntry, ManifestKeyConfig, ManifestIndex
|
|
from bot_bottle.supervise import SupervisePlan
|
|
|
|
|
|
class _Provider(AgentProvider):
|
|
"""Minimal concrete subclass for testing the default provision_ca/provision_git."""
|
|
@property
|
|
def runtime(self) -> AgentProviderRuntime:
|
|
return AgentProviderRuntime(
|
|
template="test", command="test", image="",
|
|
prompt_mode="append_file", bypass_args=(), resume_args=(),
|
|
)
|
|
def provision_plan(self, **kwargs): # type: ignore[override]
|
|
raise NotImplementedError
|
|
def provision_skills(self, plan, bottle): ... # type: ignore[override]
|
|
def provision_prompt(self, plan, bottle): ... # type: ignore[override]
|
|
def provision(self, plan, bottle): ... # type: ignore[override]
|
|
def provision_supervise_mcp(self, plan, bottle, supervise_url): ... # type: ignore[override]
|
|
def headless_prompt(self, prompt): return [] # type: ignore[override]
|
|
|
|
|
|
_PROVIDER = _Provider()
|
|
|
|
|
|
def _make_bottle(
|
|
name: str = "bot-bottle-demo-abc12",
|
|
exec_result: ExecResult | None = None,
|
|
) -> MagicMock:
|
|
bottle = MagicMock(spec=Bottle)
|
|
bottle.name = name
|
|
bottle.exec.return_value = (
|
|
exec_result if exec_result is not None
|
|
else ExecResult(returncode=0, stdout="", stderr="")
|
|
)
|
|
return bottle
|
|
|
|
|
|
def _exec_users(bottle: MagicMock) -> list[str]: # type: ignore
|
|
"""user= kwarg from each bottle.exec call, in order."""
|
|
return [c.kwargs.get("user", "node") for c in bottle.exec.call_args_list]
|
|
|
|
|
|
def _plan(
|
|
*,
|
|
agent_prompt: str = "",
|
|
skills: list[str] | None = None,
|
|
git: list[ManifestGitEntry] = (), # type: ignore
|
|
git_user: dict | None = None, # type: ignore
|
|
copy_cwd: bool = False,
|
|
user_cwd: str = "/tmp/x",
|
|
stage_dir: Path | None = None,
|
|
egress_routes: tuple[EgressRoute, ...] = (),
|
|
egress_ca_path: Path = Path(),
|
|
canary: bool = False,
|
|
supervise: bool = False,
|
|
bundle_ip: str = "192.168.50.2",
|
|
agent_git_gate_host: str = "127.0.0.1:55555",
|
|
agent_supervise_url: str = "http://127.0.0.1:55556/",
|
|
codex_auth_file: Path | None = None,
|
|
agent_provider_template: str = "claude",
|
|
guest_env: dict[str, str] | None = None,
|
|
) -> SmolmachinesBottlePlan:
|
|
bottle_json: dict = {} # type: ignore
|
|
git_gate_json: dict = {} # type: ignore
|
|
if git:
|
|
git_gate_json["repos"] = {
|
|
g.Name: {
|
|
"url": g.Upstream,
|
|
"key": {"provider": g.Key.provider or "static", "path": g.Key.path or g.IdentityFile},
|
|
}
|
|
for g in git
|
|
}
|
|
if git_user is not None:
|
|
git_gate_json["user"] = git_user
|
|
if git_gate_json:
|
|
bottle_json["git-gate"] = git_gate_json
|
|
if supervise:
|
|
bottle_json["supervise"] = True
|
|
index = ManifestIndex.from_json_obj({
|
|
"bottles": {"dev": bottle_json},
|
|
"agents": {
|
|
"demo": {
|
|
"skills": list(skills or []),
|
|
"prompt": agent_prompt,
|
|
"bottle": "dev",
|
|
},
|
|
},
|
|
})
|
|
manifest = index.load_for_agent("demo")
|
|
spec = BottleSpec(
|
|
manifest=index,
|
|
agent_name="demo",
|
|
copy_cwd=copy_cwd,
|
|
user_cwd=user_cwd,
|
|
)
|
|
supervise_plan = None
|
|
if supervise:
|
|
supervise_plan = SupervisePlan(
|
|
slug="demo-abc12",
|
|
db_path=Path("/tmp/bot-bottle.db"),
|
|
)
|
|
return SmolmachinesBottlePlan(
|
|
spec=spec,
|
|
manifest=manifest,
|
|
stage_dir=stage_dir or Path("/tmp/stage"),
|
|
slug="demo-abc12",
|
|
bundle_subnet="192.168.50.0/24",
|
|
bundle_gateway="192.168.50.1",
|
|
bundle_ip=bundle_ip,
|
|
guest_env=dict(guest_env or {}),
|
|
git_gate_plan=GitGatePlan(
|
|
slug="demo-abc12",
|
|
entrypoint_script=Path("/tmp/git-gate-entrypoint.sh"),
|
|
hook_script=Path("/tmp/git-gate-hook"),
|
|
access_hook_script=Path("/tmp/git-gate-access-hook"),
|
|
upstreams=(),
|
|
),
|
|
egress_plan=EgressPlan(
|
|
slug="demo-abc12",
|
|
routes_path=Path("/tmp/routes.yaml"),
|
|
routes=egress_routes,
|
|
token_env_map={},
|
|
mitmproxy_ca_cert_only_host_path=egress_ca_path,
|
|
canary="fake-canary-value" if canary else "",
|
|
canary_env="CANON_ALPHA_SECRET" if canary else "",
|
|
),
|
|
supervise_plan=supervise_plan,
|
|
agent_git_gate_host=agent_git_gate_host,
|
|
agent_supervise_url=agent_supervise_url,
|
|
agent_provision=_agent_provision(
|
|
agent_provider_template,
|
|
codex_auth_file=codex_auth_file,
|
|
guest_env=dict(guest_env or {}),
|
|
),
|
|
)
|
|
|
|
|
|
def _agent_provision(
|
|
template: str,
|
|
*,
|
|
codex_auth_file: Path | None = None,
|
|
guest_env: dict[str, str] | None = None,
|
|
) -> AgentProvisionPlan:
|
|
if template != "codex":
|
|
return AgentProvisionPlan(
|
|
template=template,
|
|
command=template,
|
|
prompt_mode="append_file",
|
|
image="bot-bottle-claude:latest",
|
|
dockerfile="",
|
|
guest_home="/home/node",
|
|
instance_name="bot-bottle-demo-abc12",
|
|
prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"),
|
|
guest_env=dict(guest_env or {}),
|
|
)
|
|
auth_dir = (guest_env or {}).get("CODEX_HOME", "/home/node/.codex")
|
|
files = [
|
|
AgentProvisionFile(
|
|
Path("/tmp/codex-config.toml"),
|
|
f"{auth_dir}/config.toml",
|
|
),
|
|
]
|
|
pre_copy: tuple[AgentProvisionCommand, ...] = ()
|
|
verify: tuple[AgentProvisionCommand, ...] = ()
|
|
if codex_auth_file is not None:
|
|
files.append(AgentProvisionFile(codex_auth_file, f"{auth_dir}/auth.json"))
|
|
pre_copy = (AgentProvisionCommand((
|
|
"find", auth_dir,
|
|
"-maxdepth", "1",
|
|
"-type", "f",
|
|
"(",
|
|
"-name", "*.sqlite",
|
|
"-o", "-name", "*.sqlite-*",
|
|
"-o", "-name", "*.codex-repair-*.bak",
|
|
")",
|
|
"-delete",
|
|
), "codex host credentials: could not reset runtime db files"),)
|
|
verify = (AgentProvisionCommand((
|
|
"runuser", "-u", "node", "--",
|
|
"env",
|
|
"HOME=/home/node",
|
|
f"CODEX_HOME={auth_dir}",
|
|
"codex", "login", "status",
|
|
), "codex host credentials: dummy auth was copied into the guest"),)
|
|
return AgentProvisionPlan(
|
|
template="codex",
|
|
command="codex",
|
|
prompt_mode="read_prompt_file",
|
|
image="bot-bottle-codex:latest",
|
|
dockerfile="",
|
|
guest_home="/home/node",
|
|
instance_name="bot-bottle-demo-abc12",
|
|
prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"),
|
|
guest_env=dict(guest_env or {}),
|
|
dirs=(AgentProvisionDir(auth_dir),),
|
|
files=tuple(files),
|
|
pre_copy=pre_copy,
|
|
verify=verify,
|
|
)
|
|
|
|
|
|
def _write_self_signed_cert(path: Path) -> None:
|
|
"""Drop a real self-signed PEM at `path` so provision_ca's
|
|
fingerprint computation (PEM_cert_to_DER_cert + sha256) has
|
|
actual bytes to chew on. Generated once per test via openssl."""
|
|
subprocess.run(
|
|
["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes",
|
|
"-keyout", "/dev/null",
|
|
"-out", str(path),
|
|
"-days", "1",
|
|
"-subj", "/CN=test"],
|
|
check=True, capture_output=True,
|
|
)
|
|
|
|
|
|
class TestProvisionCA(unittest.TestCase):
|
|
"""provision_ca always uses the egress MITM CA and dispatches
|
|
cp_in + exec in the right order."""
|
|
|
|
def setUp(self):
|
|
self._tmp = tempfile.TemporaryDirectory(prefix="cb-prov-ca.") # pylint: disable=consider-using-with
|
|
self.tmp = Path(self._tmp.name)
|
|
self.egress_ca = self.tmp / "egress-ca.pem"
|
|
_write_self_signed_cert(self.egress_ca)
|
|
|
|
def tearDown(self):
|
|
self._tmp.cleanup()
|
|
|
|
# provision_ca dies hard if update-ca-certificates' exit
|
|
# is non-zero; supply a stock success return so the bulk of
|
|
# the tests below exercise the happy path.
|
|
_UPDATE_OK = ExecResult(
|
|
returncode=0,
|
|
stdout="Updating certificates in /etc/ssl/certs...\n1 added, 0 removed; done.\n",
|
|
stderr="",
|
|
)
|
|
|
|
def test_egress_ca_always_installed(self):
|
|
plan = _plan(egress_ca_path=self.egress_ca)
|
|
bottle = _make_bottle(exec_result=self._UPDATE_OK)
|
|
_PROVIDER.provision_ca(bottle, plan)
|
|
bottle.cp_in.assert_called_once_with(
|
|
str(self.egress_ca),
|
|
AGENT_CA_PATH,
|
|
)
|
|
self.assertEqual(2, bottle.exec.call_count)
|
|
script = bottle.exec.call_args_list[1].args[0]
|
|
self.assertIn("chmod 644", script)
|
|
self.assertIn("update-ca-certificates", script)
|
|
self.assertEqual("root", bottle.exec.call_args.kwargs.get("user"))
|
|
|
|
def test_dies_when_egress_cert_missing(self):
|
|
plan = _plan(egress_ca_path=self.tmp / "does-not-exist.pem")
|
|
bottle = _make_bottle()
|
|
with self.assertRaises(SystemExit):
|
|
_PROVIDER.provision_ca(bottle, plan)
|
|
|
|
|
|
class TestSmolmachinesBottleExec(unittest.TestCase):
|
|
"""SmolmachinesBottle.exec retries once on SIGKILL (exit 137)."""
|
|
|
|
_SIGKILL = subprocess.CompletedProcess(
|
|
args=[], returncode=137, stdout="", stderr="",
|
|
)
|
|
_SUCCESS = subprocess.CompletedProcess(
|
|
args=[], returncode=0, stdout="done", stderr="",
|
|
)
|
|
|
|
def test_retries_on_sigkill(self):
|
|
bottle = SmolmachinesBottle("test-machine")
|
|
with patch(
|
|
"bot_bottle.backend.smolmachines.bottle.subprocess.run",
|
|
side_effect=[self._SIGKILL, self._SUCCESS],
|
|
) as mock_run, patch(
|
|
"bot_bottle.backend.smolmachines.bottle.time.sleep"
|
|
) as mock_sleep:
|
|
result = bottle.exec("echo hi")
|
|
|
|
self.assertEqual(0, result.returncode)
|
|
self.assertEqual(2, mock_run.call_count)
|
|
mock_sleep.assert_called_once_with(1.0)
|
|
|
|
def test_no_retry_on_success(self):
|
|
bottle = SmolmachinesBottle("test-machine")
|
|
with patch(
|
|
"bot_bottle.backend.smolmachines.bottle.subprocess.run",
|
|
return_value=self._SUCCESS,
|
|
) as mock_run:
|
|
result = bottle.exec("echo hi")
|
|
|
|
self.assertEqual(0, result.returncode)
|
|
self.assertEqual(1, mock_run.call_count)
|
|
|
|
def test_no_retry_on_other_error(self):
|
|
fail = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="err")
|
|
bottle = SmolmachinesBottle("test-machine")
|
|
with patch(
|
|
"bot_bottle.backend.smolmachines.bottle.subprocess.run",
|
|
return_value=fail,
|
|
) as mock_run:
|
|
result = bottle.exec("bad-cmd")
|
|
|
|
self.assertEqual(1, result.returncode)
|
|
self.assertEqual(1, mock_run.call_count)
|
|
|
|
|
|
class TestProvisionGit(unittest.TestCase):
|
|
"""provision_git writes gitconfig insteadOf rules when configured."""
|
|
|
|
def setUp(self):
|
|
self._tmp = tempfile.TemporaryDirectory(prefix="cb-prov-git.") # pylint: disable=consider-using-with
|
|
self.stage = Path(self._tmp.name)
|
|
|
|
def tearDown(self):
|
|
self._tmp.cleanup()
|
|
|
|
def test_noop_when_no_cwd_and_no_git_entries(self):
|
|
bottle = _make_bottle()
|
|
_PROVIDER.provision_git(bottle, _plan(stage_dir=self.stage))
|
|
bottle.cp_in.assert_not_called()
|
|
bottle.exec.assert_not_called()
|
|
|
|
def test_writes_gitconfig_with_ip_port_form_for_smolmachines(self):
|
|
# Smolmachines's TSI-allowlisted guest dials git-gate via
|
|
# smart HTTP at `127.0.0.1:<host port>` — the bundle's
|
|
# git HTTP port is published on host loopback at launch
|
|
# time, and the plan carries the discovered host port.
|
|
plan = _plan(
|
|
git=[ManifestGitEntry(
|
|
Name="bot-bottle",
|
|
Upstream="ssh://git@host/repo.git",
|
|
Key=ManifestKeyConfig(provider="static", path="~/.ssh/id_ed25519"),
|
|
IdentityFile="~/.ssh/id_ed25519",
|
|
)],
|
|
stage_dir=self.stage,
|
|
agent_git_gate_host="127.0.0.1:9418",
|
|
)
|
|
bottle = _make_bottle()
|
|
_PROVIDER.provision_git(bottle, plan)
|
|
# The staged gitconfig path is whatever NamedTemporaryFile
|
|
# picked; we read its contents.
|
|
cp_call = bottle.cp_in.call_args
|
|
staged_path = Path(cp_call.args[0])
|
|
self.assertEqual(self.stage, staged_path.parent)
|
|
content = staged_path.read_text(encoding="utf-8")
|
|
self.assertIn(
|
|
'[url "http://127.0.0.1:9418/bot-bottle.git"]', content,
|
|
)
|
|
self.assertIn(
|
|
"\tinsteadOf = ssh://git@host/repo.git", content,
|
|
)
|
|
|
|
|
|
class TestBundleLaunchSpec(unittest.TestCase):
|
|
def test_git_gate_uses_http_daemon_for_smolmachines(self):
|
|
plan = _plan()
|
|
plan = replace(
|
|
plan,
|
|
git_gate_plan=replace(
|
|
plan.git_gate_plan,
|
|
upstreams=(GitGateUpstream(
|
|
name="bot-bottle",
|
|
upstream_url="ssh://git@host/repo.git",
|
|
upstream_host="host",
|
|
upstream_port="22",
|
|
identity_file="/tmp/key",
|
|
known_host_key="",
|
|
),),
|
|
),
|
|
)
|
|
|
|
spec = _bundle_launch_spec(plan, "net", "127.0.0.16")
|
|
|
|
self.assertEqual(
|
|
"egress,git-gate,git-http",
|
|
spec.daemons_csv,
|
|
)
|
|
self.assertIn(9420, spec.ports_to_publish)
|
|
self.assertNotIn(9418, spec.ports_to_publish)
|
|
|
|
def test_canary_env_registered_as_sensitive_in_bundle(self):
|
|
plan = _plan(canary=True)
|
|
|
|
spec = _bundle_launch_spec(plan, "net", "127.0.0.16")
|
|
|
|
self.assertIn("CANON_ALPHA_SECRET=fake-canary-value", spec.environment)
|
|
self.assertIn(
|
|
"BOT_BOTTLE_SENSITIVE_PREFIXES=CANON_ALPHA_SECRET",
|
|
spec.environment,
|
|
)
|
|
|
|
def test_supervise_adds_daemon_volume_and_env(self):
|
|
from bot_bottle.supervise import DB_PATH_IN_CONTAINER
|
|
plan = _plan(supervise=True)
|
|
spec = _bundle_launch_spec(plan, "net", "127.0.0.16")
|
|
self.assertIn("supervise", spec.daemons_csv)
|
|
self.assertIn(f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}", spec.environment)
|
|
self.assertIn(("/tmp/bot-bottle.db", DB_PATH_IN_CONTAINER, False), spec.volumes)
|
|
|
|
def test_canary_env_visible_to_smolvm_guest(self):
|
|
plan = _plan(canary=True)
|
|
stamped = _launch._discover_urls(plan, "127.0.0.16", {9099: 65000})
|
|
|
|
self.assertEqual(
|
|
"fake-canary-value",
|
|
stamped.guest_env["CANON_ALPHA_SECRET"],
|
|
)
|
|
|
|
|
|
class TestProvisionGitUser(unittest.TestCase):
|
|
"""`provision_git` runs `git config --global` inside the
|
|
guest as the node user. SmolmachinesBottle.exec sets HOME and
|
|
USER automatically for the requested user, so --global lands
|
|
in /home/node/.gitconfig. No-op when the bottle didn't declare
|
|
git_user (issue #86)."""
|
|
|
|
def _git_config_calls(self, bottle: MagicMock) -> list[tuple[str, str]]:
|
|
"""Filter bottle.exec calls down to git-config invocations,
|
|
return list of (script, user) tuples."""
|
|
out = []
|
|
for c in bottle.exec.call_args_list:
|
|
script = c.args[0] if c.args else ""
|
|
user = c.kwargs.get("user", "node")
|
|
if "git config" in script:
|
|
out.append((script, user))
|
|
return out
|
|
|
|
def test_noop_when_no_git_user(self):
|
|
bottle = _make_bottle()
|
|
_PROVIDER.provision_git(bottle, _plan())
|
|
self.assertEqual([], self._git_config_calls(bottle))
|
|
|
|
def test_sets_name_and_email_as_node(self):
|
|
plan = _plan(git_user={
|
|
"name": "Eric Bauerfeld",
|
|
"email": "eric@dideric.is",
|
|
})
|
|
bottle = _make_bottle()
|
|
_PROVIDER.provision_git(bottle, plan)
|
|
calls = self._git_config_calls(bottle)
|
|
self.assertEqual(2, len(calls))
|
|
# Both run as node so SmolmachinesBottle.exec sets HOME=/home/node
|
|
# automatically, ensuring --global writes to /home/node/.gitconfig.
|
|
for script, user in calls:
|
|
self.assertEqual("node", user)
|
|
self.assertIn("git config --global", script)
|
|
self.assertIn("user.name", calls[0][0])
|
|
self.assertIn("Eric Bauerfeld", calls[0][0])
|
|
self.assertIn("user.email", calls[1][0])
|
|
self.assertIn("eric@dideric.is", calls[1][0])
|
|
|
|
def test_name_only(self):
|
|
plan = _plan(git_user={"name": "Bot"})
|
|
bottle = _make_bottle()
|
|
_PROVIDER.provision_git(bottle, plan)
|
|
calls = self._git_config_calls(bottle)
|
|
self.assertEqual(1, len(calls))
|
|
self.assertIn("user.name", calls[0][0])
|
|
self.assertIn("Bot", calls[0][0])
|
|
|
|
def test_email_only(self):
|
|
plan = _plan(git_user={"email": "bot@example.com"})
|
|
bottle = _make_bottle()
|
|
_PROVIDER.provision_git(bottle, plan)
|
|
calls = self._git_config_calls(bottle)
|
|
self.assertEqual(1, len(calls))
|
|
self.assertIn("user.email", calls[0][0])
|
|
self.assertIn("bot@example.com", calls[0][0])
|
|
|
|
|
|
class TestProxyHost(unittest.TestCase):
|
|
"""_proxy_host returns the per-bottle address used by the forwarder."""
|
|
|
|
def test_returns_loopback_alias(self):
|
|
plan = _plan()
|
|
result = _launch._proxy_host(plan, "127.0.0.16")
|
|
self.assertEqual("127.0.0.16", result)
|
|
|
|
|
|
class TestLaunchResourceWiring(unittest.TestCase):
|
|
def test_allocate_resources_uses_loopback_alias_and_bundle_name(self):
|
|
plan = _plan()
|
|
with patch(
|
|
"bot_bottle.backend.smolmachines.launch._loopback.ensure_pool",
|
|
) as ensure_pool, patch(
|
|
"bot_bottle.backend.smolmachines.launch._loopback.allocate",
|
|
return_value="127.0.0.16",
|
|
) as allocate:
|
|
loopback_ip, network = _launch._allocate_resources(plan, ExitStack())
|
|
|
|
ensure_pool.assert_called_once_with()
|
|
allocate.assert_called_once_with("demo-abc12")
|
|
self.assertEqual("127.0.0.16", loopback_ip)
|
|
self.assertEqual("bot-bottle-bundle-demo-abc12", network)
|
|
|
|
def test_start_bundle_wraps_raw_vm_ports_with_forwarder_ports(self):
|
|
plan = _plan(supervise=True)
|
|
plan = replace(
|
|
plan,
|
|
git_gate_plan=replace(
|
|
plan.git_gate_plan,
|
|
upstreams=(GitGateUpstream(
|
|
name="bot-bottle",
|
|
upstream_url="ssh://git@host/repo.git",
|
|
upstream_host="host",
|
|
upstream_port="22",
|
|
identity_file="/tmp/key",
|
|
known_host_key="",
|
|
),),
|
|
),
|
|
)
|
|
stack = MagicMock(spec=ExitStack)
|
|
raw_launch = MagicMock(raw_ports={9099: 55001, 9420: 55002, 9100: 55003})
|
|
handle = _forward.ForwarderHandle(
|
|
process=MagicMock(),
|
|
forwards=(
|
|
_forward.ForwardSpec("egress", "127.0.0.16", 65001, "127.0.0.1", 55001),
|
|
_forward.ForwardSpec("git-http", "127.0.0.16", 65002, "127.0.0.1", 55002),
|
|
_forward.ForwardSpec("supervise", "127.0.0.16", 65003, "127.0.0.1", 55003),
|
|
),
|
|
)
|
|
|
|
with patch(
|
|
"bot_bottle.backend.smolmachines.launch._provision_git_gate_keys",
|
|
return_value=plan,
|
|
), patch(
|
|
"bot_bottle.backend.smolmachines.launch._ensure_smolmachine",
|
|
return_value=Path("/cache/sidecar.smolmachine"),
|
|
) as ensure, patch(
|
|
"bot_bottle.backend.smolmachines.launch._bundle.start_bundle_vm",
|
|
return_value=raw_launch,
|
|
) as start_vm, patch(
|
|
"bot_bottle.backend.smolmachines.launch._forward.start_forwarder",
|
|
return_value=handle,
|
|
) as start_forwarder:
|
|
stamped = _launch._start_bundle(plan, "net", "127.0.0.16", stack)
|
|
|
|
ensure.assert_called_once()
|
|
start_vm.assert_called_once()
|
|
self.assertEqual(Path("/cache/sidecar.smolmachine"), start_vm.call_args.kwargs["from_path"])
|
|
specs = start_forwarder.call_args.args[0]
|
|
self.assertEqual(
|
|
(
|
|
_forward.ForwardSpec("egress", "127.0.0.16", 0, "127.0.0.1", 55001),
|
|
_forward.ForwardSpec("git-http", "127.0.0.16", 0, "127.0.0.1", 55002),
|
|
_forward.ForwardSpec("supervise", "127.0.0.16", 0, "127.0.0.1", 55003),
|
|
),
|
|
specs,
|
|
)
|
|
self.assertEqual("http://127.0.0.16:65001", stamped.agent_proxy_url)
|
|
self.assertEqual("127.0.0.16:65002", stamped.agent_git_gate_host)
|
|
self.assertEqual("http://127.0.0.16:65003/", stamped.agent_supervise_url)
|
|
self.assertEqual(2, stack.callback.call_count)
|
|
|
|
def test_init_vm_repairs_ownership_then_waits_for_exec(self):
|
|
plan = _plan()
|
|
with patch(
|
|
"bot_bottle.backend.smolmachines.launch._smolvm.machine_exec",
|
|
) as machine_exec, patch(
|
|
"bot_bottle.backend.smolmachines.launch._smolvm.wait_exec_ready",
|
|
) as wait_exec_ready:
|
|
_launch._init_vm(plan)
|
|
|
|
machine_exec.assert_called_once()
|
|
argv = machine_exec.call_args.args[1]
|
|
self.assertEqual(["sh", "-c"], argv[:2])
|
|
self.assertIn("chown -R node:node /home/node", argv[2])
|
|
wait_exec_ready.assert_called_once_with(plan.machine_name)
|
|
|
|
|
|
class TestPortLabels(unittest.TestCase):
|
|
def test_known_and_dynamic_port_labels_round_trip(self):
|
|
for port, label in (
|
|
(9099, "egress"),
|
|
(9420, "git-http"),
|
|
(9100, "supervise"),
|
|
(12345, "port-12345"),
|
|
):
|
|
self.assertEqual(label, _launch._label_for_port(port))
|
|
self.assertEqual(port, _launch._port_for_label(label))
|
|
|
|
def test_unknown_label_is_rejected(self):
|
|
with self.assertRaises(ValueError):
|
|
_launch._port_for_label("sidecar")
|
|
|
|
|
|
class TestDiscoverUrls(unittest.TestCase):
|
|
"""_discover_urls stamps git-gate host + supervise URL into the plan."""
|
|
|
|
def test_git_gate_host_set_when_upstreams_present(self):
|
|
plan = _plan()
|
|
plan = replace(
|
|
plan,
|
|
git_gate_plan=replace(
|
|
plan.git_gate_plan,
|
|
upstreams=(GitGateUpstream(
|
|
name="bot-bottle",
|
|
upstream_url="ssh://git@host/repo.git",
|
|
upstream_host="host",
|
|
upstream_port="22",
|
|
identity_file="/tmp/key",
|
|
known_host_key="",
|
|
),),
|
|
),
|
|
)
|
|
stamped = _launch._discover_urls(
|
|
plan,
|
|
"127.0.0.16",
|
|
{9099: 65000, 9420: 65001},
|
|
)
|
|
self.assertEqual("127.0.0.16:65001", stamped.agent_git_gate_host)
|
|
|
|
def test_supervise_url_set_when_supervise_present(self):
|
|
plan = _plan(supervise=True)
|
|
stamped = _launch._discover_urls(
|
|
plan,
|
|
"127.0.0.16",
|
|
{9099: 65000, 9100: 55556},
|
|
)
|
|
self.assertEqual("http://127.0.0.16:55556/", stamped.agent_supervise_url)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|