summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/e2e/__init__.py0
-rw-r--r--tests/e2e/conftest.py96
-rw-r--r--tests/e2e/test_vydevice_live.py71
-rw-r--r--tests/pve/.env.example70
-rw-r--r--tests/pve/README.md162
-rwxr-xr-xtests/pve/_lib.sh128
-rw-r--r--tests/pve/cloud-init/meta-data.template2
-rw-r--r--tests/pve/cloud-init/user-data.template14
-rwxr-xr-xtests/pve/create-vm.sh42
-rwxr-xr-xtests/pve/destroy-vm.sh12
-rwxr-xr-xtests/pve/ensure-template.sh175
-rwxr-xr-xtests/pve/preflight.sh45
-rwxr-xr-xtests/pve/run-e2e.sh26
-rwxr-xr-xtests/pve/start-vm.sh10
-rwxr-xr-xtests/pve/stop-vm.sh10
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"