diff --git a/docs/source/reference/package-apis/drivers/cuttlefish.md b/docs/source/reference/package-apis/drivers/cuttlefish.md new file mode 120000 index 000000000..88ce51e57 --- /dev/null +++ b/docs/source/reference/package-apis/drivers/cuttlefish.md @@ -0,0 +1 @@ +../../../../../python/packages/jumpstarter-driver-cuttlefish/README.md \ No newline at end of file diff --git a/docs/source/reference/package-apis/drivers/index.md b/docs/source/reference/package-apis/drivers/index.md index 43a6f75b8..a4c3c6874 100644 --- a/docs/source/reference/package-apis/drivers/index.md +++ b/docs/source/reference/package-apis/drivers/index.md @@ -87,6 +87,7 @@ Drivers for virtual and emulated targets: - {doc}`QEMU ` (`jumpstarter-driver-qemu`) - QEMU virtual machine management - {doc}`Renode ` (`jumpstarter-driver-renode`) - Renode embedded systems emulation - {doc}`Corellium ` (`jumpstarter-driver-corellium`) - Corellium virtualization platform +- {doc}`Cuttlefish ` (`jumpstarter-driver-cuttlefish`) - Android Cuttlefish virtual device management ### Utility @@ -103,6 +104,7 @@ androidemulator.md ble.md can.md corellium.md +cuttlefish.md doip.md dut-network.md dutlink.md diff --git a/python/packages/jumpstarter-all/pyproject.toml b/python/packages/jumpstarter-all/pyproject.toml index 3dfd92af7..2a36f4e29 100644 --- a/python/packages/jumpstarter-all/pyproject.toml +++ b/python/packages/jumpstarter-all/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "jumpstarter-driver-composite", "jumpstarter-driver-doip", "jumpstarter-driver-corellium", + "jumpstarter-driver-cuttlefish", "jumpstarter-driver-dut-network", "jumpstarter-driver-dutlink", "jumpstarter-driver-esp32", diff --git a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/driver.py b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/driver.py index 16304c12c..45725e5e7 100644 --- a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/driver.py +++ b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/driver.py @@ -62,7 +62,7 @@ def __post_init__(self): def close(self): self.kill_server() - def _adb_env(self) -> dict[str, str]: + def adb_env(self) -> dict[str, str]: """Environment with ANDROID_ADB_SERVER_PORT set.""" return {**os.environ, "ANDROID_ADB_SERVER_PORT": str(self.port)} @@ -77,7 +77,7 @@ def start_server(self) -> int: stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, - env=self._adb_env(), + env=self.adb_env(), ) if result.stdout.strip(): self.logger.info(result.stdout.strip()) @@ -98,7 +98,7 @@ def kill_server(self) -> int: stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, - env=self._adb_env(), + env=self.adb_env(), ) if result.stdout.strip(): self.logger.info(result.stdout.strip()) @@ -116,7 +116,7 @@ def list_devices(self) -> str: stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, - env=self._adb_env(), + env=self.adb_env(), ) return result.stdout except subprocess.CalledProcessError as e: diff --git a/python/packages/jumpstarter-driver-cuttlefish/README.md b/python/packages/jumpstarter-driver-cuttlefish/README.md new file mode 100644 index 000000000..5f4050108 --- /dev/null +++ b/python/packages/jumpstarter-driver-cuttlefish/README.md @@ -0,0 +1,313 @@ +# Cuttlefish Driver + +`jumpstarter-driver-cuttlefish` manages +[Android Cuttlefish](https://source.android.com/docs/devices/cuttlefish) +virtual devices through the +[Host Orchestrator](https://github.com/google/android-cuttlefish) REST API. +It provides full CVD (Cuttlefish Virtual Device) lifecycle management through +standard Jumpstarter interfaces: `VirtualPowerInterface` for on/off/cycle, +plus cuttlefish-specific operations +(snapshot, powerwash, restart). + +## Installation + +```{code-block} console +:substitutions: +$ pip3 install --extra-index-url {{index_url}} jumpstarter-driver-cuttlefish +``` + +### Prerequisites + +- A running Cuttlefish Host Orchestrator (port 2080 by default) + +## Host Setup + +A `cvd-images` named volume mounted at `/home/vsoc-01/fetch` persists +fetched AOSP images across container restarts. Instance state (`/var/tmp/cvd`) +is deliberately kept ephemeral — restarting the container gives you a clean +slate with no orphaned instance directories. + +```bash +# 1. Pull the orchestration image +podman pull us-docker.pkg.dev/android-cuttlefish-artifacts/cuttlefish-orchestration/cuttlefish-orchestration:stable + +# 2. Create a named volume for AOSP images +podman volume create cvd-images + +# 3. Start the container +# --network=host: netsim and rootcanal bind to 127.0.0.1 inside the +# container, so without host networking they'd be unreachable from +# outside. Host networking shares the VM's network namespace directly. +# +# Security note: --privileged + --network=host gives the container full +# access to the VM's network stack. HO, netsim, and rootcanal have no +# auth — only deploy on dedicated, non-public hosts. +podman run -d \ + --name cuttlefish-orchestrator \ + --restart=always \ + --privileged \ + --network=host \ + -v cvd-images:/home/vsoc-01/fetch:Z \ + -v /opt/cuttlefish:/opt/cuttlefish:Z \ + us-docker.pkg.dev/android-cuttlefish-artifacts/cuttlefish-orchestration/cuttlefish-orchestration:stable + +# 4. Fix permissions +podman exec cuttlefish-orchestrator chown -R httpcvd:httpcvd /home/vsoc-01/fetch + +# 5. Fetch AOSP images (one-time, ~2 minutes) +podman exec cuttlefish-orchestrator cvd fetch \ + --default_build=aosp-android-latest-release/aosp_cf_x86_64_auto-userdebug \ + --target_directory=/home/vsoc-01/fetch + +# 6. Verify +curl -s http://localhost:2080/_debug/statusz # should return 200 +curl -s http://localhost:2080/cvds # should return {"cvds":[]} +``` + +### Ports + +After a CVD boots, the following ports are available on the host. +All per-instance ports use the same offset: `base + instance_num - 1`. + +| Service | Base port | Instance 1 | Instance 2 | +|---------|-----------|------------|------------| +| Host Orchestrator | 2080 | 2080 (fixed) | 2080 (fixed) | +| ADB | 6520 | 6520 | 6521 | +| Netsim REST | 7681 | 7681 | 7682 | +| Rootcanal HCI | 7300 | 7300 | 7301 | + +When using `instance_num > 1`, update the netsim `port` and bt_peer +`hci_port` in the exporter config to match. + +### SSH tunnel (local development only) + +For local development, when running the exporter on your workstation +instead of as a pod, tunnel ports from the VM. This works because +`--network=host` places netsim and rootcanal on the VM's loopback - +the tunnel's `localhost` target reaches them directly. + +```bash +ssh -L 2080:localhost:2080 \ + -L 6520:localhost:6520 \ + -L 7681:localhost:7681 \ + -L 7300:localhost:7300 \ + fedora@ -p 22000 -N +``` + +In production, the exporter runs as a pod and the `host` config +points to the cuttlefish VM's address directly - no tunnel needed. + +### Resetting stale state + +If CVDs get stuck or orphaned, clear stale state inside the container: + +```bash +podman exec cuttlefish-orchestrator bash -c ' + rm -rf /var/tmp/cvd/[0-9]* /var/tmp/cvd/lock/* /tmp/cf_avd_* /tmp/vsock_* + chown -R httpcvd:httpcvd /var/tmp/cvd/ +' +``` + +Or restart the container - ephemeral `/var/tmp/cvd` means a restart is +equivalent to a full reset. Fetched images in the `cvd-images` volume +are preserved. + +### Teardown + +Delete CVDs and snapshots when done to avoid accumulation: + +```bash +j power off --destroy # deletes the CVD +j cuttlefish snapshot delete # remove specific snapshots +``` + +## Configuration + +Example exporter configuration: + +```yaml +export: + cuttlefish: + type: jumpstarter_driver_cuttlefish.driver.Cuttlefish + config: + host: localhost + port: 2080 + instance_num: 1 + env_config: + instances: + - disk: + default_build: /home/vsoc-01/fetch + vm: + enable_virtiofs: false # required for snapshot support + common: + host_package: /home/vsoc-01/fetch + gpu_mode: guest_swiftshader # required for snapshot support + netsim: + type: jumpstarter_driver_netsim.driver.Netsim + config: + host: localhost + port: 7681 # 7681 + instance_num - 1 + bt_peer: + type: jumpstarter_driver_bt_peer.driver.BtPeer + config: + hci_host: 127.0.0.1 + hci_port: 7300 # 7300 + instance_num - 1 + power: + ref: cuttlefish.power + adb: + ref: cuttlefish.adb +``` + +### Configuration Parameters + +| Parameter | Description | Type | Required | Default | +| --------------- | ----------------------------------- | ---- | -------- | ----------- | +| host | Host Orchestrator hostname | str | no | "localhost" | +| port | Host Orchestrator HTTP port | int | no | 2080 | +| group | CVD group name passed to `cvd load`. HO auto-assigns a different group name (e.g. `cvd_1`); the driver tracks the assigned name internally. | str | no | "cvd" | +| name | CVD instance name within the group | str | no | "1" | +| instance_num | CVD instance number (determines ADB/netsim/HCI ports). Must match HO's assigned slot. Pinning avoids drift (see `env_config` example). | int | no | 1 | +| adb_server_port | ADB server port on the exporter | int | no | 15037 | +| boot_timeout | Seconds to wait for boot on power on| int | no | 300 | +| env_config | Default env_config for CVD creation | dict | no | {} | + +This is a **composite driver** with three children: +- **power** — `VirtualPowerInterface`: `j power on`, `j power off [--destroy]`, `j power cycle` +- **storage** — `FlasherInterface`: not yet implemented (planned: HO artifact upload API) +- **adb** — ADB server for device communication + +The exporter config also typically includes sibling drivers: +- **netsim** (`jumpstarter-driver-netsim`) — virtual radio control (BLE, WiFi, UWB) via netsim REST API +- **bt_peer** (`jumpstarter-driver-bt-peer`) — Bluetooth peer device via bumble + rootcanal HCI + +Use `ref:` entries in the exporter config to expose children at the top level. + +## Usage + +### CLI + +```bash +# Power on (creates CVD if none exists, starts if stopped) +j power on + +# Power off (stops CVD, keeps state) +j power off + +# Power off and delete CVD entirely +j power off --destroy + +# Power cycle +j power cycle + +# Health check +j cuttlefish status + +# List all CVDs +j cuttlefish list + +# Get this CVD's details +j cuttlefish get + +# Restart the CVD +j cuttlefish restart + +# Factory reset +j cuttlefish powerwash + +# Simulate power button press +j cuttlefish powerbtn + +# List running operations +j cuttlefish ops + +# Snapshot management +# Requires: x86_64 host, enable_virtiofs: false, gpu_mode: guest_swiftshader +j cuttlefish snapshot create --id my-snapshot +j cuttlefish snapshot delete +``` + +### Python API + +```python +from jumpstarter.common.utils import serve +from jumpstarter_driver_cuttlefish.driver import Cuttlefish + +driver = Cuttlefish( + host="localhost", + port=2080, + env_config={ + "instances": [{"disk": {"default_build": "/home/vsoc-01/fetch"}}], + "common": {"host_package": "/home/vsoc-01/fetch"}, + }, +) +with serve(driver) as client: + # Check Host Orchestrator is reachable + print(client.status()) # "OK" + + # Power on (creates CVD from env_config) + client.power.on() + + # List CVDs + cvds = client.list_cvds() + print(cvds) + + # Snapshots + client.create_snapshot(snapshot_id="baseline") + + # Cleanup + client.power.off(destroy=True) +``` + +## Architecture + +```text +┌────────────┐ gRPC ┌────────────────┐ HTTP ┌──────────────────┐ +│ jmp shell │──────────────►│ Exporter │────────────►│ Host │ +│ (client) │ │ ├─ cuttlefish │ :2080 │ Orchestrator │ +│ │ │ │ ├─ power │ │ │ +│ │ │ │ ├─ storage │ │ cvd create/ │ +│ │ │ │ └─ adb │ │ start/stop │ +│ │ │ ├─ netsim ────│── :7681 ──►│ netsim REST │ +│ │ │ └─ bt_peer ───│── :7300 ──►│ rootcanal HCI │ +└────────────┘ └────────────────┘ └────────┬─────────┘ + │ + ▼ + ┌──────────────────┐ + │ Cuttlefish VM │ + │ (Android guest) │ + │ ADB :6520 │ + └──────────────────┘ +``` + +The driver is a thin REST client that translates Jumpstarter driver calls into +Host Orchestrator API requests. Long-running operations (create, start, stop, +delete) are handled asynchronously - the driver polls the `/operations/:wait` +endpoint until completion or timeout. + +`power.on()` waits for full boot by default (`boot_timeout=300`). It polls +`adb connect` + `adb devices` until the device is online, then waits for +`sys.boot_completed=1`. Set `boot_timeout: 0` to skip the wait. + +### CVD Build Sources + +The `env_config` supports two build source formats in `disk.default_build`: + +- **Android CI**: `@ab//` - fetches images from Android Build servers. + Example: `@ab/aosp-android-latest-release/aosp_cf_x86_64_auto-userdebug` (AAOS) +- **Local path**: `/path/to/android/build` - uses pre-fetched images on the host. + +## API Reference + +### Driver + +```{eval-rst} +.. autoclass:: jumpstarter_driver_cuttlefish.driver.Cuttlefish() + :members: +``` + +### Client + +```{eval-rst} +.. autoclass:: jumpstarter_driver_cuttlefish.client.CuttlefishClient() + :members: +``` diff --git a/python/packages/jumpstarter-driver-cuttlefish/jumpstarter_driver_cuttlefish/__init__.py b/python/packages/jumpstarter-driver-cuttlefish/jumpstarter_driver_cuttlefish/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/packages/jumpstarter-driver-cuttlefish/jumpstarter_driver_cuttlefish/client.py b/python/packages/jumpstarter-driver-cuttlefish/jumpstarter_driver_cuttlefish/client.py new file mode 100644 index 000000000..346aa9280 --- /dev/null +++ b/python/packages/jumpstarter-driver-cuttlefish/jumpstarter_driver_cuttlefish/client.py @@ -0,0 +1,182 @@ +import json +import threading +import time + +import click +from jumpstarter_driver_composite.client import CompositeClient +from jumpstarter_driver_power.client import VirtualPowerClient + +from jumpstarter.client.base import StubDriverClient + + +def _parse(raw: str) -> dict | list | str: + try: + return json.loads(raw) + except (json.JSONDecodeError, TypeError): + return raw + + +def _echo(obj) -> None: + if isinstance(obj, (dict, list)): + click.echo(json.dumps(obj, indent=2)) + else: + click.echo(obj) + + +def _run_with_progress(label: str, fn): + result = [None] + error = [None] + + def worker(): + try: + result[0] = fn() + except Exception as e: + error[0] = e + + t = threading.Thread(target=worker) + t.start() + start = time.time() + click.echo(f"{label}...", nl=False) + while t.is_alive(): + t.join(timeout=10) + if t.is_alive(): + elapsed = int(time.time() - start) + click.echo(f" {elapsed}s", nl=False) + elapsed = int(time.time() - start) + if error[0] is not None: + click.echo(f" failed ({elapsed}s)") + raise error[0] + click.echo(f" done ({elapsed}s)") + return result[0] + + +class CvdPowerClient(VirtualPowerClient): + def cli(self): + @click.group() + def power(): + """CVD power control.""" + + @power.command() + def on(): + """Power on (create or start CVD, wait for boot).""" + _run_with_progress("Powering on", self.on) + + @power.command() + @click.option("--destroy", is_flag=True, help="Delete CVD entirely after stopping") + def off(destroy: bool): + """Power off (stop CVD).""" + _run_with_progress("Powering off", lambda: self.off(destroy)) + + @power.command() + @click.option("--wait", "-w", default=2, type=click.IntRange(min=0), help="Seconds between off and on") + def cycle(wait: int): + """Power cycle.""" + _run_with_progress("Power cycling", lambda: self.cycle(wait)) + + return power + + +class CuttlefishClient(CompositeClient): + """Client for controlling Cuttlefish Host Orchestrator with nested children.""" + + def list_cvds(self) -> dict | list | str: + return _parse(self.call("list_cvds")) + + def get_cvd(self) -> dict | list | str: + return _parse(self.call("get_cvd")) + + def restart_cvd(self) -> dict | list | str: + return _parse(self.call("restart_cvd")) + + def powerwash_cvd(self) -> dict | list | str: + return _parse(self.call("powerwash_cvd")) + + def powerbtn_cvd(self) -> dict | list | str: + return _parse(self.call("powerbtn_cvd")) + + def get_host(self) -> str: + return self.call("get_host") + + def get_webrtc_url(self) -> str: + return self.call("get_webrtc_url") + + def status(self) -> str: + return self.call("status") + + def list_operations(self) -> dict | list | str: + return _parse(self.call("list_operations")) + + def reset_host(self) -> dict | list | str: + return _parse(self.call("reset_host")) + + def wait_boot(self, timeout: int = 0) -> str: + return self.call("wait_boot", timeout) + + def cli(self): # noqa: C901 + @click.group() + def cuttlefish(): + """Cuttlefish Host Orchestrator. + + Manage CVD lifecycle via power/storage children, + plus cuttlefish-specific operations (powerwash, etc.). + """ + + @cuttlefish.command("list") + def list_cmd(): + """List all CVDs.""" + _echo(self.list_cvds()) + + @cuttlefish.command("get") + def get_cmd(): + """Get details for this CVD.""" + _echo(self.get_cvd()) + + @cuttlefish.command("restart") + def restart_cmd(): + """Restart the CVD.""" + _echo(_run_with_progress("Restarting CVD", lambda: self.restart_cvd())) + + @cuttlefish.command("powerwash") + def powerwash_cmd(): + """Factory reset the CVD.""" + _echo(_run_with_progress("Powerwashing CVD", lambda: self.powerwash_cvd())) + + @cuttlefish.command("powerbtn") + def powerbtn_cmd(): + """Simulate power button press.""" + _echo(self.powerbtn_cvd()) + + @cuttlefish.command("status") + def status_cmd(): + """Health check.""" + click.echo(self.status()) + + @cuttlefish.command("ops") + def ops_cmd(): + """List running operations.""" + _echo(self.list_operations()) + + @cuttlefish.command("wait-boot") + @click.option("--timeout", default=0, type=int, help="Timeout in seconds (0 = use boot_timeout config)") + def wait_boot_cmd(timeout: int): + """Wait for CVD to finish booting.""" + click.echo(_run_with_progress("Waiting for boot", lambda: self.wait_boot(timeout))) + + @cuttlefish.command("reset") + def reset_cmd(): + """Delete all CVDs and clean up stale state.""" + _echo(_run_with_progress("Resetting", lambda: self.reset_host())) + + @cuttlefish.command("webrtc") + def webrtc_cmd(): + """Print the WebRTC display URL.""" + click.echo(self.get_webrtc_url()) + + for k, v in self.children.items(): + if isinstance(v, StubDriverClient): + continue + if not hasattr(v, "cli"): + continue + cuttlefish.add_command(v.cli(), k) + + return cuttlefish diff --git a/python/packages/jumpstarter-driver-cuttlefish/jumpstarter_driver_cuttlefish/client_test.py b/python/packages/jumpstarter-driver-cuttlefish/jumpstarter_driver_cuttlefish/client_test.py new file mode 100644 index 000000000..420913864 --- /dev/null +++ b/python/packages/jumpstarter-driver-cuttlefish/jumpstarter_driver_cuttlefish/client_test.py @@ -0,0 +1,223 @@ +import subprocess + +import pytest +from click.testing import CliRunner + +from .client import _echo, _parse +from .driver import Cuttlefish +from jumpstarter.common.utils import serve + +BASE = "http://localhost:2080" + + +@pytest.fixture(autouse=True) +def _mock_adb(monkeypatch): + monkeypatch.setattr("jumpstarter_driver_adb.driver.shutil.which", lambda cmd: f"/usr/bin/{cmd}") + monkeypatch.setattr( + "jumpstarter_driver_adb.driver.subprocess.run", + lambda *a, **kw: subprocess.CompletedProcess(a[0] if a else [], 0, stdout="", stderr=""), + ) + + +def _op(requests_mock, method, path, op_name="op-1"): + getattr(requests_mock, method)(f"{BASE}{path}", json={"name": op_name, "done": False}) + requests_mock.post(f"{BASE}/operations/{op_name}/:wait", json={"name": op_name, "done": True}) + + +# --- _parse --- + + +def test_parse_dict(): + assert _parse('{"a": 1}') == {"a": 1} + + +def test_parse_list(): + assert _parse("[1, 2]") == [1, 2] + + +def test_parse_plain(): + assert _parse("text") == "text" + + +# --- _echo --- + + +def test_echo_dict(capsys): + _echo({"k": "v"}) + assert '"k"' in capsys.readouterr().out + + +def test_echo_list(capsys): + _echo([1]) + assert "1" in capsys.readouterr().out + + +def test_echo_str(capsys): + _echo("hi") + assert "hi" in capsys.readouterr().out + + +# --- client methods via serve() --- + + +def test_list_cvds(requests_mock): + requests_mock.get(f"{BASE}/cvds", json={"cvds": [{"name": "d1"}]}) + with serve(Cuttlefish()) as client: + assert client.list_cvds()["cvds"][0]["name"] == "d1" + + +def test_get_cvd(requests_mock): + requests_mock.get(f"{BASE}/cvds/cvd/1", json={"adb_port": 6520}) + with serve(Cuttlefish()) as client: + assert client.get_cvd()["adb_port"] == 6520 + + +def test_restart_cvd(requests_mock): + _op(requests_mock, "post", "/cvds/cvd/1/:restart") + with serve(Cuttlefish()) as client: + assert client.restart_cvd()["done"] is True + + +def test_powerwash_cvd(requests_mock): + _op(requests_mock, "post", "/cvds/cvd/1/:powerwash") + with serve(Cuttlefish()) as client: + assert client.powerwash_cvd()["done"] is True + + +def test_powerbtn_cvd(requests_mock): + _op(requests_mock, "post", "/cvds/cvd/1/:powerbtn") + with serve(Cuttlefish()) as client: + assert client.powerbtn_cvd()["done"] is True + + +def test_status(requests_mock): + requests_mock.get(f"{BASE}/_debug/statusz", text="ok") + with serve(Cuttlefish()) as client: + assert client.status() == "OK" + + +def test_list_operations(requests_mock): + requests_mock.get(f"{BASE}/operations", json={"operations": []}) + with serve(Cuttlefish()) as client: + assert client.list_operations()["operations"] == [] + + +def test_non_operation_response(requests_mock): + """Covers _do_operation returning a non-operation result (no 'done' key).""" + requests_mock.post(f"{BASE}/cvds/cvd/1/:restart", json={"status": "ready"}) + with serve(Cuttlefish()) as client: + result = client.restart_cvd() + assert result["status"] == "ready" + + +# --- CLI --- + + +def test_cli_list(requests_mock): + requests_mock.get(f"{BASE}/cvds", json={"cvds": [{"name": "d1"}]}) + with serve(Cuttlefish()) as client: + r = CliRunner().invoke(client.cli(), ["list"]) + assert r.exit_code == 0 + assert "d1" in r.output + + +def test_cli_get(requests_mock): + requests_mock.get(f"{BASE}/cvds/cvd/1", json={"name": "n"}) + with serve(Cuttlefish()) as client: + r = CliRunner().invoke(client.cli(), ["get"]) + assert r.exit_code == 0 + + +def test_cli_restart(requests_mock): + _op(requests_mock, "post", "/cvds/cvd/1/:restart") + with serve(Cuttlefish()) as client: + r = CliRunner().invoke(client.cli(), ["restart"]) + assert r.exit_code == 0 + + +def test_cli_powerwash(requests_mock): + _op(requests_mock, "post", "/cvds/cvd/1/:powerwash") + with serve(Cuttlefish()) as client: + r = CliRunner().invoke(client.cli(), ["powerwash"]) + assert r.exit_code == 0 + + +def test_cli_powerbtn(requests_mock): + _op(requests_mock, "post", "/cvds/cvd/1/:powerbtn") + with serve(Cuttlefish()) as client: + r = CliRunner().invoke(client.cli(), ["powerbtn"]) + assert r.exit_code == 0 + + +def test_cli_status(requests_mock): + requests_mock.get(f"{BASE}/_debug/statusz", text="ok") + with serve(Cuttlefish()) as client: + r = CliRunner().invoke(client.cli(), ["status"]) + assert r.exit_code == 0 + assert "OK" in r.output + + +def test_cli_ops(requests_mock): + requests_mock.get(f"{BASE}/operations", json={"operations": []}) + with serve(Cuttlefish()) as client: + r = CliRunner().invoke(client.cli(), ["ops"]) + assert r.exit_code == 0 + + +def test_get_host(requests_mock): + with serve(Cuttlefish()) as client: + assert client.get_host() == "localhost" + + +def test_wait_boot(requests_mock): + with serve(Cuttlefish(boot_timeout=0)) as client: + assert client.wait_boot(0) == "OK" + + +def test_cli_wait_boot(requests_mock): + with serve(Cuttlefish(boot_timeout=0)) as client: + r = CliRunner().invoke(client.cli(), ["wait-boot", "--timeout", "0"]) + assert r.exit_code == 0 + + +def test_cli_power_on(requests_mock): + requests_mock.get(f"{BASE}/cvds", json={"cvds": [{"name": "1", "group": "cvd", "status": "Running"}]}) + with serve(Cuttlefish(boot_timeout=0)) as client: + r = CliRunner().invoke(client.cli(), ["power", "on"]) + assert r.exit_code == 0 + + +def test_cli_power_off(requests_mock): + _op(requests_mock, "post", "/cvds/cvd/1/:stop") + with serve(Cuttlefish()) as client: + r = CliRunner().invoke(client.cli(), ["power", "off"]) + assert r.exit_code == 0 + + +def test_cli_power_off_destroy(requests_mock): + _op(requests_mock, "delete", "/cvds/cvd/1") + with serve(Cuttlefish()) as client: + r = CliRunner().invoke(client.cli(), ["power", "off", "--destroy"]) + assert r.exit_code == 0 + + +def test_cli_power_cycle(requests_mock): + _op(requests_mock, "post", "/cvds/cvd/1/:stop") + requests_mock.get( + f"{BASE}/cvds", + json={"cvds": [{"name": "1", "group": "cvd", "status": "Stopped"}]}, + ) + _op(requests_mock, "post", "/cvds/cvd/1/:start", op_name="op-2") + with serve(Cuttlefish(boot_timeout=0)) as client: + r = CliRunner().invoke(client.cli(), ["power", "cycle", "--wait", "0"]) + assert r.exit_code == 0 + + +def test_run_with_progress_error(): + from .client import _run_with_progress + + def boom(): + raise ValueError("kaboom") + + with pytest.raises(ValueError, match="kaboom"): + _run_with_progress("Testing", boom) diff --git a/python/packages/jumpstarter-driver-cuttlefish/jumpstarter_driver_cuttlefish/driver.py b/python/packages/jumpstarter-driver-cuttlefish/jumpstarter_driver_cuttlefish/driver.py new file mode 100644 index 000000000..42ec00deb --- /dev/null +++ b/python/packages/jumpstarter-driver-cuttlefish/jumpstarter_driver_cuttlefish/driver.py @@ -0,0 +1,467 @@ +import json +import subprocess +import time +from collections.abc import Generator +from dataclasses import dataclass, field + +import requests +from jumpstarter_driver_adb.driver import AdbServer +from jumpstarter_driver_power.driver import PowerReading, VirtualPowerInterface + +from jumpstarter.driver import Driver, export +from jumpstarter.driver.flasher import FlasherInterface + + +class CuttlefishError(Exception): + """Raised when a Host Orchestrator API call fails.""" + + +class CuttlefishTimeout(CuttlefishError): + """Raised when an operation doesn't complete in time.""" + + +@dataclass(kw_only=True) +class Cuttlefish(Driver): + """Cuttlefish Host Orchestrator driver for managing Android virtual devices. + + Composite driver with children: power, storage, adb. + """ + + driver_type = "composite" + + scheme: str = "http" + host: str = "localhost" + port: int = 2080 + group: str = "cvd" + name: str = "1" + instance_num: int = 1 + adb_server_port: int = 15037 + boot_timeout: int = 300 + env_config: dict = field(default_factory=dict) + webrtc_url: str = "" + _cvd_group: str | None = field(default=None, init=False, repr=False) + _cvd_name: str | None = field(default=None, init=False, repr=False) + + def __post_init__(self): + if hasattr(super(), "__post_init__"): + super().__post_init__() + self.children["power"] = CvdPower(parent=self) + self.children["storage"] = CvdFlasher(parent=self) + self.children["adb"] = AdbServer(host="127.0.0.1", port=self.adb_server_port) + + @classmethod + def client(cls) -> str: + return "jumpstarter_driver_cuttlefish.client.CuttlefishClient" + + @property + def _base_url(self) -> str: + return f"{self.scheme}://{self.host}:{self.port}" + + @property + def _expected_adb_port(self) -> int: + return 6520 + (self.instance_num - 1) + + @property + def _cvd_path(self) -> str: + return f"/cvds/{self._cvd_group or self.group}/{self._cvd_name or self.name}" + + def _fmt(self, result) -> str: + return json.dumps(result, indent=2) if isinstance(result, (dict, list)) else str(result) + + def _request(self, method: str, path: str, data: dict | None = None, timeout: float = 10) -> dict | list | str: + try: + r = requests.request(method, f"{self._base_url}{path}", json=data, timeout=timeout) + r.raise_for_status() + try: + return r.json() + except requests.JSONDecodeError: + return r.text + except requests.ConnectionError as e: + raise CuttlefishError(f"not connected to Host Orchestrator at {self.host}:{self.port}") from e + except requests.Timeout as e: + raise CuttlefishError(f"{method} {path} timed out after {timeout}s") from e + except requests.HTTPError as e: + raise CuttlefishError(f"{method} {path} failed: {e}") from e + + def _wait_for_operation(self, op_name: str, timeout: float = 300) -> dict: + deadline = time.monotonic() + timeout + start = time.monotonic() + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + elapsed = int(time.monotonic() - start) + self.logger.info("operation %s: waiting (%ds elapsed, %ds remaining)", op_name, elapsed, int(remaining)) + try: + r = requests.post( + f"{self._base_url}/operations/{op_name}/:wait", + timeout=min(130, max(1, remaining)), + ) + except requests.ConnectionError as e: + raise CuttlefishError(f"lost connection during operation {op_name}") from e + except requests.Timeout: + self.logger.info("operation %s: poll timeout after %ds, retrying", op_name, elapsed) + time.sleep(2) + continue + if r.status_code in (503, 504): + self.logger.info("operation %s: server busy (%d), retrying in 2s", op_name, r.status_code) + time.sleep(2) + continue + if r.status_code == 500: + body = None + try: + body = r.json() + except (ValueError, requests.JSONDecodeError): + pass + if body and isinstance(body, dict): + msg = body.get("error", "unknown error") + details = body.get("details", "") + raise CuttlefishError(f"operation failed: {msg}\n{details}") + raise CuttlefishError(f"operation failed with status 500: {r.text}") + try: + r.raise_for_status() + except requests.HTTPError as e: + raise CuttlefishError(f"operation {op_name} failed: {e}") from e + return r.json() + raise CuttlefishTimeout(f"operation {op_name} timed out after {timeout}s") + + def _do_operation( + self, + method: str, + path: str, + data: dict | None = None, + timeout: float = 300, + ) -> dict | list | str: + result = self._request(method, path, data) + if isinstance(result, dict) and "done" in result: + op_name = result.get("name") + if not op_name: + raise CuttlefishError(f"operation response missing 'name': {result}") + self.logger.info(f"Waiting for operation {op_name}") + return self._wait_for_operation(str(op_name), timeout) + return result + + def _get_existing_cvds(self) -> list[dict]: + """Return CVDs belonging to this driver's group. + + Raises CuttlefishError on connection/timeout/server failures so callers + don't mistake a failed query for "no CVDs exist". + """ + result = self._request("GET", "/cvds") + if not isinstance(result, dict): + raise CuttlefishError(f"unexpected response from GET /cvds: {result!r}") + all_cvds = result.get("cvds", []) + own_group = self._cvd_group or self.group + return [c for c in all_cvds if c.get("group") == own_group] + + @property + def _cvd_device(self) -> str: + """Pinned ADB address derived from config, never queried from HO.""" + return f"{self.host}:{self._expected_adb_port}" + + def _auto_connect_adb(self) -> str: + adb = self.children.get("adb") + if not adb: + return self._cvd_device + device = self._cvd_device + self.logger.info(f"Auto-connecting ADB to {device}") + try: + adb.connect_device(device) + except Exception: + self.logger.warning("ADB connect to %s failed, will retry during boot wait", device) + return device + + def _auto_disconnect_adb(self): + adb = self.children.get("adb") + if not adb: + return + device = self._cvd_device + self.logger.info(f"Disconnecting ADB from {device}") + try: + adb.disconnect_device(device) + except Exception: + pass + + def _wait_boot(self, timeout: float = 300): + """Wait for CVD to be ADB-reachable and fully booted.""" + adb = self.children.get("adb") + if not adb: + return + + device = self._cvd_device + + deadline = time.monotonic() + timeout + adb_path = adb.adb_path + adb_env = adb.adb_env() + + self.logger.info("Waiting for %s to come online", device) + while time.monotonic() < deadline: + try: + subprocess.run( + [adb_path, "connect", device], + capture_output=True, + text=True, + timeout=5, + env=adb_env, + ) + except (subprocess.TimeoutExpired, OSError): + pass + try: + r = subprocess.run( + [adb_path, "devices"], + capture_output=True, + text=True, + timeout=5, + env=adb_env, + ) + for line in r.stdout.splitlines(): + if device in line and "\tdevice" in line: + self.logger.info("%s is online", device) + break + else: + time.sleep(3) + continue + break + except (subprocess.TimeoutExpired, OSError): + time.sleep(3) + else: + raise CuttlefishTimeout(f"{device} did not come online within {timeout}s") + + self.logger.info("Waiting for boot to complete on %s", device) + while time.monotonic() < deadline: + try: + r = subprocess.run( + [adb_path, "-s", device, "shell", "getprop", "sys.boot_completed"], + capture_output=True, + text=True, + timeout=10, + env=adb_env, + ) + if r.stdout.strip() == "1": + self.logger.info("Boot completed on %s", device) + return + except (subprocess.TimeoutExpired, OSError): + pass + time.sleep(5) + + raise CuttlefishTimeout(f"boot did not complete on {device} within {timeout}s") + + @export + def get_host(self) -> str: + return self.host + + @export + def get_webrtc_url(self) -> str: + if self.webrtc_url: + return self.webrtc_url + return f"{self.scheme}://{self.host}:1080" + + @export + def list_cvds(self) -> str: + return self._fmt(self._request("GET", "/cvds")) + + @export + def get_cvd(self) -> str: + return self._fmt(self._request("GET", self._cvd_path)) + + @export + def restart_cvd(self) -> str: + self.logger.info(f"Restarting CVD {self.group}/{self.name}") + return self._fmt(self._do_operation("POST", f"{self._cvd_path}/:restart")) + + @export + def powerwash_cvd(self) -> str: + self.logger.info(f"Powerwashing CVD {self.group}/{self.name}") + return self._fmt(self._do_operation("POST", f"{self._cvd_path}/:powerwash")) + + @export + def powerbtn_cvd(self) -> str: + self.logger.info(f"Power button on CVD {self.group}/{self.name}") + return self._fmt(self._do_operation("POST", f"{self._cvd_path}/:powerbtn")) + + @export + def status(self) -> str: + """Check that nginx and Host Orchestrator are both reachable.""" + self._request("GET", "/_debug/statusz") + return "OK" + + @export + def create_cvd(self, config_json: str) -> str: + try: + config = json.loads(config_json) + except json.JSONDecodeError as e: + raise CuttlefishError(f"invalid JSON: {e}") from e + return self._fmt(self._do_operation("POST", "/cvds", config, timeout=600)) + + @export + def start_cvd(self) -> str: + return self._fmt(self._do_operation("POST", f"{self._cvd_path}/:start")) + + @export + def stop_cvd(self) -> str: + return self._fmt(self._do_operation("POST", f"{self._cvd_path}/:stop")) + + @export + def delete_cvd(self) -> str: + return self._fmt(self._do_operation("DELETE", self._cvd_path)) + + @export + def get_adb_port(self) -> str: + result = self._request("GET", self._cvd_path) + if isinstance(result, dict): + for cvd in result.get("cvds", []): + port = cvd.get("adb_port") + if port is not None: + return str(port) + raise CuttlefishError(f"no ADB port found for {self.group}/{self.name}") + + @export + def list_operations(self) -> str: + return self._fmt(self._request("GET", "/operations")) + + @export + def wait_boot(self, timeout: int = 0) -> str: + """Wait for CVD to finish booting. Uses boot_timeout config if timeout=0.""" + t = timeout or self.boot_timeout + if t: + self._wait_boot(t) + return "OK" + + @export + def reset_host(self) -> str: + """Forcefully delete all CVDs and clean host state via HO reset endpoint. + + Kills orphaned processes, removes stale files, and resets HO tracking. + """ + self.logger.warning("Resetting host orchestrator") + self._auto_disconnect_adb() + result = self._do_operation("POST", "/reset", timeout=60) + self._cvd_group = None + self._cvd_name = None + return self._fmt(result) + + +@dataclass(kw_only=True) +class CvdPower(VirtualPowerInterface, Driver): + """Virtual power control for Cuttlefish devices. + + on() creates a CVD if none exists, or starts an existing one. + If multiple CVDs exist in the configured group, all are deleted before + creating a fresh one (assumes single-tenant host orchestrator). + off() stops the CVD; off(destroy=True) deletes it entirely. + """ + + parent: Cuttlefish + + @classmethod + def client(cls) -> str: + return "jumpstarter_driver_cuttlefish.client.CvdPowerClient" + + @export + def on(self) -> None: # noqa: C901 + existing = self.parent._get_existing_cvds() + + if len(existing) > 1: + self.logger.warning( + "Found %d stale CVDs in group %s, deleting", len(existing), self.parent._cvd_group or self.parent.group + ) + failed = [] + for cvd in existing: + group = cvd.get("group", self.parent.group) + name = cvd.get("name", self.parent.name) + try: + self.parent._do_operation("DELETE", f"/cvds/{group}/{name}") + except CuttlefishError: + self.logger.warning("Failed to delete stale CVD %s/%s", group, name) + failed.append(f"{group}/{name}") + if failed: + raise CuttlefishError( + f"cannot create CVD - failed to delete stale CVDs: {', '.join(failed)}. " + f"Run 'j cuttlefish reset' then retry." + ) + existing = [] + + if existing: + cvd = existing[0] + self.parent._cvd_group = cvd.get("group") + self.parent._cvd_name = cvd.get("name") + self.logger.info( + "Found existing CVD %s/%s (status: %s)", + self.parent._cvd_group, + self.parent._cvd_name, + cvd.get("status"), + ) + if cvd.get("status") != "Running": + self.parent._do_operation("POST", f"{self.parent._cvd_path}/:start") + else: + self.logger.info("Creating CVD from env_config") + try: + result = self.parent._do_operation( + "POST", "/cvds", {"env_config": self.parent.env_config}, timeout=600, + ) + except CuttlefishError as e: + msg = str(e) + if "in use" in msg or "already running" in msg or "ValidateTapDevices" in msg: + raise CuttlefishError( + f"CVD creation failed - orphaned processes from a previous session. " + f"Run 'j cuttlefish reset' then retry. Original error: {msg}" + ) from e + raise + if isinstance(result, dict): + for cvd in result.get("cvds", []): + self.parent._cvd_group = cvd.get("group") + self.parent._cvd_name = cvd.get("name") + actual_port = cvd.get("adb_port") + if actual_port and actual_port != self.parent._expected_adb_port: + try: + self.parent._do_operation("DELETE", self.parent._cvd_path) + except CuttlefishError: + self.logger.warning("Failed to clean up CVD after port mismatch") + self.parent._cvd_group = None + self.parent._cvd_name = None + raise CuttlefishError( + f"HO assigned adb_port {actual_port} but expected " + f"{self.parent._expected_adb_port} — stale state may have leaked. " + f"Run 'j cuttlefish reset' then retry." + ) + break + + self.parent._auto_connect_adb() + if self.parent.boot_timeout: + self.parent._wait_boot(self.parent.boot_timeout) + + @export + def off(self, destroy: bool = False) -> None: + p = self.parent + cvd_id = f"{p._cvd_group or p.group}/{p._cvd_name or p.name}" + if destroy: + p._auto_disconnect_adb() + self.logger.info(f"Deleting CVD {cvd_id}") + p._do_operation("DELETE", p._cvd_path) + p._cvd_group = None + p._cvd_name = None + else: + self.logger.info(f"Stopping CVD {cvd_id}") + p._do_operation("POST", f"{p._cvd_path}/:stop") + + @export + def read(self) -> Generator[PowerReading, None, None]: + raise NotImplementedError("no power telemetry for virtual devices") + + +@dataclass(kw_only=True) +class CvdFlasher(FlasherInterface, Driver): + """Flasher for Cuttlefish devices (not yet implemented). + + Planned: upload artifacts to Host Orchestrator via its upload API. + """ + + parent: Cuttlefish + + @export + def flash(self, source, target: str | None = None) -> None: + raise NotImplementedError("CvdFlasher.flash() not yet implemented") + + @export + def dump(self, target, partition: str | None = None) -> None: + raise NotImplementedError("dump not supported for Cuttlefish devices") diff --git a/python/packages/jumpstarter-driver-cuttlefish/jumpstarter_driver_cuttlefish/driver_test.py b/python/packages/jumpstarter-driver-cuttlefish/jumpstarter_driver_cuttlefish/driver_test.py new file mode 100644 index 000000000..50207d0dd --- /dev/null +++ b/python/packages/jumpstarter-driver-cuttlefish/jumpstarter_driver_cuttlefish/driver_test.py @@ -0,0 +1,519 @@ +import json +import subprocess +from unittest.mock import MagicMock, patch + +import pytest +import requests + +from .driver import Cuttlefish, CuttlefishError, CuttlefishTimeout + +BASE = "http://localhost:2080" + +_ADB_PATCHES = [ + patch("jumpstarter_driver_adb.driver.shutil.which", return_value="/usr/bin/adb"), + patch("jumpstarter_driver_adb.driver.subprocess.run"), +] + + +@pytest.fixture +def drv(): + for p in _ADB_PATCHES: + p.start() + try: + yield Cuttlefish(group="cvd_1", name="dev1") + finally: + for p in _ADB_PATCHES: + p.stop() + + +def test_status_ok(requests_mock, drv): + requests_mock.get(f"{BASE}/_debug/statusz", text="ok") + assert drv.status() == "OK" + + +def test_status_connection_error(requests_mock, drv): + requests_mock.get(f"{BASE}/_debug/statusz", exc=requests.ConnectionError) + with pytest.raises(CuttlefishError, match="not connected"): + drv.status() + + +def test_list_cvds(requests_mock, drv): + body = {"cvds": [{"name": "dev1", "group": "cvd_1", "status": "Running"}]} + requests_mock.get(f"{BASE}/cvds", json=body) + result = json.loads(drv.list_cvds()) + assert result["cvds"][0]["name"] == "dev1" + + +def test_get_cvd(requests_mock, drv): + body = {"cvds": [{"name": "dev1", "group": "cvd_1", "adb_port": 6520}]} + requests_mock.get(f"{BASE}/cvds/cvd_1/dev1", json=body) + result = json.loads(drv.get_cvd()) + assert result["cvds"][0]["adb_port"] == 6520 + + +def test_get_cvd_http_error(requests_mock, drv): + requests_mock.get(f"{BASE}/cvds/cvd_1/dev1", status_code=404, json={"error": "not found"}) + with pytest.raises(CuttlefishError, match="failed"): + drv.get_cvd() + + +def test_create_cvd_ok(requests_mock, drv): + """Operation returns done=false, then completes on poll.""" + requests_mock.post(f"{BASE}/cvds", json={"name": "op-1", "done": False}) + requests_mock.post(f"{BASE}/operations/op-1/:wait", json={"name": "op-1", "done": True}) + config = {"env_config": {}} + result = json.loads(drv.create_cvd(json.dumps(config))) + assert result["done"] is True + history = [r for r in requests_mock.request_history if r.path == "/cvds"] + assert history[0].json() == config + + +def test_create_cvd_invalid_json(drv): + with pytest.raises(CuttlefishError, match="invalid JSON"): + drv.create_cvd("not json {{{") + + +def test_wait_503_retry(requests_mock, drv): + """503 should retry, then succeed.""" + requests_mock.post(f"{BASE}/cvds", json={"name": "op-1", "done": False}) + responses = [ + {"status_code": 503, "text": "unavailable"}, + {"status_code": 200, "json": {"name": "op-1", "done": True}}, + ] + requests_mock.post(f"{BASE}/operations/op-1/:wait", responses) + result = json.loads(drv.create_cvd("{}")) + assert result["done"] is True + + +def test_wait_504_retry(requests_mock, drv): + """504 should retry, then succeed.""" + requests_mock.post(f"{BASE}/cvds", json={"name": "op-1", "done": False}) + responses = [ + {"status_code": 504, "text": "timeout"}, + {"status_code": 200, "json": {"name": "op-1", "done": True}}, + ] + requests_mock.post(f"{BASE}/operations/op-1/:wait", responses) + result = json.loads(drv.create_cvd("{}")) + assert result["done"] is True + + +def test_wait_500_error_with_body(requests_mock, drv): + """500 with JSON error body should raise with message.""" + requests_mock.post(f"{BASE}/cvds", json={"name": "op-1", "done": False}) + requests_mock.post( + f"{BASE}/operations/op-1/:wait", + status_code=500, + json={"error": "disk full", "details": "no space left"}, + ) + with pytest.raises(CuttlefishError, match="disk full"): + drv.create_cvd("{}") + + +def test_wait_500_error_plain_text(requests_mock, drv): + """500 with non-JSON body should still raise.""" + requests_mock.post(f"{BASE}/cvds", json={"name": "op-1", "done": False}) + requests_mock.post(f"{BASE}/operations/op-1/:wait", status_code=500, text="internal error") + with pytest.raises(CuttlefishError, match="500"): + drv.create_cvd("{}") + + +@patch("jumpstarter_driver_cuttlefish.driver.time.sleep") +def test_wait_timeout(mock_sleep, requests_mock, drv): + """Operation that never completes should raise CuttlefishTimeout.""" + requests_mock.post(f"{BASE}/operations/op-1/:wait", exc=requests.Timeout) + with pytest.raises(CuttlefishTimeout, match="timed out"): + drv._wait_for_operation("op-1", timeout=0.1) + + +def test_wait_connection_lost(requests_mock, drv): + """Connection drop during polling should raise.""" + requests_mock.post(f"{BASE}/operations/op-1/:wait", exc=requests.ConnectionError) + with pytest.raises(CuttlefishError, match="lost connection"): + drv._wait_for_operation("op-1") + + +def test_wait_unexpected_http_error(requests_mock, drv): + """Non-500/503/504 error should raise.""" + requests_mock.post(f"{BASE}/operations/op-1/:wait", status_code=403, text="forbidden") + with pytest.raises(CuttlefishError, match="failed"): + drv._wait_for_operation("op-1") + + +def _mock_op(requests_mock, method, path, op_name="op-1"): + """Register mocks for an operation endpoint and its wait endpoint.""" + getattr(requests_mock, method)(f"{BASE}{path}", json={"name": op_name, "done": False}) + requests_mock.post(f"{BASE}/operations/{op_name}/:wait", json={"name": op_name, "done": True}) + + +def test_start_cvd(requests_mock, drv): + _mock_op(requests_mock, "post", "/cvds/cvd_1/dev1/:start") + result = json.loads(drv.start_cvd()) + assert result["done"] is True + + +def test_stop_cvd(requests_mock, drv): + _mock_op(requests_mock, "post", "/cvds/cvd_1/dev1/:stop") + result = json.loads(drv.stop_cvd()) + assert result["done"] is True + + +def test_restart_cvd(requests_mock, drv): + _mock_op(requests_mock, "post", "/cvds/cvd_1/dev1/:restart") + result = json.loads(drv.restart_cvd()) + assert result["done"] is True + + +def test_delete_cvd(requests_mock, drv): + _mock_op(requests_mock, "delete", "/cvds/cvd_1/dev1") + result = json.loads(drv.delete_cvd()) + assert result["done"] is True + + +def test_powerwash_cvd(requests_mock, drv): + _mock_op(requests_mock, "post", "/cvds/cvd_1/dev1/:powerwash") + result = json.loads(drv.powerwash_cvd()) + assert result["done"] is True + + +def test_powerbtn_cvd(requests_mock, drv): + _mock_op(requests_mock, "post", "/cvds/cvd_1/dev1/:powerbtn") + result = json.loads(drv.powerbtn_cvd()) + assert result["done"] is True + + +def test_list_operations(requests_mock, drv): + body = {"operations": [{"name": "op-1", "done": False}]} + requests_mock.get(f"{BASE}/operations", json=body) + result = json.loads(drv.list_operations()) + assert len(result["operations"]) == 1 + + +def test_get_adb_port_ok(requests_mock, drv): + body = {"cvds": [{"name": "dev1", "group": "cvd_1", "adb_port": 6520}]} + requests_mock.get(f"{BASE}/cvds/cvd_1/dev1", json=body) + assert drv.get_adb_port() == "6520" + + +def test_get_adb_port_no_cvds(requests_mock, drv): + requests_mock.get(f"{BASE}/cvds/cvd_1/dev1", json={"cvds": []}) + with pytest.raises(CuttlefishError, match="no ADB port"): + drv.get_adb_port() + + +def test_get_adb_port_missing_field(requests_mock, drv): + requests_mock.get(f"{BASE}/cvds/cvd_1/dev1", json={"cvds": [{"name": "dev1"}]}) + with pytest.raises(CuttlefishError, match="no ADB port"): + drv.get_adb_port() + + +def test_request_timeout(requests_mock, drv): + requests_mock.get(f"{BASE}/_debug/statusz", exc=requests.Timeout) + with pytest.raises(CuttlefishError, match="timed out"): + drv.status() + + +def test_request_non_json_response(requests_mock, drv): + requests_mock.get(f"{BASE}/_debug/statusz", text="ok", headers={"Content-Type": "text/plain"}) + assert drv.status() == "OK" + + +def test_request_custom_port(): + for p in _ADB_PATCHES: + p.start() + try: + drv = Cuttlefish(host="10.0.0.1", port=9090) + assert drv._base_url == "http://10.0.0.1:9090" + finally: + for p in _ADB_PATCHES: + p.stop() + + +def test_scheme_https(): + for p in _ADB_PATCHES: + p.start() + try: + drv = Cuttlefish(scheme="https", host="10.0.0.1", port=443) + assert drv._base_url == "https://10.0.0.1:443" + finally: + for p in _ADB_PATCHES: + p.stop() + + +def test_expected_adb_port(drv): + assert drv._expected_adb_port == 6520 + + +def test_expected_adb_port_instance_2(): + for p in _ADB_PATCHES: + p.start() + try: + drv = Cuttlefish(instance_num=3) + assert drv._expected_adb_port == 6522 + finally: + for p in _ADB_PATCHES: + p.stop() + + +def test_cvd_device(drv): + assert drv._cvd_device == "localhost:6520" + + +def test_get_host(drv): + assert drv.get_host() == "localhost" + + +def test_get_existing_cvds_ok(requests_mock, drv): + body = {"cvds": [{"name": "dev1", "group": "cvd_1"}]} + requests_mock.get(f"{BASE}/cvds", json=body) + assert len(drv._get_existing_cvds()) == 1 + + +def test_get_existing_cvds_unreachable(requests_mock, drv): + requests_mock.get(f"{BASE}/cvds", exc=requests.ConnectionError) + with pytest.raises(CuttlefishError, match="not connected"): + drv._get_existing_cvds() + + +def test_get_existing_cvds_non_dict(requests_mock, drv): + requests_mock.get(f"{BASE}/cvds", text="not json") + with pytest.raises(CuttlefishError, match="unexpected response"): + drv._get_existing_cvds() + + +def test_auto_connect_adb(drv): + drv.children["adb"] = MagicMock() + result = drv._auto_connect_adb() + assert result == "localhost:6520" + drv.children["adb"].connect_device.assert_called_once_with("localhost:6520") + + +def test_auto_connect_adb_failure(drv): + mock_adb = MagicMock() + mock_adb.connect_device.side_effect = RuntimeError("fail") + drv.children["adb"] = mock_adb + result = drv._auto_connect_adb() + assert result == "localhost:6520" + + +def test_auto_connect_adb_no_child(drv): + drv.children.pop("adb", None) + result = drv._auto_connect_adb() + assert result == "localhost:6520" + + +def test_auto_disconnect_adb(drv): + drv.children["adb"] = MagicMock() + drv._auto_disconnect_adb() + drv.children["adb"].disconnect_device.assert_called_once_with("localhost:6520") + + +def test_auto_disconnect_adb_failure(drv): + mock_adb = MagicMock() + mock_adb.disconnect_device.side_effect = RuntimeError("fail") + drv.children["adb"] = mock_adb + drv._auto_disconnect_adb() + + +def test_auto_disconnect_adb_no_child(drv): + drv.children.pop("adb", None) + drv._auto_disconnect_adb() + + +def test_wait_boot_no_adb_child(drv): + drv.children.pop("adb", None) + drv._wait_boot(timeout=1) + + +def test_wait_boot_wrapper_zero_timeout(drv): + drv.boot_timeout = 0 + assert drv.wait_boot(timeout=0) == "OK" + + +@patch("jumpstarter_driver_cuttlefish.driver.subprocess.run") +def test_wait_boot_success(mock_run, drv): + mock_adb = MagicMock() + mock_adb.adb_path = "/usr/bin/adb" + mock_adb.adb_env.return_value = {} + drv.children["adb"] = mock_adb + + call_count = [0] + + def fake_run(cmd, **kwargs): + call_count[0] += 1 + if "devices" in cmd: + return subprocess.CompletedProcess(cmd, 0, stdout="localhost:6520\tdevice\n") + if "getprop" in cmd: + return subprocess.CompletedProcess(cmd, 0, stdout="1\n") + return subprocess.CompletedProcess(cmd, 0, stdout="connected\n") + + mock_run.side_effect = fake_run + drv._wait_boot(timeout=30) + + +@patch("jumpstarter_driver_cuttlefish.driver.time.sleep") +@patch("jumpstarter_driver_cuttlefish.driver.subprocess.run") +def test_wait_boot_timeout(mock_run, mock_sleep, drv): + mock_adb = MagicMock() + mock_adb.adb_path = "/usr/bin/adb" + mock_adb.adb_env.return_value = {} + drv.children["adb"] = mock_adb + + mock_run.return_value = subprocess.CompletedProcess([], 0, stdout="") + with pytest.raises(CuttlefishTimeout, match="did not come online"): + drv._wait_boot(timeout=0.1) + + +def test_cvd_power_off_stop(requests_mock, drv): + power = drv.children["power"] + requests_mock.post(f"{BASE}/cvds/cvd_1/dev1/:stop", json={"name": "op-1", "done": False}) + requests_mock.post(f"{BASE}/operations/op-1/:wait", json={"name": "op-1", "done": True}) + power.off() + + +def test_cvd_power_off_destroy(requests_mock, drv): + drv.children["adb"] = MagicMock() + power = drv.children["power"] + requests_mock.delete(f"{BASE}/cvds/cvd_1/dev1", json={"name": "op-1", "done": False}) + requests_mock.post(f"{BASE}/operations/op-1/:wait", json={"name": "op-1", "done": True}) + power.off(destroy=True) + assert drv._cvd_group is None + assert drv._cvd_name is None + + +def test_cvd_power_on_existing_running(requests_mock, drv): + drv.children["adb"] = MagicMock() + drv.boot_timeout = 0 + power = drv.children["power"] + requests_mock.get( + f"{BASE}/cvds", + json={"cvds": [{"name": "dev1", "group": "cvd_1", "status": "Running"}]}, + ) + power.on() + assert drv._cvd_group == "cvd_1" + assert drv._cvd_name == "dev1" + + +def test_cvd_power_on_existing_stopped(requests_mock, drv): + drv.children["adb"] = MagicMock() + drv.boot_timeout = 0 + power = drv.children["power"] + requests_mock.get( + f"{BASE}/cvds", + json={"cvds": [{"name": "dev1", "group": "cvd_1", "status": "Stopped"}]}, + ) + requests_mock.post(f"{BASE}/cvds/cvd_1/dev1/:start", json={"name": "op-1", "done": False}) + requests_mock.post(f"{BASE}/operations/op-1/:wait", json={"name": "op-1", "done": True}) + power.on() + + +def test_cvd_power_on_create_new(requests_mock, drv): + drv.children["adb"] = MagicMock() + drv.boot_timeout = 0 + power = drv.children["power"] + requests_mock.get(f"{BASE}/cvds", json={"cvds": []}) + requests_mock.post(f"{BASE}/cvds", json={"name": "op-1", "done": False}) + requests_mock.post( + f"{BASE}/operations/op-1/:wait", + json={"name": "op-1", "done": True, "cvds": [{"group": "cvd_1", "name": "dev1", "adb_port": 6520}]}, + ) + power.on() + assert drv._cvd_group == "cvd_1" + assert drv._cvd_name == "dev1" + + +def test_cvd_power_on_stale_cleanup(requests_mock, drv): + drv.children["adb"] = MagicMock() + drv.boot_timeout = 0 + power = drv.children["power"] + requests_mock.get( + f"{BASE}/cvds", + json={ + "cvds": [ + {"name": "d1", "group": "cvd_1"}, + {"name": "d2", "group": "cvd_1"}, + ] + }, + ) + requests_mock.delete(f"{BASE}/cvds/cvd_1/d1", json={"name": "op-d1", "done": False}) + requests_mock.delete(f"{BASE}/cvds/cvd_1/d2", json={"name": "op-d2", "done": False}) + requests_mock.post(f"{BASE}/operations/op-d1/:wait", json={"name": "op-d1", "done": True}) + requests_mock.post(f"{BASE}/operations/op-d2/:wait", json={"name": "op-d2", "done": True}) + requests_mock.post(f"{BASE}/cvds", json={"name": "op-c", "done": False}) + requests_mock.post(f"{BASE}/operations/op-c/:wait", json={"name": "op-c", "done": True}) + power.on() + + +def test_cvd_power_on_stale_cleanup_failure(requests_mock, drv): + """Failed stale CVD deletion aborts instead of creating duplicate.""" + drv.children["adb"] = MagicMock() + drv.boot_timeout = 0 + power = drv.children["power"] + requests_mock.get( + f"{BASE}/cvds", + json={ + "cvds": [ + {"name": "d1", "group": "cvd_1"}, + {"name": "d2", "group": "cvd_1"}, + ] + }, + ) + requests_mock.delete(f"{BASE}/cvds/cvd_1/d1", status_code=500, json={"error": "busy"}) + requests_mock.delete(f"{BASE}/cvds/cvd_1/d2", json={"name": "op-d2", "done": False}) + requests_mock.post(f"{BASE}/operations/op-d2/:wait", json={"name": "op-d2", "done": True}) + with pytest.raises(CuttlefishError, match="failed to delete stale CVDs"): + power.on() + + +def test_cvd_power_on_port_mismatch(requests_mock, drv): + drv.children["adb"] = MagicMock() + drv.boot_timeout = 0 + power = drv.children["power"] + requests_mock.get(f"{BASE}/cvds", json={"cvds": []}) + requests_mock.post(f"{BASE}/cvds", json={"name": "op-1", "done": False}) + requests_mock.post( + f"{BASE}/operations/op-1/:wait", + json={"name": "op-1", "done": True, "cvds": [{"group": "cvd_1", "name": "dev1", "adb_port": 9999}]}, + ) + requests_mock.delete(f"{BASE}/cvds/cvd_1/dev1", json={"name": "op-del", "done": False}) + requests_mock.post(f"{BASE}/operations/op-del/:wait", json={"name": "op-del", "done": True}) + with pytest.raises(CuttlefishError, match="adb_port 9999"): + power.on() + assert any(r.method == "DELETE" for r in requests_mock.request_history) + + +def test_cvd_power_read_not_implemented(drv): + power = drv.children["power"] + with pytest.raises(NotImplementedError): + list(power.read()) + + +def test_cvd_flasher_flash_not_implemented(drv): + flasher = drv.children["storage"] + with pytest.raises(NotImplementedError): + flasher.flash("source") + + +def test_cvd_flasher_dump_not_implemented(drv): + flasher = drv.children["storage"] + with pytest.raises(NotImplementedError): + flasher.dump("target") + + +def test_cvd_power_on_ignores_other_groups(requests_mock, drv): + """CVDs from other groups are not touched.""" + drv.children["adb"] = MagicMock() + drv.boot_timeout = 0 + power = drv.children["power"] + requests_mock.get( + f"{BASE}/cvds", + json={ + "cvds": [ + {"name": "d1", "group": "other_group", "status": "Running"}, + {"name": "dev1", "group": "cvd_1", "status": "Running"}, + ] + }, + ) + power.on() + assert drv._cvd_group == "cvd_1" + assert drv._cvd_name == "dev1" + assert not any(r.method == "DELETE" for r in requests_mock.request_history) diff --git a/python/packages/jumpstarter-driver-cuttlefish/pyproject.toml b/python/packages/jumpstarter-driver-cuttlefish/pyproject.toml new file mode 100644 index 000000000..da3b5d3cc --- /dev/null +++ b/python/packages/jumpstarter-driver-cuttlefish/pyproject.toml @@ -0,0 +1,47 @@ +[project] +name = "jumpstarter-driver-cuttlefish" +dynamic = ["version", "urls"] +description = "Cuttlefish Host Orchestrator driver for CVD lifecycle management" +license = "Apache-2.0" +authors = [{ name = "Benny Zlotnik", email = "bzlotnik@redhat.com" }] +requires-python = ">=3.11" +dependencies = [ + "click>=8.0.0", + "jumpstarter", + "jumpstarter-driver-adb", + "jumpstarter-driver-composite", + "jumpstarter-driver-power", + "requests>=2.28.0", +] + +[project.entry-points."jumpstarter.drivers"] +Cuttlefish = "jumpstarter_driver_cuttlefish.driver:Cuttlefish" + +[tool.hatch.version] +source = "vcs" +raw-options = { 'root' = '../../../' } + +[tool.hatch.metadata.hooks.vcs.urls] +Homepage = "https://jumpstarter.dev" +source_archive = "https://github.com/jumpstarter-dev/repo/archive/{commit_hash}.zip" + +[tool.pytest.ini_options] +addopts = "--cov=jumpstarter_driver_cuttlefish --cov-report=html --cov-report=xml" +asyncio_mode = "strict" +asyncio_default_fixture_loop_scope = "function" +testpaths = ["jumpstarter_driver_cuttlefish"] + +[build-system] +requires = ["hatchling", "hatch-vcs", "hatch-pin-jumpstarter"] +build-backend = "hatchling.build" + +[dependency-groups] +dev = [ + "pytest-cov>=6.0.0", + "pytest>=8.3.3", + "pytest-asyncio>=0.24.0", + "requests-mock>=1.12.0", +] + +[tool.hatch.build.hooks.pin_jumpstarter] +name = "pin_jumpstarter" diff --git a/python/pyproject.toml b/python/pyproject.toml index f0f1dcef4..79cf012fe 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -12,8 +12,9 @@ jumpstarter-driver-androidemulator = { workspace = true } jumpstarter-driver-ble = { workspace = true } jumpstarter-driver-can = { workspace = true } jumpstarter-driver-composite = { workspace = true } -jumpstarter-driver-doip = { workspace = true } jumpstarter-driver-corellium = { workspace = true } +jumpstarter-driver-cuttlefish = { workspace = true } +jumpstarter-driver-doip = { workspace = true } jumpstarter-driver-dut-network = { workspace = true } jumpstarter-driver-dutlink = { workspace = true } jumpstarter-driver-energenie = { workspace = true }