diff --git a/examples/soc-pytest/README.md b/examples/soc-pytest/README.md new file mode 100644 index 000000000..bf0bc6874 --- /dev/null +++ b/examples/soc-pytest/README.md @@ -0,0 +1,176 @@ +# Jumpstarter SOC testing with pytest example + +This example aims to demonstrate Jumpstarter in an a simple +SOC testing scenario using pytest. + +The following drivers will be utilized: +- DUTLink: for power, storage and console control of the target +- UStreamer: with an hdmi capture card plus a webcam for video snapshits + +This example requires the following hardware: +- 1x Raspberry Pi 4 +- 1x dutlink (DUTLink could be replaced by a composite set of power, storage mux and serial port interface) +- 1x HDMI Capture card +- 1x Webcam + +# Running the example (distributed env) + +1) Setup an environment with the required hardware, and customize the exporter.yaml +2) Setup the exporter to be run from a container (TODO: link) +3) Label the exporter in k8s with the `board=rpi4` label +4) Prepare the images by running `make` in the `image` directory +5) Run the tests in this directory by running: +```bash +$ cd jumpstarter_example_soc_pytest +$ uv run pytest -s +================================================================== test session starts =================================================================== +platform linux -- Python 3.12.3, pytest-8.3.3, pluggy-1.5.0 +rootdir: /home/majopela/jumpstarter/examples/soc-pytest +configfile: pyproject.toml +plugins: anyio-4.6.2.post1, cov-5.0.0 +collected 6 items + +test_on_rpi4.py::TestResource::test_setup_device +--------------------------------------------------------------------- live log setup --------------------------------------------------------------------- +INFO jumpstarter.client.lease:lease.py:35 Leasing Exporter matching labels {'board': 'rpi4'} for seconds: 1800 + +INFO jumpstarter.client.lease:lease.py:42 Lease c33b74ff-ad92-42a6-aa88-2c8a944a297c created +INFO jumpstarter.client.lease:lease.py:46 Polling Lease c33b74ff-ad92-42a6-aa88-2c8a944a297c +INFO jumpstarter.client.lease:lease.py:51 Lease c33b74ff-ad92-42a6-aa88-2c8a944a297c acquired +INFO jumpstarter.client.lease:lease.py:73 Connecting to Lease with name c33b74ff-ad92-42a6-aa88-2c8a944a297c +--------------------------------------------------------------------- live log call ---------------------------------------------------------------------- +INFO /home/majopela/jumpstarter/examples/soc-pytest/jumpstarter_example_soc_pytest/test_on_rpi4.py:test_on_rpi4.py:51 Setting up device +read: 2.45GB [00:49, 52.8MB/s] +INFO jumpstarter.testing.utils:utils.py:15 Waiting for login prompt + +RPi: BOOTLOADER release VERSION:817717 DATE: 2023/01/11 TIME: 17:40:52 +BOOTMODE: 0x06 partition 1 build-ts BUILD_TIMESTAMP=1673458852 serial c3656a7d boardrev d03114 stc 608563 +.. +Starting start4.elf @ 0xfeb00200 partition 1 ++ +[ 0.000000] Booting Linux on physical CPU 0x0000000000 [0x410fd083] +... +... + +Raspbian GNU/Linux 12 rpitest ttyS0 + +rpitest login: root +Password: +Linux rpitest 6.6.31+rpt-rpi-v8 #1 SMP PREEMPT Debian 1:6.6.31-1+rpt1 (2024-05-29) aarch64 + +The programs included with the Debian GNU/Linux system are free software; +the exact distribution terms for each program are described in the +individual files in /usr/share/doc/*/copyright. + +Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent +permitted by applicable law. +root@rpitest:~#INFO jumpstarter.testing.utils:utils.py:21 Logged in +INFO /home/majopela/jumpstarter/examples/soc-pytest/jumpstarter_example_soc_pytest/test_on_rpi4.py:test_on_rpi4.py:112 Attempting a soft power off + poweroff +root@rpitest:~# Stopping session-1.scope - Session 1 of User root... +... +[ 28.964752] reboot: Power down +PASSED +test_on_rpi4.py::TestResource::test_tpm2_device +--------------------------------------------------------------------- live log setup --------------------------------------------------------------------- +INFO jumpstarter.testing.utils:utils.py:15 Waiting for login prompt +INFO jumpstarter.testing.utils:utils.py:21 Logged in +--------------------------------------------------------------------- live log call ---------------------------------------------------------------------- +INFO /home/majopela/jumpstarter/examples/soc-pytest/jumpstarter_example_soc_pytest/test_on_rpi4.py:test_on_rpi4.py:78 Running command: apt-get install -y tpm2-tools +apt-get install -y tpm2-tools +... +root@rpitest:~# INFO /home/majopela/jumpstarter/examples/soc-pytest/jumpstarter_example_soc_pytest/test_on_rpi4.py:test_on_rpi4.py:78 Running command: tpm2_createprimary -C e -c primary.ctx +tpm2_createprimary -C e -c primary.ctx +name-alg: + value: sha256 + raw: 0xb +attributes: + value: fixedtpm|fixedparent|sensitivedataorigin|userwithauth|restricted|decrypt + raw: 0x30072 +type: + value: rsa + raw: 0x1 +exponent: 65537 +bits: 2048 +scheme: + value: null + raw: 0x10 +scheme-halg: + value: (null) + raw: 0x0 +sym-alg: + value: aes + raw: 0x6 +sym-mode: + value: cfb + raw: 0x43 +sym-keybits: 128 +rsa: efe8d8387679d50d7cea501f4302834eebd4c4b3ec7f7b6a40128c63f3e9fb6e9203429dba4e1221d4d40039ff757dc3cbec638c79e11fe5cb4cc159a5e15a3d785b179f3081ada24f6370bad9b81ad2ddcba2e137bb62a454069d37da7cd1e3a06cb7fe03fc8386b055746b5396ee3b44aa1e40dae4e6257c763a53f7eb60a29df18ee14bce38d376434d89e9c95a79d1563833a48db8016c130f6246f24e023b8874e6f2f8bb1fbfe8ad9a1a0ef71b7fc0ed412056a40a225b6f352ea32aa9564c56bef09df7107b871db136aa530ae479b0b09256373479716416bc18fc7544df8c5de99383c37193f5e016bca7ab39231a69c6d4255d93aed66527bb261d +root@rpitest:~# INFO /home/majopela/jumpstarter/examples/soc-pytest/jumpstarter_example_soc_pytest/test_on_rpi4.py:test_on_rpi4.py:78 Running command: tpm2_create -G rsa -u key.pub -r key.priv -C primary.ctx +tpm2_create -G rsa -u key.pub -r key.priv -C primary.ctx +name-alg: + value: sha256 + raw: 0xb +attributes: + value: fixedtpm|fixedparent|sensitivedataorigin|userwithauth|decrypt|sign + raw: 0x60072 +type: + value: rsa + raw: 0x1 +exponent: 65537 +bits: 2048 +scheme: + value: null + raw: 0x10 +scheme-halg: + value: (null) + raw: 0x0 +sym-alg: + value: null + raw: 0x10 +sym-mode: + value: (null) + raw: 0x0 +sym-keybits: 0 +rsa: c8cebe46344bbed17c39a497c3e5c53406be142ce741697641d940b77a835b3956c4ce0c5949688ff44a5d8ef847097e1870589ff4afcd401d2b7814b9a57ecc1f750b8a759b4e4f59915d8dda68c5463c8392870a59e21a02481e4d9b8d7ad27dd915850a587b6ff1a87fa98c578a0188e74c2731e39456c4e2e7f3158a878a294f82105a6ead9e397c15cd80c8b587c9a3f47513680cbe5f5fb5a0a41830566e5b70f312fa5e28fc780f45e72d4c8aa42fc2ea9d19e1068815493e2acda90cd6f7dabede223b494f916bd0c67682d4d5b4073b80954c0bab0ac612ae243f92c1d85ab3a7840d1d4aa7390f6155edb3341f229fbc015a8637d16230da03920f +root@rpitest:~# INFO /home/majopela/jumpstarter/examples/soc-pytest/jumpstarter_example_soc_pytest/test_on_rpi4.py:test_on_rpi4.py:78 Running command: tpm2_load -C primary.ctx -u key.pub -r key.priv -c key.ctx +tpm2_load -C primary.ctx -u key.pub -r key.priv -c key.ctx +name: 000b0395380f392a3ef0773853ed245ed1a2ba94d26261d846268146f2f4de148cf0 +root@rpitest:~# INFO /home/majopela/jumpstarter/examples/soc-pytest/jumpstarter_example_soc_pytest/test_on_rpi4.py:test_on_rpi4.py:78 Running command: echo my message > message.dat +echo my message > message.dat +root@rpitest:~# INFO /home/majopela/jumpstarter/examples/soc-pytest/jumpstarter_example_soc_pytest/test_on_rpi4.py:test_on_rpi4.py:78 Running command: tpm2_sign -c key.ctx -g sha256 -o sig.rssa message.dat +tpm2_sign -c key.ctx -g sha256 -o sig.rssa message.dat +root@rpitest:~# INFO /home/majopela/jumpstarter/examples/soc-pytest/jumpstarter_example_soc_pytest/test_on_rpi4.py:test_on_rpi4.py:78 Running command: tpm2_verifysignature -c key.ctx -g sha256 -s sig.rssa -m message.dat +.dat_verifysignature -c key.ctx -g sha256 -s sig.rssa -m message +root@rpitest:~# echo result: $? +result: 0 +PASSED +------------------------------------------------------------------- live log teardown -------------------------------------------------------------------- +INFO /home/majopela/jumpstarter/examples/soc-pytest/jumpstarter_example_soc_pytest/test_on_rpi4.py:test_on_rpi4.py:112 Attempting a soft power off +poweroff +root@rpitest:~# poweroff +... +[ 80.068761] reboot: Power down + +test_on_rpi4.py::TestResource::test_power_off_camera PASSED +test_on_rpi4.py::TestResource::test_power_on_camera PASSED +test_on_rpi4.py::TestResource::test_power_on_hdmi +--------------------------------------------------------------------- live log call ---------------------------------------------------------------------- +INFO imagehash:imagehash.py:79 video comparing snapshot test_booting_empty_ok.jpeg: snapshot f0f0f0f0f0f0f0f0, ref f0f0f0f0f0f0f0f0, diff: 0 +INFO imagehash:imagehash.py:79 video comparing snapshot test_booting_rainbow_ok.jpeg: snapshot 3c3c3c1c1c1c1c1c, ref 3c3c3c1c1c1c1c1c, diff: 0 +INFO imagehash:imagehash.py:79 video comparing snapshot test_booting_raspberries_ok.jpeg: snapshot c000000000000000, ref c000000000000000, diff: 0 +PASSED +test_on_rpi4.py::TestResource::test_login_console_hdmi +--------------------------------------------------------------------- live log setup --------------------------------------------------------------------- +INFO jumpstarter.testing.utils:utils.py:15 Waiting for login prompt +INFO jumpstarter.testing.utils:utils.py:21 Logged in +--------------------------------------------------------------------- live log call ---------------------------------------------------------------------- +INFO imagehash:imagehash.py:79 video comparing snapshot test_booted_ok.jpeg: snapshot c0c0000000000000, ref c0c0000000000000, diff: 0 +PASSED +------------------------------------------------------------------- live log teardown -------------------------------------------------------------------- +INFO /home/majopela/jumpstarter/examples/soc-pytest/jumpstarter_example_soc_pytest/test_on_rpi4.py:test_on_rpi4.py:112 Attempting a soft power off +INFO jumpstarter.client.lease:lease.py:63 Releasing Lease c33b74ff-ad92-42a6-aa88-2c8a944a297c + + +============================================================= 6 passed in 303.59s (0:05:03) ============================================================== +``` diff --git a/examples/soc-pytest/jumpstarter_example_soc_pytest/exporter.yaml b/examples/soc-pytest/jumpstarter_example_soc_pytest/exporter.yaml new file mode 100644 index 000000000..6aafb3fd6 --- /dev/null +++ b/examples/soc-pytest/jumpstarter_example_soc_pytest/exporter.yaml @@ -0,0 +1,24 @@ +apiVersion: jumpstarter.dev/v1alpha1 +kind: ExporterConfig +endpoint: grpc.jumpstarter.example.com:443 +token: xxxxx +export: + # a DUTLink interface to the DUT + dutlink: + type: jumpstarter_driver_dutlink.driver.Dutlink + config: + storage_device: "/dev/disk/by-id/usb-SanDisk_3.2_Gen_1_5B4C0AB025C0-0:0" + # an HDMI to USB capture card + video: + type: jumpstarter_driver_ustreamer.driver.UStreamer + config: + args: + device: '/dev/v4l/by-path/pci-0000:00:14.0-usbv2-0:3:1.0-video-index0' + resolution: 1920x1080 + # a USB camera pointing to the DUT + camera: + type: jumpstarter_driver_ustreamer.driver.UStreamer + config: + args: + device: '/dev/v4l/by-path/pci-0000:00:14.0-usbv2-0:4:1.0-video-index0' + resolution: 1280x720 \ No newline at end of file diff --git a/examples/soc-pytest/jumpstarter_example_soc_pytest/image/Makefile b/examples/soc-pytest/jumpstarter_example_soc_pytest/image/Makefile new file mode 100644 index 000000000..f3414940a --- /dev/null +++ b/examples/soc-pytest/jumpstarter_example_soc_pytest/image/Makefile @@ -0,0 +1,56 @@ +all: prepare-image + +############################################################################### +# Image preparation targets +############################################################################### + +download-image: + scripts/download-latest-raspbian + +prepare-image: images/latest.raw mount + scripts/prepare-latest-raw + touch images/.prepared + umount mnt + +images/.prepared: + make prepare-image + +images/latest.raw.xz: + make download-image + +images/latest.raw: images/latest.raw.xz + xz -d -f -v -T0 -k $^ + touch images/latest.raw + rm -f images/.prepared + +clean-image: + rm -f images/.prepared + rm -f images/latest.raw + +clean-images: clean-image + rm -rf images/download.raspberrypi.org + rm -rf images/latest.raw.xz + +clean: clean-image clean-images + +############################################################################### +# Image manipulation targets +############################################################################### + +mnt: + mkdir -p $@ + +umount: + umount mnt || true + +mount: umount images/latest.raw mnt + guestmount -a images/latest.raw -m /dev/sda2 -m /dev/sda1:/boot/firmware -o allow_other --rw mnt + + +############################################################################### +# phony targets are targets which don't produce files, just for utility +############################################################################### + + +.PHONY: download-image prepare-image +.PHONY: mount umount diff --git a/examples/soc-pytest/jumpstarter_example_soc_pytest/image/README.md b/examples/soc-pytest/jumpstarter_example_soc_pytest/image/README.md new file mode 100644 index 000000000..e65006975 --- /dev/null +++ b/examples/soc-pytest/jumpstarter_example_soc_pytest/image/README.md @@ -0,0 +1,76 @@ +# Image preparation scripts + +This directory contains scripts to prepare the image for the example SOC test, +running `make` should: + +* Download a minimal raspbian image +* Inject the settings for the test to be performed (enable UART, setup basic password, tpm dtb) + +You will need guestmount installed, sudo permissions. + +fuse must be configured to enable `user_allow_other` in `/etc/fuse.conf`. + + +```bash +$ make +make download-image +make[1]: Entering directory '/home/majopela/jumpstarter/examples/soc-pytest/jumpstarter_example_soc_pytest/image' +scripts/download-latest-raspbian +https://downloads.raspberrypi.org/raspios_lite_armhf/images/raspios_lite_armhf-2024-07-04/2024-07-04-raspios-bookworm-armhf-lite.img.xz +--2024-10-17 12:12:43-- https://downloads.raspberrypi.org/raspios_lite_armhf/images/raspios_lite_armhf-2024-07-04/2024-07-04-raspios-bookworm-armhf-lite.img.xz +Resolving downloads.raspberrypi.org (downloads.raspberrypi.org)... 2a00:1098:80:56::2:1, 2a00:1098:80:56::1:1, 2a00:1098:82:47::1, ... +Connecting to downloads.raspberrypi.org (downloads.raspberrypi.org)|2a00:1098:80:56::2:1|:443... connected. +HTTP request sent, awaiting response... 200 OK +Length: 523828628 (500M) [application/x-xz] +Saving to: ‘./images/downloads.raspberrypi.org/raspios_lite_armhf/images/raspios_lite_armhf-2024-07-04/2024-07-04-raspios-bookworm-armhf-lite.img.xz’ + +downloads.raspberrypi.org/raspios_lite_armhf/ima 100%[=======================================================================================================>] 499.56M 77.9MB/s in 6.6s + +2024-10-17 12:12:53 (75.3 MB/s) - ‘./images/downloads.raspberrypi.org/raspios_lite_armhf/images/raspios_lite_armhf-2024-07-04/2024-07-04-raspios-bookworm-armhf-lite.img.xz’ saved [523828628/523828628] + +FINISHED --2024-10-17 12:12:53-- +Total wall clock time: 10s +Downloaded: 1 files, 500M in 6.6s (75.3 MB/s) +Latest image: ./images/downloads.raspberrypi.org/raspios_lite_armhf/images/raspios_lite_armhf-2024-07-04/2024-07-04-raspios-bookworm-armhf-lite.img.xz +Updating link from latest.raw.xz -> ./images/downloads.raspberrypi.org/raspios_lite_armhf/images/raspios_lite_armhf-2024-07-04/2024-07-04-raspios-bookworm-armhf-lite.img.xz +make[1]: Leaving directory '/home/majopela/jumpstarter/examples/soc-pytest/jumpstarter_example_soc_pytest/image' +xz -d -f -v -T0 -k images/latest.raw.xz +images/latest.raw.xz (1/1) + 100 % 499.6 MiB / 2,512.0 MiB = 0.199 102 MiB/s 0:24 +touch images/latest.raw +rm -f images/.prepared +umount mnt || true +umount: /home/majopela/jumpstarter/examples/soc-pytest/jumpstarter_example_soc_pytest/image/mnt: not mounted. +guestmount -a images/latest.raw -m /dev/sda2 -m /dev/sda1:/boot/firmware -o allow_other --rw mnt +scripts/prepare-latest-raw ++ sudo sed -i 's/console=serial0,115200 console=tty1/console=serial0,115200/g' mnt/boot/firmware/cmdline.txt ++ cat mnt/boot/firmware/cmdline.txt +console=serial0,115200 root=PARTUUID=d28ec40f-02 rootfstype=ext4 fsck.repair=yes rootwait quiet init=/usr/lib/raspberrypi-sys-mods/firstboot ++ cat ++ sudo tee mnt/boot/firmware/custom.toml +# Raspberry Pi First Boot Setup +[system] +hostname = "rpitest" + +[user] +name = "root" +password = "changeme" +password_encrypted = false + +[ssh] +enabled = false + +[wlan] +country = "es" + +[locale] +keymap = "es" +timezone = "Europe/Madrid" ++ cat ++ sudo tee -a mnt/boot/firmware/config.txt +dtparam=spi=on +dtoverlay=tpm-slb9670 +enable_uart=1 +touch images/.prepared +umount mnt +``` \ No newline at end of file diff --git a/examples/soc-pytest/jumpstarter_example_soc_pytest/image/images/.gitignore b/examples/soc-pytest/jumpstarter_example_soc_pytest/image/images/.gitignore new file mode 100644 index 000000000..72e8ffc0d --- /dev/null +++ b/examples/soc-pytest/jumpstarter_example_soc_pytest/image/images/.gitignore @@ -0,0 +1 @@ +* diff --git a/examples/soc-pytest/jumpstarter_example_soc_pytest/image/images/.gitkeep b/examples/soc-pytest/jumpstarter_example_soc_pytest/image/images/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/examples/soc-pytest/jumpstarter_example_soc_pytest/image/scripts/download-latest-raspbian b/examples/soc-pytest/jumpstarter_example_soc_pytest/image/scripts/download-latest-raspbian new file mode 100755 index 000000000..7bd18469e --- /dev/null +++ b/examples/soc-pytest/jumpstarter_example_soc_pytest/image/scripts/download-latest-raspbian @@ -0,0 +1,22 @@ +#!/bin/bash +set -e +#LATEST_URL=$(wget -O /dev/null -o - --max-redirect=0 https://downloads.raspberrypi.org/raspios_lite_armhf_latest 2>/dev/null| sed -n "s/^Location: \(.*\) \[following\]$/\1/p") +LATEST_URL=$(curl https://downloads.raspberrypi.org/raspios_lite_armhf_latest -v 2>&1 | sed -n "s/< location: \(.*\)\r$/\1/p") +echo $LATEST_URL +CACHE="./images" +wget "${LATEST_URL}" -np -m -A '*img.xz' -c -P "${CACHE}" +# use the latest compose image +LATEST_IMG=$(ls -Art "${CACHE}/downloads.raspberrypi.org/raspios_lite_armhf/images"/*/*.img.xz | tail -n 1) + +echo "Latest image: ${LATEST_IMG}" + +# calculate full path to LATEST_IMG +LATEST_IMG_FULLPATH=$(readlink -f ${LATEST_IMG}) +EXISTING_LINK=$(readlink "${CACHE}/latest.raw.xz" || true ) +# if the link has changed, update the link +if [[ "${LATEST_IMG_FULLPATH}" != "${EXISTING_LINK}" ]]; then + echo "Updating link from latest.raw.xz -> ${LATEST_IMG}" + ln -fs "${LATEST_IMG_FULLPATH}" "${CACHE}/latest.raw.xz" +else + echo "We are up-to-date." +fi diff --git a/examples/soc-pytest/jumpstarter_example_soc_pytest/image/scripts/prepare-latest-raw b/examples/soc-pytest/jumpstarter_example_soc_pytest/image/scripts/prepare-latest-raw new file mode 100755 index 000000000..566c2b7b8 --- /dev/null +++ b/examples/soc-pytest/jumpstarter_example_soc_pytest/image/scripts/prepare-latest-raw @@ -0,0 +1,32 @@ +#!/bin/sh +set -x +# all output to serial port +sudo sed -i 's/console=serial0,115200 console=tty1/console=serial0,115200/g' mnt/boot/firmware/cmdline.txt +cat mnt/boot/firmware/cmdline.txt + +cat << EOF | sudo tee mnt/boot/firmware/custom.toml +# Raspberry Pi First Boot Setup +[system] +hostname = "rpitest" + +[user] +name = "root" +password = "changeme" +password_encrypted = false + +[ssh] +enabled = false + +[wlan] +country = "es" + +[locale] +keymap = "es" +timezone = "Europe/Madrid" +EOF + +cat << EOF | sudo tee -a mnt/boot/firmware/config.txt +dtparam=spi=on +dtoverlay=tpm-slb9670 +enable_uart=1 +EOF \ No newline at end of file diff --git a/examples/soc-pytest/jumpstarter_example_soc_pytest/test_booted_ok.jpeg b/examples/soc-pytest/jumpstarter_example_soc_pytest/test_booted_ok.jpeg new file mode 100644 index 000000000..b68d7231a Binary files /dev/null and b/examples/soc-pytest/jumpstarter_example_soc_pytest/test_booted_ok.jpeg differ diff --git a/examples/soc-pytest/jumpstarter_example_soc_pytest/test_booting_empty_ok.jpeg b/examples/soc-pytest/jumpstarter_example_soc_pytest/test_booting_empty_ok.jpeg new file mode 100644 index 000000000..0539b92ab Binary files /dev/null and b/examples/soc-pytest/jumpstarter_example_soc_pytest/test_booting_empty_ok.jpeg differ diff --git a/examples/soc-pytest/jumpstarter_example_soc_pytest/test_booting_rainbow_ok.jpeg b/examples/soc-pytest/jumpstarter_example_soc_pytest/test_booting_rainbow_ok.jpeg new file mode 100644 index 000000000..488701c6e Binary files /dev/null and b/examples/soc-pytest/jumpstarter_example_soc_pytest/test_booting_rainbow_ok.jpeg differ diff --git a/examples/soc-pytest/jumpstarter_example_soc_pytest/test_booting_raspberries_ok.jpeg b/examples/soc-pytest/jumpstarter_example_soc_pytest/test_booting_raspberries_ok.jpeg new file mode 100644 index 000000000..1ce110692 Binary files /dev/null and b/examples/soc-pytest/jumpstarter_example_soc_pytest/test_booting_raspberries_ok.jpeg differ diff --git a/examples/soc-pytest/jumpstarter_example_soc_pytest/test_on_rpi4.py b/examples/soc-pytest/jumpstarter_example_soc_pytest/test_on_rpi4.py new file mode 100644 index 000000000..1cf6e6ff6 --- /dev/null +++ b/examples/soc-pytest/jumpstarter_example_soc_pytest/test_on_rpi4.py @@ -0,0 +1,119 @@ +import logging +import os +import sys +import time + +import opendal +import pexpect +import pytest +from jumpstarter_imagehash import ImageHash + +from jumpstarter.client.adapters import PexpectAdapter +from jumpstarter.testing.pytest import JumpstarterTest +from jumpstarter.testing.utils import wait_and_login + +log = logging.getLogger(__file__) + +class TestResource(JumpstarterTest): + filter_labels = {"board":"rpi4"} + + @classmethod + def teardown_class(cls): + try: + cls._client.dutlink.power.off() + except Exception as e: + log.error(f"Failed to power off the device: {e}") + + # call parent teardown + super().teardown_class() + + @pytest.fixture() + def console(self, client): + with PexpectAdapter(client=client.dutlink.console) as console: + if os.environ.get("DEBUG_CONSOLE") == "1": + console.logfile_read = sys.stdout.buffer + yield console + + @pytest.fixture() + def video(self, client): + return ImageHash(client.video) + + @pytest.fixture() + def shell(self, client, console): + client.dutlink.power.off() + time.sleep(1) + client.dutlink.power.on() + yield wait_and_login(console, "root", "changeme", "@rpitest:~#") + _power_off(client, console) + + def test_setup_device(self, client, console): + client.dutlink.power.off() + log.info("Setting up device") + try: + client.dutlink.storage.write_local_file("image/images/latest.raw") + except opendal.exceptions.NotFound: + pytest.exit("No image found, please enter the image directory and run `make`, " + "more details in the README.md") + return + client.dutlink.storage.dut() + client.dutlink.power.on() + console.logfile_read = sys.stdout.buffer + # first boot on raspbian will take some time, we wait for the login + wait_and_login(console, "root", "changeme", "@rpitest:~#") + # then power off the device + _power_off(client, console) + + def test_tpm2_device(self, shell): + shell.logfile_read = sys.stdout.buffer + + lines = ["apt-get install -y tpm2-tools", + "tpm2_createprimary -C e -c primary.ctx", + "tpm2_create -G rsa -u key.pub -r key.priv -C primary.ctx", + "tpm2_load -C primary.ctx -u key.pub -r key.priv -c key.ctx", + "echo my message > message.dat", + "tpm2_sign -c key.ctx -g sha256 -o sig.rssa message.dat", + "tpm2_verifysignature -c key.ctx -g sha256 -s sig.rssa -m message.dat"] + + for line in lines: + log.info(f"Running command: {line}") + shell.sendline(line) + shell.expect("@rpitest:~#", timeout=200) + + shell.sendline("echo result: $?") + shell.expect(r"result: \d.", timeout=200) + assert shell.after.decode().strip() == "result: 0" + + def test_power_off_camera(self, client): + client.dutlink.power.off() + client.camera.snapshot().save("camera_off.jpeg") + + def test_power_on_camera(self, client): + client.dutlink.power.on() + time.sleep(1) + client.camera.snapshot().save("camera_on.jpeg") + client.dutlink.power.off() + + def test_power_on_hdmi(self, client, video): + # check all the image snapshots through the rpi4 boot process + client.dutlink.power.on() + time.sleep(1) + video.assert_snapshot("test_booting_empty_ok.jpeg") + time.sleep(6) + video.assert_snapshot("test_booting_rainbow_ok.jpeg") + time.sleep(4) + video.assert_snapshot("test_booting_raspberries_ok.jpeg") + client.dutlink.power.off() + + def test_login_console_hdmi(self, shell, video): + video.assert_snapshot("test_booted_ok.jpeg") + + +def _power_off(client, console): + log.info("Attempting a soft power off") + try: + console.sendline("poweroff") + console.expect("Power down.") + except pexpect.TIMEOUT: + log.error("Timeout waiting for power down, continuing with hard power off") + finally: + client.dutlink.power.off() diff --git a/examples/soc-pytest/pyproject.toml b/examples/soc-pytest/pyproject.toml new file mode 100644 index 000000000..681fd3ad5 --- /dev/null +++ b/examples/soc-pytest/pyproject.toml @@ -0,0 +1,28 @@ +[project] +name = "jumpstarter-example-soc-pytest" +version = "0.1.0" +description = "" +authors = [ + { name = "Miguel Angel Ajo Pelayo", email = "majopela@redhat.com" }, + { name = "Kirk Brauer", email = "kbrauer@hatci.com" }, + { name = "Nick Cao", email = "ncao@redhat.com" }, +] +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "jumpstarter", + "pytest>=8.3.2", + # TODO: figure this out + #"jumsptarter_imagehash", + #"jumpstarter_driver_dutlink", +] + +[tool.uv.sources] +jumpstarter = { workspace = true } +#jumpstarter-imagehash = { workspace = true } +#jumpstarter-driver-dutlink = { workspace = true } + +[tool.pytest.ini_options] +addopts = "-s --ignore examples/pytest/test_on_rpi4.py" +log_cli = 1 +log_cli_level = "INFO" \ No newline at end of file diff --git a/jumpstarter/testing/__init__.py b/jumpstarter/testing/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/jumpstarter/testing/pytest.py b/jumpstarter/testing/pytest.py new file mode 100644 index 000000000..f6993d06b --- /dev/null +++ b/jumpstarter/testing/pytest.py @@ -0,0 +1,61 @@ +import time + +import pytest + +from jumpstarter.common import MetadataFilter +from jumpstarter.common.utils import env +from jumpstarter.config.client import ClientConfigV1Alpha1 + + +class JumpstarterTest: + """ + Base class for Jumpstarter test cases in pytest + + This class provides a client fixture that can be used to interact with + Jumpstarter services in test cases. + + Looks for the JUMPSTARTER_HOST environment variable to connect to a + established Jumpstarter shell, otherwise it will try to acquire a lease + for a single exporter using the filter_labels annotation. + i.e.: + ``` + class TestResource(JumpstarterTest): + filter_labels = {"board":"rpi4"} + + @pytest.fixture() + def console(self, client): + with PexpectAdapter(client=client.dutlink.console) as console: + yield console + + def test_setup_device(self, client, console): + client.dutlink.power.off() + log.info("Setting up device") + client.dutlink.storage.write_local_file("2024-07-04-raspios-bookworm-arm64-lite.img") + client.dutlink.storage.dut() + client.dutlink.power.on() + ``` + """ + @classmethod + def setup_class(cls): + try: + cls.__client = env() + cls._client = cls.__client.__enter__() + except RuntimeError: + labels = getattr(cls, "filter_labels", {}) + cls._lease = ClientConfigV1Alpha1.load("default").lease(metadata_filter=MetadataFilter(labels=labels)) + cls.__client = cls._lease.__enter__().connect() + cls._client = cls.__client.__enter__() + + @classmethod + def teardown_class(cls): + cls.__client.__exit__(None, None, None) + if hasattr(cls, "_lease"): + cls._lease.__exit__(None, None, None) + + # BUG workaround: make sure that grpc servers get the client/lease release properly + time.sleep(1) + + @pytest.fixture() + def client(self): + return self._client + diff --git a/jumpstarter/testing/utils.py b/jumpstarter/testing/utils.py new file mode 100644 index 000000000..19c8b7d3e --- /dev/null +++ b/jumpstarter/testing/utils.py @@ -0,0 +1,22 @@ +import logging + +log = logging.getLogger(__name__) + +def wait_and_login(pexpect_console, username, password, prompt, timeout=240): + """ + Wait for login prompt and login + + :param pexpect_console: pexpect console object + :type pexpect_console: pexpect.spawn + + :return: pexpect console object + :rtype: pexpect.spawn + """ + log.info("Waiting for login prompt") + pexpect_console.expect("login:", timeout=timeout) + pexpect_console.sendline(username) + pexpect_console.expect("Password:") + pexpect_console.sendline(password) + pexpect_console.expect(prompt, timeout=60) + log.info("Logged in") + return pexpect_console diff --git a/uv.lock b/uv.lock index 0a326d385..2e1c2122d 100644 --- a/uv.lock +++ b/uv.lock @@ -14,6 +14,7 @@ members = [ "jumpstarter-driver-sdwire", "jumpstarter-driver-ustreamer", "jumpstarter-example-automotive", + "jumpstarter-example-soc-pytest", "jumpstarter-imagehash", ] @@ -734,7 +735,7 @@ wheels = [ [[package]] name = "jumpstarter" -version = "0.0.3.dev44+gb4ed30f.d20241016" +version = "0.0.3.dev49+g43aabe2.d20241016" source = { editable = "." } dependencies = [ { name = "aiohttp" }, @@ -811,7 +812,7 @@ dev = [ [[package]] name = "jumpstarter-driver-can" -version = "0.0.3.dev44+gfd0d3ba" +version = "0.0.3.dev49+g43aabe2.d20241016" source = { editable = "contrib/drivers/can" } dependencies = [ { name = "can-isotp" }, @@ -840,7 +841,7 @@ dev = [ [[package]] name = "jumpstarter-driver-dutlink" -version = "0.0.3.dev44+gfd0d3ba" +version = "0.0.3.dev49+g43aabe2.d20241016" source = { editable = "contrib/drivers/dutlink" } dependencies = [ { name = "jumpstarter" }, @@ -869,7 +870,7 @@ dev = [ [[package]] name = "jumpstarter-driver-raspberrypi" -version = "0.0.3.dev44+gfd0d3ba" +version = "0.0.3.dev49+g43aabe2.d20241016" source = { editable = "contrib/drivers/raspberrypi" } dependencies = [ { name = "gpiozero" }, @@ -896,7 +897,7 @@ dev = [ [[package]] name = "jumpstarter-driver-sdwire" -version = "0.0.3.dev44+gfd0d3ba" +version = "0.0.3.dev49+g43aabe2.d20241016" source = { editable = "contrib/drivers/sdwire" } dependencies = [ { name = "jumpstarter" }, @@ -925,7 +926,7 @@ dev = [ [[package]] name = "jumpstarter-driver-ustreamer" -version = "0.0.3.dev44+gfd0d3ba" +version = "0.0.3.dev49+g43aabe2.d20241016" source = { editable = "contrib/drivers/ustreamer" } dependencies = [ { name = "jumpstarter" }, @@ -961,9 +962,24 @@ dependencies = [ [package.metadata] requires-dist = [{ name = "jumpstarter", editable = "." }] +[[package]] +name = "jumpstarter-example-soc-pytest" +version = "0.1.0" +source = { virtual = "examples/soc-pytest" } +dependencies = [ + { name = "jumpstarter" }, + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "jumpstarter", editable = "." }, + { name = "pytest", specifier = ">=8.3.2" }, +] + [[package]] name = "jumpstarter-imagehash" -version = "0.0.3.dev44+gb4ed30f.d20241016" +version = "0.0.3.dev49+g43aabe2.d20241016" source = { editable = "contrib/libs/imagehash" } dependencies = [ { name = "imagehash" },