diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/e2e/__init__.py | 0 | ||||
| -rw-r--r-- | tests/e2e/conftest.py | 96 | ||||
| -rw-r--r-- | tests/e2e/test_vydevice_live.py | 71 | ||||
| -rw-r--r-- | tests/pve/.env.example | 70 | ||||
| -rw-r--r-- | tests/pve/README.md | 162 | ||||
| -rwxr-xr-x | tests/pve/_lib.sh | 128 | ||||
| -rw-r--r-- | tests/pve/cloud-init/meta-data.template | 2 | ||||
| -rw-r--r-- | tests/pve/cloud-init/user-data.template | 14 | ||||
| -rwxr-xr-x | tests/pve/create-vm.sh | 42 | ||||
| -rwxr-xr-x | tests/pve/destroy-vm.sh | 12 | ||||
| -rwxr-xr-x | tests/pve/ensure-template.sh | 175 | ||||
| -rwxr-xr-x | tests/pve/preflight.sh | 45 | ||||
| -rwxr-xr-x | tests/pve/run-e2e.sh | 26 | ||||
| -rwxr-xr-x | tests/pve/start-vm.sh | 10 | ||||
| -rwxr-xr-x | tests/pve/stop-vm.sh | 10 |
15 files changed, 863 insertions, 0 deletions
diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/e2e/__init__.py diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py new file mode 100644 index 0000000..893afce --- /dev/null +++ b/tests/e2e/conftest.py @@ -0,0 +1,96 @@ +"""Shared fixtures for pyvyos live end-to-end tests. + +These tests run only when PYVYOS_E2E=1 is set in the environment. +They expect a VyOS HTTPS API reachable at VYDEVICE_HOSTNAME with the +key in VYDEVICE_APIKEY. The harness under tests/pve/ provides one +way to stand up such a VM on a local Proxmox host, but any VyOS +instance you can reach works. +""" + +from __future__ import annotations + +import os +import time + +import pytest + +from pyvyos import VyDevice + + +E2E_ENABLED = os.environ.get("PYVYOS_E2E") == "1" + + +def _env_bool(name: str, default: str = "false") -> bool: + return os.environ.get(name, default).lower() in {"1", "true", "yes"} + + +@pytest.fixture(scope="session") +def device() -> VyDevice: + """A VyDevice pointed at the live VyOS under test. + + Session-scoped because the device is stateless on the client side + and reusing it keeps the suite fast. + """ + missing = [ + v for v in ("VYDEVICE_HOSTNAME", "VYDEVICE_APIKEY") if not os.environ.get(v) + ] + if missing: + pytest.fail( + f"missing required environment variables: {missing}. " + "See tests/pve/.env.example for the full set." + ) + + return VyDevice( + hostname=os.environ["VYDEVICE_HOSTNAME"], + apikey=os.environ["VYDEVICE_APIKEY"], + protocol=os.environ.get("VYDEVICE_PROTOCOL", "https"), + port=int(os.environ.get("VYDEVICE_PORT", "443")), + verify=_env_bool("VYDEVICE_VERIFY_SSL"), + timeout=10, + ) + + +@pytest.fixture(scope="session", autouse=True) +def _wait_for_api(device: VyDevice) -> None: + """Block the suite until the HTTPS API answers, or fail loudly. + + A freshly cloned VM may need a moment after `qm start` before the + HTTPS service binds. We retry a cheap read for up to two minutes. + """ + if not E2E_ENABLED: + return + + deadline = time.time() + 120 + last_error: str | None = None + while time.time() < deadline: + try: + response = device.show(path=["system", "image"]) + if not response.error: + return + last_error = response.error + except Exception as exc: # noqa: BLE001 — broad on purpose, we retry + last_error = repr(exc) + time.sleep(3) + + pytest.fail( + f"VyOS HTTPS API at {os.environ.get('VYDEVICE_HOSTNAME')} did not become " + f"ready within 120s. Last error: {last_error}" + ) + + +def pytest_collection_modifyitems(config, items) -> None: + """Skip only e2e tests unless PYVYOS_E2E=1. + + This conftest is auto-discovered by pytest at the session level, + so the hook sees every collected item in the run. Filter by path + to avoid skipping the unit suite under tests/modules/. + """ + if E2E_ENABLED: + return + skip = pytest.mark.skip( + reason="set PYVYOS_E2E=1 to run live VyOS end-to-end tests" + ) + e2e_dir = os.path.join("tests", "e2e") + os.sep + for item in items: + if e2e_dir in str(item.fspath): + item.add_marker(skip) diff --git a/tests/e2e/test_vydevice_live.py b/tests/e2e/test_vydevice_live.py new file mode 100644 index 0000000..7eaadd4 --- /dev/null +++ b/tests/e2e/test_vydevice_live.py @@ -0,0 +1,71 @@ +"""Live VyOS smoke tests for pyvyos. + +These are intentionally a small, opinionated subset of the SDK: +just enough to prove that payloads round-trip against a real VyOS +HTTPS API. Destructive operations (reboot/poweroff/image-add/ +image-delete/reset/config-file-load) live in a separate file when +they exist; not in scope for v0.4.x. +""" + +from __future__ import annotations + +import random + +from pyvyos import ApiResponse, VyDevice + + +def _ok(response: ApiResponse) -> None: + assert response.status == 200, f"status={response.status} error={response.error!r}" + assert not response.error, f"unexpected error: {response.error!r}" + + +def _dummy_name() -> str: + """VyOS requires dummy interfaces to match `dumN`. + + Pick a high random N to avoid collision with anything an operator + may have created manually. + """ + return f"dum{random.randint(900, 9999)}" + + +def test_show_system_image(device: VyDevice) -> None: + response = device.show(path=["system", "image"]) + _ok(response) + assert response.result is not None + + +def test_retrieve_show_config_system(device: VyDevice) -> None: + response = device.retrieve_show_config(path=["system"]) + _ok(response) + assert isinstance(response.result, dict) + + +def test_configure_set_read_delete_dummy_iface(device: VyDevice) -> None: + name = _dummy_name() + address = "192.0.2.10/32" + iface_path = ["interfaces", "dummy", name] + + try: + set_response = device.configure_set(path=iface_path + ["address", address]) + _ok(set_response) + + read_response = device.retrieve_return_values(path=iface_path + ["address"]) + _ok(read_response) + # retrieve_return_values returns a list of stringified values. + assert address in str(read_response.result), read_response.result + finally: + cleanup = device.configure_delete(path=iface_path) + _ok(cleanup) + + +def test_configure_multiple_op_batch(device: VyDevice) -> None: + name = _dummy_name() + iface_path = ["interfaces", "dummy", name] + + response = device.configure_multiple_op( + op_path=[ + {"op": "set", "path": iface_path + ["address", "192.0.2.11/32"]}, + {"op": "delete", "path": iface_path}, + ] + ) + _ok(response) diff --git a/tests/pve/.env.example b/tests/pve/.env.example new file mode 100644 index 0000000..428b519 --- /dev/null +++ b/tests/pve/.env.example @@ -0,0 +1,70 @@ +# PVE end-to-end harness — sample environment. +# +# Copy this file to tests/pve/.env and fill it in. +# tests/pve/.env is gitignored and never committed. +# +# These scripts ssh to a Proxmox VE host you control and create a +# disposable VyOS VM to run pyvyos against. They do not modify any +# production state and they do not run in CI. + +# --- PVE host access ------------------------------------------------- +# Anything `ssh` accepts: a bare hostname, an alias from ~/.ssh/config, +# or user@host. The value below is a placeholder; replace it with your +# own host or alias, e.g. `pve.example.local` or your ~/.ssh/config +# alias. +PVE_SSH_TARGET=pve.example.local + +# PVE node name as `hostname -s` reports it on the PVE host. +PVE_NODE=pve + +# Storage where VM disks land. Anything `pvesm status` lists. +PVE_STORAGE=local-lvm + +# Bridge the VyOS VM attaches to. +PVE_BRIDGE=vmbr0 + +# Directory on the PVE host that holds ISO and qcow2 images. +# Standard PVE layout. +PVE_IMAGE_DIR=/var/lib/vz/template/iso + +# --- VyOS install ISO ------------------------------------------------ +# Path on the PVE host to the VyOS rolling install ISO. The harness +# does not download it; place it there yourself (e.g. from +# https://github.com/vyos/vyos-nightly-build/releases) and pin the +# filename here. Bump deliberately when you want a fresher build. +VYOS_ISO_PATH=/var/lib/vz/template/iso/vyos-rolling.iso +VYOS_IMAGE_URL=https://github.com/vyos/vyos-nightly-build/releases + +# --- VyOS template (built once, then reused) ------------------------- +# VMID reserved for the template. Pick a value well outside your +# normal VM range. Must be free the first time you run ensure-template. +VYOS_TEMPLATE_VMID=9203 +VYOS_TEMPLATE_NAME=vyos-pyvyos-e2e-template + +# --- e2e VM (clone of the template) ---------------------------------- +VYOS_E2E_VMID=9900 +VYOS_E2E_VM_NAME=pyvyos-e2e +VYOS_E2E_MEMORY=1024 +VYOS_E2E_CORES=1 + +# Static IP for the e2e VM. Must be free on PVE_BRIDGE. +VYOS_E2E_IP=192.0.2.10 +VYOS_E2E_CIDR=24 +VYOS_E2E_GATEWAY=192.0.2.1 + +# Seconds to wait for cloud-init to apply the seed inside the freshly +# installed VyOS image. 120s is comfortable; lower it if your hardware +# is fast and you are tired of waiting. +VYOS_CLOUD_INIT_WAIT_SECONDS=120 + +# --- VyOS HTTPS API -------------------------------------------------- +# API key baked into the template at template-build time. The clones +# inherit it. Treat as a lab secret; rotate by rebuilding the template. +VYDEVICE_APIKEY=pyvyos-e2e-please-change-me + +# pyvyos client settings. Derived from the e2e VM above; only override +# if you change listen-address or port in cloud-init. +VYDEVICE_HOSTNAME=192.0.2.10 +VYDEVICE_PORT=443 +VYDEVICE_PROTOCOL=https +VYDEVICE_VERIFY_SSL=false diff --git a/tests/pve/README.md b/tests/pve/README.md new file mode 100644 index 0000000..b84ad05 --- /dev/null +++ b/tests/pve/README.md @@ -0,0 +1,162 @@ +# PVE end-to-end harness + +This directory holds a small, opinionated harness to run pyvyos +against a real VyOS VM on a local Proxmox VE host. + +It is intentionally **not** part of CI. The unit test suite under +`tests/modules/` is fast, mock-based, and runs on every PR. The +harness here exists for maintainers who want a periodic reality +check — the gap a mock cannot close. + +> This harness creates confidence against real VyOS without turning +> the project into a CI of appliances. + +## What it gives you + +A reproducible flow: + +```text +ensure-template.sh # build a reusable VyOS template once +create-vm.sh # clone the template into a fresh e2e VM +start-vm.sh # power it on +run-e2e.sh # run pytest tests/e2e against it +stop-vm.sh # power it off +destroy-vm.sh # purge it +``` + +The template carries an API key baked at build time via cloud-init. +Clones inherit it, so the e2e VM is reachable as soon as the API is +up. + +## Requirements + +- a Proxmox VE host you can `ssh` to (preferably via an alias in + `~/.ssh/config`) +- a VyOS rolling install ISO already on the PVE host, under + `/var/lib/vz/template/iso/` (download from + https://github.com/vyos/vyos-nightly-build/releases; this harness + does **not** download it for you) +- `genisoimage` (or `mkisofs`) installed on the PVE host — + `apt install genisoimage` once +- Python 3.11+ and `uv` (or `pytest`) on your workstation to run the + test suite itself + +## First-time setup + +```bash +cp tests/pve/.env.example tests/pve/.env +$EDITOR tests/pve/.env # fill in your host, VMIDs, IP, API key +chmod +x tests/pve/*.sh + +tests/pve/preflight.sh # read-only sanity check +tests/pve/ensure-template.sh # phase 1: creates VM with installer +``` + +The first run of `ensure-template.sh` boots a VM with the VyOS +installer ISO attached and prints instructions: + +```text +on the PVE host: + qm terminal <VMID> # Ctrl-] exits + +inside VyOS: + login: vyos / vyos + install image # accept defaults, set a password + poweroff +``` + +Do that once. Then run it again: + +```bash +tests/pve/ensure-template.sh # phase 2: cloud-init applies API key, + # shuts down, flips template flag +``` + +The script is a state machine on the VMID, so re-running it from any +point is safe. + +## Per-run workflow + +```bash +tests/pve/create-vm.sh +tests/pve/start-vm.sh +tests/pve/run-e2e.sh +tests/pve/stop-vm.sh +tests/pve/destroy-vm.sh +``` + +`run-e2e.sh` exports the `VYDEVICE_*` variables from `.env` and runs +`pytest tests/e2e -v` with `PYVYOS_E2E=1`. Without that variable the +e2e tests are skipped automatically. + +## Scope + +The live suite is intentionally small: + +| Test | What it proves | +| ------------------------------------ | ----------------------------- | +| `test_show_system_image` | operational API responds | +| `test_retrieve_show_config_system` | config retrieval works | +| `test_configure_set_read_delete...` | set / read-back / delete loop | +| `test_configure_multiple_op_batch` | the batch payload is correct | + +Out of scope on purpose: `reboot`, `poweroff`, `image_add`, +`image_delete`, `reset`, `config_file_load`. Those are destructive or +slow; they will get their own opt-in file if anyone needs them. + +## Troubleshooting + +### Cloud-init did not apply on first boot + +VyOS cloud-init handling varies between rolling builds. If the API +key is not active after phase 2, boot the VM, configure the API +manually once, save, poweroff, and rerun `ensure-template.sh`: + +```text +configure +set service https api rest +set service https api keys id pyvyos key 'pyvyos-e2e-please-change-me' +set service https listen-address '0.0.0.0' +commit +save +exit +poweroff +``` + +The `set service https api rest` line is essential. Without it the +HTTPS service only exposes `/info`, and every other endpoint +(`/retrieve`, `/configure`, `/show`, …) responds with `404`. See +the VyOS docs for HTTP API service. + +### HTTP 400 "Dummy interface must be named dumN" + +The live tests intentionally use interface names like `dum1234` to +match the VyOS naming policy for `dummy` interfaces. If you adapt +the tests, keep that pattern; arbitrary names are rejected by VyOS +config validation with a 400. + +### "VMID already exists" + +Each script checks before mutating. To start over from scratch: + +```bash +tests/pve/destroy-vm.sh # clears the e2e VM +ssh $PVE_SSH_TARGET qm destroy $VYOS_TEMPLATE_VMID --purge +tests/pve/ensure-template.sh # phase 1 again +``` + +### API key in the template + +The key from `tests/pve/.env` is baked into the template. That is +fine for a private lab. **Do not** export this template to a shared +PVE or hand it to other people without rotating the key. + +## What this harness is not + +- Not Vagrant. `examples/vagrant/` already covers desktop labs. +- Not Docker. VyOS is an appliance OS; real KVM is the honest test. +- Not CI. GitHub Actions cannot run nested KVM cheaply, and a + scheduled live job on someone else's hardware is not worth the + operational cost for a thin SDK. +- Not a packaging story. The harness is for maintainers and brave + users; nothing in `pyvyos/` depends on it. diff --git a/tests/pve/_lib.sh b/tests/pve/_lib.sh new file mode 100755 index 0000000..10a4242 --- /dev/null +++ b/tests/pve/_lib.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# tests/pve/_lib.sh — source-only helpers for the PVE e2e harness. +# +# Public surface: +# pve_load_env [path] load tests/pve/.env (or argument) and validate +# pve_require_reachable fail unless `ssh $PVE_SSH_TARGET true` works +# pve_ssh "cmd..." run a command on the PVE host +# pve_qm args... shorthand for `pve_ssh qm args...` +# pve_scp src dst copy a local file to the PVE host +# pve_template_phase echo: missing | running | stopped | template +# hdr "title" print a section header to stderr +# +# Refuses direct execution. + +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + echo "ERR: tests/pve/_lib.sh is source-only; do not execute it." >&2 + exit 1 +fi + +set -euo pipefail + +PVE_LIB_REQUIRED_VARS=( + PVE_SSH_TARGET + PVE_NODE + PVE_STORAGE + PVE_BRIDGE + PVE_IMAGE_DIR + VYOS_ISO_PATH + VYOS_TEMPLATE_VMID + VYOS_TEMPLATE_NAME + VYOS_E2E_VMID + VYOS_E2E_VM_NAME + VYOS_E2E_IP + VYOS_E2E_CIDR + VYOS_E2E_GATEWAY + VYDEVICE_APIKEY +) + +pve_load_env() { + local env_file="${1:-}" + if [[ -z "$env_file" ]]; then + env_file="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/.env" + fi + if [[ ! -f "$env_file" ]]; then + echo "ERR: missing env file: $env_file" >&2 + echo " copy tests/pve/.env.example to tests/pve/.env and fill it in." >&2 + return 1 + fi + set -a + # shellcheck disable=SC1090 + source "$env_file" + set +a + + local missing=() + local v + for v in "${PVE_LIB_REQUIRED_VARS[@]}"; do + if [[ -z "${!v:-}" ]]; then + missing+=("$v") + fi + done + if (( ${#missing[@]} > 0 )); then + echo "ERR: env file $env_file is missing values for: ${missing[*]}" >&2 + return 1 + fi +} + +pve_ssh() { + # ssh joins remaining args with spaces and hands the result to the + # remote login shell, so any unquoted `;`, `&`, `|`, `$`, glob, etc. + # would be re-interpreted there. When the caller passes a single + # argument we treat it as a verbatim remote script. When several are + # passed we shell-escape each one with `printf %q` so the remote + # shell sees exactly one argv per local argv. + if (( $# == 1 )); then + ssh -o BatchMode=yes -o ConnectTimeout=10 "$PVE_SSH_TARGET" "$1" + else + local cmd + # shellcheck disable=SC2059 + cmd="$(printf '%q ' "$@")" + ssh -o BatchMode=yes -o ConnectTimeout=10 "$PVE_SSH_TARGET" "$cmd" + fi +} + +pve_qm() { + pve_ssh qm "$@" +} + +pve_scp() { + local src="$1" dst="$2" + scp -o BatchMode=yes -o ConnectTimeout=10 "$src" "$PVE_SSH_TARGET:$dst" +} + +pve_require_reachable() { + if ! pve_ssh true >/dev/null 2>&1; then + echo "ERR: cannot ssh to PVE host '$PVE_SSH_TARGET'." >&2 + echo " check ~/.ssh/config, your key, and that the host is up." >&2 + return 1 + fi +} + +# Echo one of: +# missing — VMID not registered in PVE +# running — VM exists and is running +# stopped — VM exists, not a template, currently stopped +# template — VM exists and is flagged as a template +pve_template_phase() { + local vmid="$1" + if ! pve_qm status "$vmid" >/dev/null 2>&1; then + echo missing + return + fi + if pve_qm config "$vmid" | grep -q '^template: 1'; then + echo template + return + fi + # `qm status <vmid>` prints e.g. "status: running" + local s + s="$(pve_qm status "$vmid" | awk '{print $2}')" + if [[ "$s" == "running" ]]; then + echo running + else + echo stopped + fi +} + +hdr() { + printf '\n=== %s ===\n' "$*" >&2 +} diff --git a/tests/pve/cloud-init/meta-data.template b/tests/pve/cloud-init/meta-data.template new file mode 100644 index 0000000..bf0e4fe --- /dev/null +++ b/tests/pve/cloud-init/meta-data.template @@ -0,0 +1,2 @@ +instance-id: __INSTANCE_ID__ +local-hostname: __HOSTNAME__ diff --git a/tests/pve/cloud-init/user-data.template b/tests/pve/cloud-init/user-data.template new file mode 100644 index 0000000..d1ac0ee --- /dev/null +++ b/tests/pve/cloud-init/user-data.template @@ -0,0 +1,14 @@ +#cloud-config +# VyOS NoCloud user-data baked into the template. +# +# Placeholders replaced at render time by ensure-template.sh: +# __APIKEY__ pyvyos HTTPS API key +# __HOSTNAME__ initial system hostname + +vyos_config_commands: + - set system host-name '__HOSTNAME__' + - set service https api rest + - set service https api keys id pyvyos key '__APIKEY__' + - set service https listen-address '0.0.0.0' + - set service https port '443' + - set service ssh port '22' diff --git a/tests/pve/create-vm.sh b/tests/pve/create-vm.sh new file mode 100755 index 0000000..af34a66 --- /dev/null +++ b/tests/pve/create-vm.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# tests/pve/create-vm.sh — clone the template into a fresh e2e VM. +# +# Refuses to overwrite an existing VMID. Use destroy-vm.sh first if +# the e2e VMID is already taken. + +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=_lib.sh +source "$HERE/_lib.sh" + +pve_load_env +pve_require_reachable + +hdr "Check e2e VMID $VYOS_E2E_VMID is free" +if pve_qm status "$VYOS_E2E_VMID" >/dev/null 2>&1; then + echo "ERR: VMID $VYOS_E2E_VMID already exists." >&2 + echo " run tests/pve/destroy-vm.sh first, or pick a different VYOS_E2E_VMID." >&2 + exit 1 +fi + +hdr "Check template $VYOS_TEMPLATE_VMID exists" +if ! pve_qm config "$VYOS_TEMPLATE_VMID" | grep -q '^template: 1'; then + echo "ERR: template VMID $VYOS_TEMPLATE_VMID not found." >&2 + echo " run tests/pve/ensure-template.sh first." >&2 + exit 1 +fi + +hdr "qm clone $VYOS_TEMPLATE_VMID -> $VYOS_E2E_VMID" +pve_qm clone "$VYOS_TEMPLATE_VMID" "$VYOS_E2E_VMID" \ + --name "$VYOS_E2E_VM_NAME" --full true + +hdr "qm set memory/cores/static-ip" +# The template already carries the HTTPS API key (baked by cloud-init +# at template-build time), so this clone does not need another seed. +# We only override the static IP via PVE's ipconfig drive. +pve_qm set "$VYOS_E2E_VMID" \ + --memory "$VYOS_E2E_MEMORY" --cores "$VYOS_E2E_CORES" \ + --ipconfig0 "ip=$VYOS_E2E_IP/$VYOS_E2E_CIDR,gw=$VYOS_E2E_GATEWAY" + +echo "ok: VM $VYOS_E2E_VMID created (not started). Next: tests/pve/start-vm.sh" >&2 diff --git a/tests/pve/destroy-vm.sh b/tests/pve/destroy-vm.sh new file mode 100755 index 0000000..ad3b22a --- /dev/null +++ b/tests/pve/destroy-vm.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=_lib.sh +source "$HERE/_lib.sh" +pve_load_env +pve_require_reachable +hdr "Destroy VM $VYOS_E2E_VMID (purge disks)" +pve_ssh "qm status $VYOS_E2E_VMID >/dev/null 2>&1 || { echo 'not present, nothing to do'; exit 0; }" +pve_ssh "qm stop $VYOS_E2E_VMID || true" +pve_ssh "qm destroy $VYOS_E2E_VMID --purge" +hdr "ok" diff --git a/tests/pve/ensure-template.sh b/tests/pve/ensure-template.sh new file mode 100755 index 0000000..5243dde --- /dev/null +++ b/tests/pve/ensure-template.sh @@ -0,0 +1,175 @@ +#!/usr/bin/env bash +# tests/pve/ensure-template.sh — idempotently build the VyOS template. +# +# State machine on $VYOS_TEMPLATE_VMID: +# +# template -> nothing to do; exit 0. +# +# missing -> phase 1: +# qm create with the install ISO + seed CD + empty disk, +# boot, print operator instructions, exit 0. +# You then open the serial console, run `install image`, +# poweroff, and rerun this script. +# +# running -> phase 1 still in progress; print instructions, exit 1. +# +# stopped -> phase 2: +# swap boot order to the installed disk, start, wait for +# cloud-init to apply the seed (vyos_config_commands sets +# the HTTPS API key), shutdown, detach the install ISO +# and the seed, qm template. + +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=_lib.sh +source "$HERE/_lib.sh" + +pve_load_env +pve_require_reachable + +VYOS_CLOUD_INIT_WAIT_SECONDS="${VYOS_CLOUD_INIT_WAIT_SECONDS:-120}" + +SEED_ISO_REMOTE="$PVE_IMAGE_DIR/${VYOS_TEMPLATE_NAME}-seed.iso" + +render_and_upload_seed() { + # Generates the NoCloud seed ISO on the PVE host itself. Avoids + # requiring genisoimage/mkisofs on the workstation, and keeps the + # rendered API key off the local filesystem. + hdr "Render and build seed ISO on PVE host" + pve_ssh "command -v genisoimage >/dev/null 2>&1 || command -v mkisofs >/dev/null 2>&1 || { + echo 'ERR: PVE host has neither genisoimage nor mkisofs. apt install genisoimage' >&2 + exit 1 + }" + + local user_data meta_data + user_data="$(sed -e "s|__APIKEY__|$VYDEVICE_APIKEY|g" \ + -e "s|__HOSTNAME__|$VYOS_TEMPLATE_NAME|g" \ + "$HERE/cloud-init/user-data.template")" + meta_data="$(sed -e "s|__INSTANCE_ID__|$VYOS_TEMPLATE_NAME-$(date +%s)|g" \ + -e "s|__HOSTNAME__|$VYOS_TEMPLATE_NAME|g" \ + "$HERE/cloud-init/meta-data.template")" + + pve_ssh "set -e + tmp=\$(mktemp -d) + trap 'rm -rf \$tmp' EXIT + cat > \$tmp/user-data <<'PYVYOS_USERDATA_EOF' +$user_data +PYVYOS_USERDATA_EOF + cat > \$tmp/meta-data <<'PYVYOS_METADATA_EOF' +$meta_data +PYVYOS_METADATA_EOF + if command -v genisoimage >/dev/null 2>&1; then + genisoimage -output '$SEED_ISO_REMOTE' -volid cidata -joliet -rock \ + \$tmp/user-data \$tmp/meta-data >/dev/null 2>&1 + else + mkisofs -output '$SEED_ISO_REMOTE' -volid cidata -joliet -rock \ + \$tmp/user-data \$tmp/meta-data >/dev/null 2>&1 + fi + " + echo "seed ISO at $SEED_ISO_REMOTE" >&2 +} + +phase1_create_install_vm() { + hdr "Phase 1: create VM with install ISO and seed" + + if ! pve_ssh "test -s '$VYOS_ISO_PATH'"; then + echo "ERR: VyOS install ISO not found on PVE host at $VYOS_ISO_PATH" >&2 + echo " download a rolling build from $VYOS_IMAGE_URL" >&2 + echo " and place it at that path, or update VYOS_ISO_PATH." >&2 + return 1 + fi + + render_and_upload_seed + + pve_qm create "$VYOS_TEMPLATE_VMID" \ + --name "$VYOS_TEMPLATE_NAME" \ + --memory 1024 --cores 1 \ + --net0 "virtio,bridge=$PVE_BRIDGE" \ + --serial0 socket --vga serial0 \ + --scsihw virtio-scsi-pci \ + --ostype l26 \ + --agent enabled=1 + + # 4G disk is plenty for a router appliance. + pve_qm set "$VYOS_TEMPLATE_VMID" --scsi0 "$PVE_STORAGE:4" + + # ide2 = install ISO (boot here first). ide3 = NoCloud seed. + pve_qm set "$VYOS_TEMPLATE_VMID" \ + --ide2 "$VYOS_ISO_PATH,media=cdrom" \ + --ide3 "$SEED_ISO_REMOTE,media=cdrom" \ + --boot "order=ide2;scsi0" + + pve_qm start "$VYOS_TEMPLATE_VMID" + + cat >&2 <<EOF + +VyOS install VM started. Finish the install manually: + + on the PVE host: + qm terminal $VYOS_TEMPLATE_VMID # press Ctrl-] to exit + + inside VyOS: + login: vyos / vyos + install image + (accept defaults; set the new password to whatever you want) + poweroff + +When the VM is powered off, re-run this script to finalize the template. +EOF +} + +phase2_finalize_template() { + hdr "Phase 2: boot installed disk, let cloud-init apply seed, template" + + # Boot from installed disk now (install ISO no longer needed) and + # keep the seed CD so cloud-init picks it up on first post-install + # boot. We do NOT detach the install ISO yet; some VyOS rolling + # builds re-trigger first-boot wizardry from the CD if removed + # before the installed system has fully run. We detach both at the + # end, atomically, just before flipping the template flag. + pve_qm set "$VYOS_TEMPLATE_VMID" --boot "order=scsi0" + pve_qm start "$VYOS_TEMPLATE_VMID" + + echo "waiting ${VYOS_CLOUD_INIT_WAIT_SECONDS}s for cloud-init to apply..." >&2 + sleep "$VYOS_CLOUD_INIT_WAIT_SECONDS" + + hdr "Shutdown VM" + pve_qm shutdown "$VYOS_TEMPLATE_VMID" --timeout 60 || pve_qm stop "$VYOS_TEMPLATE_VMID" + + hdr "Detach install ISO and seed, then flip template flag" + pve_qm set "$VYOS_TEMPLATE_VMID" --delete ide2 || true + pve_qm set "$VYOS_TEMPLATE_VMID" --delete ide3 || true + pve_qm template "$VYOS_TEMPLATE_VMID" + + echo "template $VYOS_TEMPLATE_VMID is ready." >&2 + echo "next: tests/pve/create-vm.sh" >&2 +} + +main() { + local phase + phase="$(pve_template_phase "$VYOS_TEMPLATE_VMID")" + case "$phase" in + template) + echo "template VMID $VYOS_TEMPLATE_VMID already exists; nothing to do." >&2 + ;; + missing) + phase1_create_install_vm + ;; + running) + echo "ERR: VMID $VYOS_TEMPLATE_VMID is currently running." >&2 + echo " finish the install in the serial console and poweroff," >&2 + echo " then rerun this script." >&2 + return 1 + ;; + stopped) + phase2_finalize_template + ;; + *) + echo "ERR: unexpected template phase: $phase" >&2 + return 1 + ;; + esac +} + +main "$@" diff --git a/tests/pve/preflight.sh b/tests/pve/preflight.sh new file mode 100755 index 0000000..3fc6a16 --- /dev/null +++ b/tests/pve/preflight.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# tests/pve/preflight.sh — read-only sanity check on the PVE host. +# +# Verifies the host is reachable, has the tools the other scripts use, +# and reports the current state of the template VMID and the e2e VMID +# so you know whether ensure-template.sh / create-vm.sh have work to +# do. Mutates nothing. + +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=_lib.sh +source "$HERE/_lib.sh" + +pve_load_env +pve_require_reachable + +hdr "PVE identity" +pve_ssh 'hostname; pveversion' + +hdr "Required tools (qm, pvesm, ip, curl, sha256sum)" +pve_ssh 'for c in qm pvesm ip curl sha256sum; do + if command -v "$c" >/dev/null 2>&1; then + printf " ok %s\n" "$c" + else + printf " MISSING %s\n" "$c" + fi +done' + +hdr "Storage and bridge" +pve_ssh "pvesm status; ip -br link show $PVE_BRIDGE 2>/dev/null || echo 'bridge $PVE_BRIDGE not found'" + +hdr "Image directory" +pve_ssh "ls -la $PVE_IMAGE_DIR 2>/dev/null | head -20 || echo 'directory missing: $PVE_IMAGE_DIR'" + +hdr "Template VMID $VYOS_TEMPLATE_VMID" +pve_ssh "qm status $VYOS_TEMPLATE_VMID 2>/dev/null \ + && qm config $VYOS_TEMPLATE_VMID | grep -E '^(name|template|ostype|net0):' \ + || echo 'template VMID $VYOS_TEMPLATE_VMID does not exist (ensure-template.sh will create it)'" + +hdr "e2e VMID $VYOS_E2E_VMID" +pve_ssh "qm status $VYOS_E2E_VMID 2>/dev/null \ + || echo 'e2e VMID $VYOS_E2E_VMID is free (create-vm.sh will clone it)'" + +hdr "ok" diff --git a/tests/pve/run-e2e.sh b/tests/pve/run-e2e.sh new file mode 100755 index 0000000..10955cb --- /dev/null +++ b/tests/pve/run-e2e.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# tests/pve/run-e2e.sh — run the live pyvyos tests against the VM. +# +# Assumes the VM is already running and the HTTPS API is reachable. +# Use start-vm.sh first (and create-vm.sh before that, if needed). + +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=_lib.sh +source "$HERE/_lib.sh" + +pve_load_env + +REPO_ROOT="$(cd "$HERE/../.." && pwd)" +cd "$REPO_ROOT" + +export PYVYOS_E2E=1 +export VYDEVICE_HOSTNAME VYDEVICE_PORT VYDEVICE_PROTOCOL +export VYDEVICE_VERIFY_SSL VYDEVICE_APIKEY + +hdr "pytest tests/e2e (PYVYOS_E2E=1)" +if command -v uv >/dev/null 2>&1; then + uv run pytest tests/e2e -v +else + pytest tests/e2e -v +fi diff --git a/tests/pve/start-vm.sh b/tests/pve/start-vm.sh new file mode 100755 index 0000000..40ef11e --- /dev/null +++ b/tests/pve/start-vm.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=_lib.sh +source "$HERE/_lib.sh" +pve_load_env +pve_require_reachable +hdr "qm start $VYOS_E2E_VMID" +pve_ssh "qm start $VYOS_E2E_VMID" +hdr "ok" diff --git a/tests/pve/stop-vm.sh b/tests/pve/stop-vm.sh new file mode 100755 index 0000000..8a035ab --- /dev/null +++ b/tests/pve/stop-vm.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=_lib.sh +source "$HERE/_lib.sh" +pve_load_env +pve_require_reachable +hdr "qm shutdown $VYOS_E2E_VMID (60s timeout, then stop)" +pve_ssh "qm shutdown $VYOS_E2E_VMID --timeout 60 || qm stop $VYOS_E2E_VMID" +hdr "ok" |
