fix: make installed wheel self-contained + harden install.sh prereqs
tracker-policy-pr / check-pr (pull_request) Successful in 16s
test / unit (pull_request) Successful in 44s
test / integration-docker (pull_request) Successful in 43s
lint / lint (push) Successful in 1m0s
test / integration-firecracker (pull_request) Successful in 3m54s
test / coverage (pull_request) Successful in 25s
test / publish-infra (pull_request) Has been skipped
tracker-policy-pr / check-pr (pull_request) Successful in 16s
test / unit (pull_request) Successful in 44s
test / integration-docker (pull_request) Successful in 43s
lint / lint (push) Successful in 1m0s
test / integration-firecracker (pull_request) Successful in 3m54s
test / coverage (pull_request) Successful in 25s
test / publish-infra (pull_request) Has been skipped
Addresses the review on PR #481. Self-contained wheel (review point 1): the gateway/infra/orchestrator images build from a context that must hold bot_bottle/, pyproject.toml, and the root-level Dockerfiles. Modules previously located these by walking __file__ to the repo root, so an installed wheel (package in site-packages, no repo root) passed `doctor` but failed `start`. - Add bot_bottle/resources.py: build_root() returns the repo root in a checkout (unchanged) or a staged copy from the wheel's bundled _resources/ otherwise; dockerfile()/nix_netpool_module()/ netpool_script() derive from it. - setup.py bundles the root Dockerfiles, nix module, netpool script, and pyproject.toml into bot_bottle/_resources/ at build; MANIFEST.in ships them in the sdist. - Route every _REPO_ROOT/_REPO_DIR call site (docker/macos launch, macos infra, firecracker infra_vm/infra_artifact/setup, orchestrator lifecycle/gateway) through resources. Checkout behavior is unchanged. install.sh prerequisites (review point 2): check for git when installing a git+ spec, and — before the pip fallback — that pip is usable and the interpreter isn't externally managed (PEP 668), pointing at pipx. Tests: test_resources covers checkout + staged-wheel layouts; test_wheel_install builds the wheel, installs it into an isolated venv, and asserts `doctor` runs and build_root() yields a valid context. Running `start` end-to-end still needs a Docker/KVM host (CI). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -53,6 +53,21 @@ class TestInstallScript(unittest.TestCase):
|
||||
# Tests / local installs point BOT_BOTTLE_INSTALL_SPEC at a checkout.
|
||||
self.assertIn("BOT_BOTTLE_INSTALL_SPEC", self.text)
|
||||
|
||||
def test_requires_git_for_git_specs(self):
|
||||
# A git+ / .git spec (the default) shells out to git; the script must
|
||||
# gate on it rather than failing opaquely inside pipx/pip.
|
||||
self.assertIn("command -v git", self.text)
|
||||
self.assertIn("git+*|*.git", self.text)
|
||||
|
||||
def test_checks_pip_usable_before_fallback(self):
|
||||
self.assertIn("python3 -m pip --version", self.text)
|
||||
|
||||
def test_detects_externally_managed_python(self):
|
||||
# PEP 668: 'pip install --user' is blocked on externally-managed
|
||||
# interpreters; the script must detect this and point at pipx.
|
||||
self.assertIn("EXTERNALLY-MANAGED", self.text)
|
||||
self.assertIn("pipx", self.text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -283,7 +283,7 @@ class TestBuildOrLoadImages(unittest.TestCase):
|
||||
images = launch_mod.build_or_load_images(plan)
|
||||
|
||||
build.assert_called_once_with(
|
||||
"agent:base", launch_mod._REPO_DIR, # pylint: disable=protected-access
|
||||
"agent:base", str(launch_mod.resources.build_root()),
|
||||
dockerfile="/repo/Dockerfile",
|
||||
)
|
||||
derived.assert_called_once_with("agent:base", build)
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Unit: bot_bottle.resources — build-resource resolution for both a source
|
||||
checkout and an installed wheel.
|
||||
|
||||
The checkout path is what the whole test suite already runs under; the wheel
|
||||
path is exercised here by faking an installed layout (a package dir with a
|
||||
bundled ``_resources/`` and no sibling Dockerfiles) and asserting that
|
||||
``build_root()`` stages a repo-root-shaped context. See
|
||||
``test_wheel_install.py`` for the end-to-end build+install check.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from bot_bottle import resources
|
||||
|
||||
from tests.unit import use_bottle_root
|
||||
|
||||
|
||||
class TestCheckoutMode(unittest.TestCase):
|
||||
"""The environment the suite runs in: a real source checkout."""
|
||||
|
||||
def test_is_source_checkout(self):
|
||||
self.assertTrue(resources.is_source_checkout())
|
||||
|
||||
def test_build_root_is_repo_root(self):
|
||||
root = resources.build_root()
|
||||
self.assertTrue((root / "bot_bottle").is_dir())
|
||||
self.assertTrue((root / "pyproject.toml").is_file())
|
||||
self.assertTrue((root / "Dockerfile.gateway").is_file())
|
||||
|
||||
def test_resource_helpers_resolve(self):
|
||||
self.assertTrue(resources.dockerfile("Dockerfile.gateway").is_file())
|
||||
self.assertTrue(resources.nix_netpool_module().is_file())
|
||||
self.assertTrue(resources.netpool_script().is_file())
|
||||
|
||||
def test_bundled_resources_all_exist_at_root(self):
|
||||
# Drift guard: every path setup.py bundles must exist in the checkout.
|
||||
root = resources.build_root()
|
||||
for rel in resources.BUNDLED_RESOURCES:
|
||||
self.assertTrue((root / rel).is_file(), f"missing bundled resource: {rel}")
|
||||
|
||||
|
||||
class TestDistVersion(unittest.TestCase):
|
||||
"""The version key for the staged build root, from importlib.metadata."""
|
||||
|
||||
def test_returns_installed_version(self):
|
||||
import importlib.metadata as md
|
||||
|
||||
with patch.object(md, "version", return_value="9.9.9"):
|
||||
self.assertEqual("9.9.9", resources._dist_version()) # pylint: disable=protected-access
|
||||
|
||||
def test_falls_back_when_not_installed(self):
|
||||
import importlib.metadata as md
|
||||
|
||||
with patch.object(md, "version", side_effect=md.PackageNotFoundError()):
|
||||
self.assertEqual("dev", resources._dist_version()) # pylint: disable=protected-access
|
||||
|
||||
|
||||
class TestWheelMode(unittest.TestCase):
|
||||
"""Fake an installed wheel: a package dir with _resources/ and no
|
||||
checkout Dockerfiles beside it."""
|
||||
|
||||
def _fake_install(self, tmp: Path) -> Path:
|
||||
pkg = tmp / "site-packages" / "bot_bottle"
|
||||
(pkg / "cli").mkdir(parents=True)
|
||||
(pkg / "__init__.py").write_text("")
|
||||
(pkg / "cli" / "__init__.py").write_text("# module\n")
|
||||
bundled = pkg / "_resources"
|
||||
for rel in resources.BUNDLED_RESOURCES:
|
||||
dst = bundled / rel
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
dst.write_text(f"# fake {rel}\n")
|
||||
return pkg
|
||||
|
||||
def test_stage_and_resolve(self):
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpname:
|
||||
tmp = Path(tmpname)
|
||||
pkg = self._fake_install(tmp)
|
||||
appdata = tmp / "appdata"
|
||||
restore = use_bottle_root(appdata)
|
||||
self.addCleanup(restore)
|
||||
with patch.object(resources, "_PKG", pkg), \
|
||||
patch.object(resources, "_BUNDLED", pkg / "_resources"), \
|
||||
patch.object(resources, "_CHECKOUT_ROOT", tmp / "no-checkout"):
|
||||
self.assertFalse(resources.is_source_checkout())
|
||||
|
||||
root = resources.build_root()
|
||||
# Staged context looks like a repo root.
|
||||
self.assertTrue((root / "bot_bottle" / "__init__.py").is_file())
|
||||
self.assertTrue((root / "bot_bottle" / "cli" / "__init__.py").is_file())
|
||||
self.assertTrue((root / "pyproject.toml").is_file())
|
||||
self.assertTrue((root / "Dockerfile.gateway").is_file())
|
||||
self.assertTrue((root / "nix" / "firecracker-netpool.nix").is_file())
|
||||
self.assertTrue((root / "scripts" / "firecracker-netpool.sh").is_file())
|
||||
# The bundled-resource copies are NOT re-nested under the staged
|
||||
# package (keeps it byte-identical to a checkout package).
|
||||
self.assertFalse((root / "bot_bottle" / "_resources").exists())
|
||||
# Helpers resolve off the staged root.
|
||||
self.assertEqual(root / "Dockerfile.gateway",
|
||||
resources.dockerfile("Dockerfile.gateway"))
|
||||
# Idempotent: second call returns the same completed dir.
|
||||
self.assertEqual(root, resources.build_root())
|
||||
|
||||
def test_missing_bundle_raises(self):
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpname:
|
||||
tmp = Path(tmpname)
|
||||
pkg = tmp / "bot_bottle"
|
||||
pkg.mkdir()
|
||||
restore = use_bottle_root(tmp / "appdata")
|
||||
self.addCleanup(restore)
|
||||
with patch.object(resources, "_PKG", pkg), \
|
||||
patch.object(resources, "_BUNDLED", pkg / "_resources"), \
|
||||
patch.object(resources, "_CHECKOUT_ROOT", tmp / "no-checkout"):
|
||||
with self.assertRaises(resources.ResourceError):
|
||||
resources.build_root()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Integration: build the wheel, install it into an isolated venv, and prove
|
||||
the installed distribution is self-contained.
|
||||
|
||||
This is the boundary a source-tree existence test can't reach (issue #197
|
||||
review): under an installed wheel the package lives in ``site-packages`` with
|
||||
no repo root above it, so anything resolving Dockerfiles / nix / scripts from
|
||||
``__file__``'s parents would break. Here we install for real and assert that
|
||||
``bot-bottle doctor`` runs from the console script and that
|
||||
``bot_bottle.resources`` stages a valid, repo-root-shaped build context.
|
||||
|
||||
It does NOT run `start` — building images needs a Docker/KVM host (CI). It
|
||||
skips cleanly when the build/venv toolchain isn't available.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _tooling_available() -> bool:
|
||||
return importlib.util.find_spec("build") is not None
|
||||
|
||||
|
||||
@unittest.skipUnless(_tooling_available(), "python 'build' module not installed")
|
||||
class TestWheelInstall(unittest.TestCase):
|
||||
_tmp: "tempfile.TemporaryDirectory[str]"
|
||||
venv_py: Path
|
||||
app_root: Path
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls._tmp = tempfile.TemporaryDirectory( # pylint: disable=consider-using-with
|
||||
prefix="bb-wheel-")
|
||||
tmp = Path(cls._tmp.name)
|
||||
dist = tmp / "dist"
|
||||
|
||||
build = subprocess.run(
|
||||
[sys.executable, "-m", "build", "--wheel", "--outdir", str(dist), str(REPO_ROOT)],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
if build.returncode != 0:
|
||||
raise unittest.SkipTest(f"wheel build unavailable:\n{build.stderr[-1500:]}")
|
||||
wheels = list(dist.glob("*.whl"))
|
||||
if not wheels:
|
||||
raise unittest.SkipTest("no wheel produced")
|
||||
|
||||
venv = tmp / "venv"
|
||||
made = subprocess.run([sys.executable, "-m", "venv", str(venv)],
|
||||
capture_output=True, text=True, check=False)
|
||||
if made.returncode != 0:
|
||||
raise unittest.SkipTest(f"venv unavailable:\n{made.stderr[-1500:]}")
|
||||
cls.venv_py = venv / "bin" / "python"
|
||||
|
||||
install = subprocess.run(
|
||||
[str(cls.venv_py), "-m", "pip", "install", "--quiet", str(wheels[0])],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
if install.returncode != 0:
|
||||
raise unittest.SkipTest(f"pip install failed:\n{install.stderr[-1500:]}")
|
||||
|
||||
# Isolate the staged build root the wheel writes under the app-data dir.
|
||||
cls.app_root = tmp / "appdata"
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls._tmp.cleanup()
|
||||
|
||||
def _run(self, tail: "list[str]") -> "subprocess.CompletedProcess[str]":
|
||||
"""Run the installed venv's python with `tail` appended, from a neutral
|
||||
cwd so the source checkout isn't on sys.path — we must import the
|
||||
*installed* package, not the repo we built from."""
|
||||
env = {"BOT_BOTTLE_ROOT": str(self.app_root), "PATH": "/usr/bin:/bin"}
|
||||
return subprocess.run(
|
||||
[str(self.venv_py), *tail],
|
||||
capture_output=True, text=True, env=env, cwd=self._tmp.name, check=False,
|
||||
)
|
||||
|
||||
def test_console_entry_point_installed(self):
|
||||
# The `bot-bottle` script the wheel declares must exist in the venv.
|
||||
script = self.venv_py.parent / "bot-bottle"
|
||||
self.assertTrue(script.is_file(), "bot-bottle console script not installed")
|
||||
|
||||
def test_doctor_runs_from_installed_package(self):
|
||||
proc = self._run(["-m", "bot_bottle.cli", "doctor"])
|
||||
# doctor exits non-zero here (no backend), but it must RUN and report.
|
||||
self.assertIn("python", proc.stdout)
|
||||
self.assertIn(proc.returncode, (0, 1))
|
||||
|
||||
def test_installed_wheel_is_self_contained(self):
|
||||
# From the installed layout (not a checkout), resources must resolve
|
||||
# Dockerfiles and stage a repo-root-shaped build context.
|
||||
script = (
|
||||
"import bot_bottle.resources as r\n"
|
||||
"assert not r.is_source_checkout(), 'should not look like a checkout'\n"
|
||||
"assert r.dockerfile('Dockerfile.gateway').is_file()\n"
|
||||
"assert r.nix_netpool_module().is_file()\n"
|
||||
"assert r.netpool_script().is_file()\n"
|
||||
"root = r.build_root()\n"
|
||||
"assert (root / 'bot_bottle' / '__init__.py').is_file(), 'no package in context'\n"
|
||||
"assert (root / 'pyproject.toml').is_file(), 'no pyproject in context'\n"
|
||||
"assert (root / 'Dockerfile.gateway').is_file(), 'no Dockerfile in context'\n"
|
||||
"print('SELF_CONTAINED_OK')\n"
|
||||
)
|
||||
proc = self._run(["-c", script])
|
||||
self.assertIn("SELF_CONTAINED_OK", proc.stdout, msg=proc.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user