diff --git a/scripts/install-lxd.sh b/scripts/install-lxd.sh new file mode 100755 index 0000000..82da717 --- /dev/null +++ b/scripts/install-lxd.sh @@ -0,0 +1,68 @@ +#!/bin/bash +set -euo pipefail + +echo "=== Installing LXD on Debian Bookworm ===" + +# Check if snapd is installed +if ! command -v snap &> /dev/null; then + echo "Installing snapd..." + sudo apt update + sudo apt install -y snapd + echo "Enabling snapd service..." + sudo systemctl enable --now snapd.socket + # Wait for snapd to be ready + sleep 5 +else + echo "snapd already installed" +fi + +# Check if LXD is already installed +if snap list lxd &> /dev/null; then + echo "LXD already installed via snap" +else + echo "Installing LXD via snap..." + sudo snap install lxd +fi + +# Check if LXD is initialized +if sudo lxd init --dump &> /dev/null; then + echo "LXD already initialized" +else + echo "Initializing LXD with default settings..." + sudo lxd init --auto +fi + +echo "" +echo "Configuring LXD network for Docker compatibility..." +lxc network set lxdbr0 ipv4.firewall false 2>/dev/null || true +lxc network set lxdbr0 ipv6.firewall false 2>/dev/null || true +lxc network set lxdbr0 ipv4.nat true 2>/dev/null || true + +LXD_SUBNET=$(lxc network get lxdbr0 ipv4.address) +if ! sudo iptables -t nat -C POSTROUTING -s "$LXD_SUBNET" ! -d "$LXD_SUBNET" -j MASQUERADE 2>/dev/null; then + sudo iptables -t nat -I POSTROUTING -s "$LXD_SUBNET" ! -d "$LXD_SUBNET" -j MASQUERADE +fi + +if command -v docker &>/dev/null; then + if ! sudo iptables -C DOCKER-USER -i lxdbr0 -j ACCEPT 2>/dev/null; then + sudo iptables -I DOCKER-USER -i lxdbr0 -j ACCEPT + fi + if ! sudo iptables -C DOCKER-USER -o lxdbr0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT 2>/dev/null; then + sudo iptables -I DOCKER-USER -o lxdbr0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT + fi +fi + +echo "✓ LXD installation complete!" +echo " Default network bridge (lxdbr0) configured for internet access" + +# Add user to lxd group for passwordless access +if groups "$USER" | grep -q "\blxd\b"; then + echo " User '$USER' already in lxd group" +else + echo "Adding user '$USER' to lxd group..." + sudo usermod -a -G lxd "$USER" + echo "✓ User added to lxd group" + echo "" + echo "IMPORTANT: You need to log out and back in for group changes to take effect" + echo "Or run: newgrp lxd" +fi diff --git a/tasks.py b/tasks.py index f7467bc..e802f3e 100644 --- a/tasks.py +++ b/tasks.py @@ -71,3 +71,9 @@ def install_zoxide(c): @task(install_apt_packages, install_docker, install_uv, install_claude, install_fzf, install_zoxide) def bootstrap(c): """Install all base tools.""" + + +@task +def test(c): + """Run tasks in an ephemeral LXD container.""" + run(c, "pytest tests/ -v -s") diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..47f2e1c --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,84 @@ +import subprocess +import time +from collections.abc import Generator + +import pytest + +CONTAINER = "cli-tools-test" +IMAGE = "images:debian/bookworm" +REPO_ROOT = "/home/jev/projects/cli-tools" + + +def _lxc(*args: str, capture: bool = False) -> subprocess.CompletedProcess: + return subprocess.run( + ["lxc", *args], + check=True, + capture_output=capture, + text=capture, + ) + + + +def _wait_for_network(container: str, retries: int = 20, delay: float = 2.0) -> None: + print("[container] Waiting for network...", flush=True) + for _ in range(retries): + result = subprocess.run( + ["lxc", "exec", container, "--", "bash", "-c", "ping -c1 8.8.8.8 &>/dev/null"], + capture_output=True, + ) + if result.returncode == 0: + print("[container] Network ready", flush=True) + return + time.sleep(delay) + raise RuntimeError(f"Container {container!r} did not get network access") + + +@pytest.fixture(scope="session") +def container() -> Generator[str, None, None]: + # Clean up any stale container from a previous run + subprocess.run(["lxc", "delete", "--force", CONTAINER], capture_output=True) + + print(f"\n[container] Launching {CONTAINER}...", flush=True) + _lxc("launch", IMAGE, CONTAINER) + time.sleep(3) + + _wait_for_network(CONTAINER) + + print("[container] Installing sudo and python3-pip...", flush=True) + _lxc( + "exec", CONTAINER, "--", + "bash", "-c", + """ + apt-get update -qq + DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sudo python3-pip + """, + ) + + print("[container] Creating jev user...", flush=True) + _lxc( + "exec", CONTAINER, "--", + "bash", "-c", + """ + useradd -m -s /bin/bash -u 1000 jev + usermod -aG sudo jev + echo 'jev ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/jev + chmod 440 /etc/sudoers.d/jev + """, + ) + + print("[container] Installing invoke...", flush=True) + _lxc( + "exec", CONTAINER, "--", + "bash", "-c", + "pip3 install invoke --quiet --break-system-packages", + ) + + print("[container] Mounting repo...", flush=True) + _lxc("config", "device", "add", CONTAINER, "repo", "disk", + f"source={REPO_ROOT}", "path=/home/jev/cli-tools") + print("[container] Ready\n", flush=True) + + yield CONTAINER + + print(f"\n[container] Destroying {CONTAINER}...", flush=True) + _lxc("delete", "--force", CONTAINER) diff --git a/tests/test_tasks.py b/tests/test_tasks.py new file mode 100644 index 0000000..49937ba --- /dev/null +++ b/tests/test_tasks.py @@ -0,0 +1,45 @@ +import subprocess + + +def lxc_exec(container: str, cmd: str, check: bool = True) -> subprocess.CompletedProcess: + """Run a command in the container as the jev user.""" + return subprocess.run( + ["lxc", "exec", container, "--user", "1000", "--", "bash", "-lc", cmd], + check=check, + ) + + +def run_task(container: str, task: str) -> None: + lxc_exec(container, f"cd /home/jev/cli-tools && invoke {task.replace('_', '-')}") + + +def test_install_apt_packages(container: str) -> None: + run_task(container, "install_apt_packages") + for binary in ["git", "micro", "tree", "detox"]: + result = lxc_exec(container, f"which {binary}", check=False) + assert result.returncode == 0, f"{binary!r} not found after install_apt_packages" + + +def test_install_docker(container: str) -> None: + run_task(container, "install_docker") + result = lxc_exec(container, "docker --version", check=False) + assert result.returncode == 0, "docker not found after install_docker" + + +def test_install_uv(container: str) -> None: + run_task(container, "install_uv") + result = lxc_exec(container, "~/.local/bin/uv --version", check=False) + assert result.returncode == 0, "uv not found after install_uv" + + +def test_install_fzf(container: str) -> None: + run_task(container, "install_fzf") + for binary in ["fzf", "batcat"]: + result = lxc_exec(container, f"which {binary}", check=False) + assert result.returncode == 0, f"{binary!r} not found after install_fzf" + + +def test_install_zoxide(container: str) -> None: + run_task(container, "install_zoxide") + result = lxc_exec(container, "which zoxide", check=False) + assert result.returncode == 0, "zoxide not found after install_zoxide"