aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorOliver Smith <osmith@sysmocom.de>2024-07-29 13:49:11 +0200
committerOliver Smith <osmith@sysmocom.de>2024-08-02 13:22:56 +0200
commit6cc780e5dc273531d0c336dd21329c9e1393f4e1 (patch)
tree7f368b062c06555b95197d7756eba20b8c49cd02
parent59f2cc1dd23e4996ae988a788dcf347a8ace7791 (diff)
testenv: add test environment script
Add a new testenv.py script that builds/installs all components needed for a testsuite, builds the testsuite from source and runs it. Features: * --binary-repo argument to install packages from osmocom:latest or any other repository from the Osmocom OBS instead of building from source * without --binary-repo, the test components are built with osmo-dev, cloning the missing source git repositories and building them in the right order * --podman argument to run the testsuite and its components inside a container (using podman instead of docker so it runs rootless) * Simple testenv.cfg file to specify components for running testsuites * Iterative compilation of components and testsuite * Using ccache * Testsuite doesn't start if any of the components fail to start (e.g. because of a config error) * Testsuite gets stopped if any of the components crash * ^C stops the testsuite + all components * Test component output logs to stdout in addition to a log file (turn off with --no-tee) * --test argument to only run one specific test * --shell argument to run an interactive shell before teardown to inspect the test environment while components are still running This script unifies the use cases of running a testsuite without containers (for local development), and with containers (as jenkins runs it, but can also be used for local development e.g. to get a clean pcap). Previously jenkins used a different set of configurations from docker-playground.git and many different containers instead of just one. Related: OS#6494 Change-Id: If9f8b79dd6e5b4f06be4e5ff73db97759c3acfb2
-rw-r--r--.gitignore1
-rw-r--r--README.md8
-rw-r--r--_testenv/README.md142
-rw-r--r--_testenv/data/osmo-dev/osmo-bts-trx.opts1
-rw-r--r--_testenv/data/podman/Dockerfile123
-rw-r--r--_testenv/data/podman/obs.key26
-rwxr-xr-x_testenv/data/scripts/log_format.sh8
-rwxr-xr-x_testenv/data/scripts/rename_junit_xml_classname.sh37
-rwxr-xr-x_testenv/data/scripts/respawn.sh22
-rwxr-xr-x_testenv/data/scripts/testenv-podman-main.sh20
-rw-r--r--_testenv/pyproject.toml2
-rwxr-xr-x_testenv/testenv.py126
-rw-r--r--_testenv/testenv/__init__.py218
-rw-r--r--_testenv/testenv/cmd.py103
-rw-r--r--_testenv/testenv/daemons.py120
-rw-r--r--_testenv/testenv/osmo_dev.py135
-rw-r--r--_testenv/testenv/podman.py287
-rw-r--r--_testenv/testenv/podman_install.py153
-rw-r--r--_testenv/testenv/requirements.py83
-rw-r--r--_testenv/testenv/testdir.py177
-rw-r--r--_testenv/testenv/testenv_cfg.py189
-rw-r--r--_testenv/testenv/testsuite.py218
l---------testenv.py1
23 files changed, 2200 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
index e77dfa77..f03b0b3d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,3 +22,4 @@ selftest/Selftest
sms.db
sms.db-shm
sms.db-wal
+__pycache__
diff --git a/README.md b/README.md
index 907ebb16..c84f31cc 100644
--- a/README.md
+++ b/README.md
@@ -12,6 +12,14 @@ Those test suites mostly are performing *functional testing* of cellular
network elements, from 2G, 3G, 4G to 5G. The individual test-suites are
in sub-directories, while some shared library code is in *library*.
+Running Testsuites
+------------------
+
+Use the `testenv.py` script to run the testsuites, e.g.:
+
+```
+$ ./testenv.py run mgw
+```
Continuous Integration
----------------------
diff --git a/_testenv/README.md b/_testenv/README.md
new file mode 100644
index 00000000..2c5a1814
--- /dev/null
+++ b/_testenv/README.md
@@ -0,0 +1,142 @@
+# testenv
+
+Build everything needed for running Osmocom TTCN-3 testsuites and execute them.
+
+## testenv.cfg
+
+The `testenv.cfg` file has one `[testsuite]` section, typically with one or
+more sections for test components.
+
+### Example
+
+```ini
+[testsuite]
+program=MGCP_Test
+config=MGCP_Test.cfg
+
+[mgw]
+program=osmo-mgw
+make=osmo-mgw
+package=osmo-mgw
+copy=osmo-mgw.cfg
+```
+
+### Keys
+
+#### Testsuite section
+
+* `program=` the executable for starting the testsuite, without arguments.
+
+* `config=`: the testsuite configuration file.
+
+* `copy=`: additional file(s) to copy from the testsuite directory to the test
+ directory, useful for include files mentioned in the config. Multiple values
+ are separated by spaces.
+
+* `clean=`: optional script to run before running the testsuite and on exit.
+ This can be used to clean up network devices for example, or to fix name
+ collisions when running a test with multiple configs
+ (`rename_junit_xml_classname.sh`). See below for `PATH`. A
+ `TESTENV_CLEAN_REASON` env var is set to `prepare`, `crashed` or `finished`
+ depending on when the script runs.
+
+#### Component section
+
+* `program=`: executable for starting a test component, may contain arguments.
+ See below for `PATH`.
+
+* `copy=`: file(s) to copy from the testsuite directory to the test directory,
+ like `.cfg` and `.confmerge` files. Multiple values are separated by spaces.
+
+* `make=`: osmo-dev make target for building from source, if running without
+ `--binary-repo`. This is usually the name of the git repository, but could
+ also be e.g. `.make.osmocom-bb.clone` to only clone and not build
+ `osmocom-bb.git` for faketrx. Set `make=no` to not build/clone anything.
+
+* `package=`: debian package(s) to be installed for running a test component
+ and the script in `prepare=`. Multiple values separated by spaces. Set to
+ `package=no` to not install any package.
+
+* `prepare=`: optional script to run before staring the program (after files
+ are copied to the test directory). Typically this is used to create configs
+ with `osmo-config-merge`. See below for `PATH`.
+
+* `setup=`: optional script to run after the program was started. Execution of
+ the next program / the testsuite will wait until the setup script has quit.
+ This can be used to wait until the program is ready or to fill a test
+ database for example. See below for `PATH`.
+
+* `clean=`: optional script to run before `prepare=` and on exit. This can be
+ used to clean up network devices for example, or to fix name collisions when
+ running a test with multiple configs (`rename_junit_xml_classname.sh`). See
+ below for `PATH`. A `TESTENV_CLEAN_REASON` env var is set to `prepare`,
+ `crashed` or `finished` depending on when the script runs.
+
+### PATH
+
+Executables mentioned in `program=`, `prepare=`, `setup=` and `clean=` run
+with a `PATH` environment variable containing:
+
+* The directory of the testsuite
+* The directory for binaries built from source
+* The directory `_testenv/data/scripts` (which has e.g. `respawn.sh`)
+
+### Latest configs
+
+Sometimes we need to run test components and/or testsuites with different
+configurations depending on whether we are currently testing the nightly/master
+versions of test components or the latest (stable) versions. For example, when
+a new feature gets introduced that we need to configure and test, but which is
+not available in the latest stable version.
+
+For this purpose, it is possible to add configuration keys ending in `_latest`
+to `testenv.cfg`. These keys will override the original keys if `testenv.py`
+is running with a binary repository ending in `:latest` or with `--latest`.
+
+It is also possible to take different code paths or exclude tests in the
+TTCN-3 code if the latest configs are (not) used. This is done with
+`f_osmo_repo_is` in `library/Misc_Helpers.ttcn`.
+
+### Multiple testenv.cfg files
+
+Usually each testsuite has only one `testenv.cfg` file, e.g. `mgw/testenv.cfg`.
+For some testsuites it is necessary to run them multiple times with slightly
+different configurations. In that case, we have multiple `testenv.cfg` files,
+typically one `testenv_generic.cfg` and additional `testenv_<NAME>.cfg` files.
+
+For example:
+* `bts/testenv_generic.cfg`
+* `bts/testenv_hopping.cfg`
+* `bts/testenv_oml.cfg`
+
+## Environment variables
+
+* `TESTENV_SRC_DIR`:
+ Set the directory for sources of Osmocom components. The default is the
+ directory above your osmo-ttcn3-hacks.git clone.
+
+* `TESTENV_NO_IMAGE_UP_TO_DATE_CHECK`:
+ Do not compare the timestamp of `data/podman/Dockerfile` with the date of the
+ podman image. This check does not work on jenkins where we always have
+ a fresh clone.
+
+* `TESTENV_COLOR_{DEBUG|INFO|WARNING|ERROR|CRITICAL|RESET}`:
+ Change the colors for different log levels (we use this in Jenkins, which
+ prints the output on white background). Find the defaults in
+ `testenv/__init__.py:ColorFormatter()`.
+
+* `TESTENV_SOURCE_HIGHLIGHT_COLORS`:
+ The argument to pass to `source-highlight` for formatting the junit log xml
+ files. Jenkins sets this to `esc` instead of `esc256` for better contrast on
+ white background.
+
+## Troubleshooting
+
+### Timeout waiting for RESET-ACK after sending RESET
+
+This can happen if another Osmocom program is started before OsmoSTP, then
+tries to connect to OsmoSTP and fails, and waits several seconds before
+retrying. Check the logs to confirm this is the case. To fix this, adjust your
+`testenv.cfg` to start OsmoSTP before other Osmocom programs. The testenv
+scripts will wait a bit to give OsmoSTP enough time to start up, before
+starting the other test components.
diff --git a/_testenv/data/osmo-dev/osmo-bts-trx.opts b/_testenv/data/osmo-dev/osmo-bts-trx.opts
new file mode 100644
index 00000000..8acec2d8
--- /dev/null
+++ b/_testenv/data/osmo-dev/osmo-bts-trx.opts
@@ -0,0 +1 @@
+osmo-bts --enable-trx
diff --git a/_testenv/data/podman/Dockerfile b/_testenv/data/podman/Dockerfile
new file mode 100644
index 00000000..95a8c668
--- /dev/null
+++ b/_testenv/data/podman/Dockerfile
@@ -0,0 +1,123 @@
+ARG REGISTRY=docker.io
+ARG DISTRO=debian:12
+FROM ${REGISTRY}/${DISTRO}
+
+# Arguments used after FROM must be specified again
+ARG OSMOCOM_REPO_TESTSUITE_MIRROR="https://downloads.osmocom.org"
+ARG OSMOCOM_REPO="$OSMOCOM_REPO_TESTSUITE_MIRROR/packages/osmocom:/latest/Debian_12/"
+
+# Copy from common dir
+COPY obs.key /obs.key
+
+# Install packages from Debian repositories (alphabetic order)
+ENV DEBIAN_FRONTEND=noninteractive
+RUN set -x && \
+ apt-get update && \
+ apt-get install -y --no-install-recommends \
+ autoconf \
+ automake \
+ bison \
+ build-essential \
+ ca-certificates \
+ ccache \
+ cmake \
+ flex \
+ git \
+ iproute2 \
+ iputils-ping \
+ libbson-dev \
+ libc-ares-dev \
+ libcsv-dev \
+ libcurl4-gnutls-dev \
+ libdbd-sqlite3 \
+ libdbi-dev \
+ libgcrypt-dev \
+ libgnutls28-dev \
+ libidn11-dev \
+ libjansson-dev \
+ libmicrohttpd-dev \
+ libmnl-dev \
+ libmongoc-dev \
+ libnghttp2-dev \
+ libortp-dev \
+ libpcap-dev \
+ libpcsclite-dev \
+ libsctp-dev \
+ libsofia-sip-ua-glib-dev \
+ libsqlite3-dev \
+ libssl-dev \
+ libtalloc-dev \
+ libtins-dev \
+ libtool \
+ libulfius-dev \
+ liburing-dev \
+ libusb-1.0-0-dev \
+ libyaml-dev \
+ libzmq3-dev \
+ meson \
+ netcat-openbsd \
+ pcscd \
+ pkg-config \
+ procps \
+ psmisc \
+ python3-pip \
+ rebar3 \
+ rsync \
+ source-highlight \
+ sqlite3 \
+ sudo \
+ tcpdump \
+ vim \
+ wget \
+ wireshark-common \
+ && \
+ apt-get clean
+
+# Ccache is installed above so it can be optionally used when rebuilding the
+# testsuites inside the docker containers. Don't use it by default.
+ENV USE_CCACHE=0
+
+# Binary-only transcoding library for RANAP/RUA/HNBAP to work around TITAN only implementing BER
+RUN set -x && \
+ export DPKG_ARCH="$(dpkg --print-architecture)" && \
+ wget https://ftp.osmocom.org/binaries/libfftranscode/libfftranscode0_0.5_${DPKG_ARCH}.deb && \
+ wget https://ftp.osmocom.org/binaries/libfftranscode/libfftranscode-dev_0.5_${DPKG_ARCH}.deb && \
+ dpkg -i ./libfftranscode0_0.5_${DPKG_ARCH}.deb ./libfftranscode-dev_0.5_${DPKG_ARCH}.deb && \
+ apt-get install --fix-broken && \
+ rm libfftranscode*.deb
+
+# Install osmo-python-tests (for obtaining talloc reports from SUT)
+ADD https://gerrit.osmocom.org/plugins/gitiles/python/osmo-python-tests/+/master?format=TEXT /tmp/osmo-python-tests-commit
+RUN set -x && \
+ git clone --depth=1 https://gerrit.osmocom.org/python/osmo-python-tests osmo-python-tests && \
+ pip3 install ./osmo-python-tests --break-system-packages && \
+ rm -rf osmo-python-tests
+
+# Add eclipse-titan from osmocom:latest, invalidate cache when :latest changes
+RUN echo "deb [signed-by=/obs.key] $OSMOCOM_REPO ./" \
+ > /etc/apt/sources.list.d/osmocom-latest.list
+ADD $OSMOCOM_REPO/Release /tmp/Release
+RUN set -x && \
+ apt-get update && \
+ apt-get install -y --no-install-recommends \
+ eclipse-titan \
+ && \
+ apt-get clean && \
+ rm /etc/apt/sources.list.d/osmocom-latest.list
+
+# Add mongodb for open5gs-hss. Using the package from bullseye since bookworm
+# mongodb-org package is not available. Furthermore, manually install required
+# libssl1.1.
+RUN set -x && \
+ mkdir -p /tmp/mongodb && \
+ cd /tmp/mongodb && \
+ wget "https://pgp.mongodb.com/server-5.0.asc" -O "/mongodb.key" && \
+ wget "http://security.debian.org/debian-security/pool/updates/main/o/openssl/libssl1.1_1.1.1n-0+deb10u6_amd64.deb" && \
+ dpkg -i "libssl1.1_1.1.1n-0+deb10u6_amd64.deb" && \
+ echo "deb [signed-by=/mongodb.key] http://repo.mongodb.org/apt/debian bullseye/mongodb-org/5.0 main" \
+ > /etc/apt/sources.list.d/mongodb-org.list && \
+ apt-get update && \
+ apt-get install -y mongodb-org && \
+ apt-get clean && \
+ cd / && \
+ rm -rf /tmp/mongodb
diff --git a/_testenv/data/podman/obs.key b/_testenv/data/podman/obs.key
new file mode 100644
index 00000000..ecca0844
--- /dev/null
+++ b/_testenv/data/podman/obs.key
@@ -0,0 +1,26 @@
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+Version: GnuPG v1.4.5 (GNU/Linux)
+
+mQENBGKzE1QBCADFcM3ZzggvgxNRNNqDGWf5xIDAiK5qzFLdGes7L6F9VCHdaPy0
+RAOB5bFb/Q1tSDFNEBLtaauXKz+4iGL6qMVjZcyjzpB5w4jKN+kkrFRhjDNUv/SH
+BX6d+P7v5WBGSNArNgA8D1BGzckp5a99EZ0okMJFEqIcN40PD6OGugpq5XnVV5Nk
+e93fLa2Cu8vhFBcVn6CuHeEhsmuMf6NLbQRNfNNCEEUYaZn7beMYtpZ7t1djsKx5
+1xGm50OzI22FLu8lELQ9d7qMVGRG3WHYawX9BDteRybiyqxfwUHm1haWazRJtlGt
+UWyzvwAb80BK1J2Nu5fbAa3w5CoEPAbUuCyrABEBAAG0JW9zbW9jb20gT0JTIFBy
+b2plY3QgPG9zbW9jb21Ab3Ntb2NvbT6JAVQEEwEIAD4WIQRrKp83ktFetw1Oao+G
+pzC2U3JZcwUCYrMV4wIbAwUJBB6yjwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAK
+CRCGpzC2U3JZc4FRCACQQkKIrnvQ7n2u7GSmyVZa3I+oLoFXSGqaGyey5TW/nrMm
+vFDKU3qliHiuNSmUY35SnAhXUsvqOYppxVRoO1MLrqUvzMOnIWqkJpf8mtjGUnsW
+jyVeto7Rsjs75y2i1Hk+e7ljb/V65J3NlfrfEYWbqR9AKd53ReNXTdrQ0J05A38N
+GdI4Ld/2lNISAwaBmGhqdeKsLHpQw/JERU1TApVJR1whFiIwDF1rOCg9GPnNKIk7
+yRZdK267XzztrainX/cbPILyzUZEDhYs6wQuyACyQ1YUxZIxrwVfk7PMNay8CrLH
+z42B73Ne5IAj8+op/3iJafFONLm7YXiDUFN+QDYAiQEzBBMBCAAdFiEExoiYhHND
+S7aVYlnqa51NyAUyjdsFAmKzE1UACgkQa51NyAUyjdvuZgf+OXmr//i7u7Gg7eWB
+7e0qUsyCId9lXS8J437x3K6ciJfD7/6RSy8TFW5Nglm/uSkbyq582I8t+SoOirMD
+E6cg9U/5+h5s46bAf+Kd2XS/6tLGeNLM18i4el8CP06NpFzDrsKu76uYFpyRiiHD
+otBdtgxeLJ83LugGfZslF+/5cigJkAJMhAdVvGO8h85R6fba8ZSOKtMKkaQRfi76
+nhyOrJPlLuS+DLEnHwdkOFgtKnxHdjM97K+Tx0gisb6uwaWroXfSLnhP8RTLLZZy
+Z+noU1Hw3c+mn4c/NYbcC/uwHYHKRzuf9gHnQ3dGgv0Z5sbeLRVo92hjGj7Ftlyd
+4hmKBg==
+=HxK4
+-----END PGP PUBLIC KEY BLOCK-----
diff --git a/_testenv/data/scripts/log_format.sh b/_testenv/data/scripts/log_format.sh
new file mode 100755
index 00000000..a74affe4
--- /dev/null
+++ b/_testenv/data/scripts/log_format.sh
@@ -0,0 +1,8 @@
+#!/bin/sh -e
+# Run ttcn3_logformat on all merged log files.
+
+for i in *.merged; do
+ temp="$i.temp"
+ ttcn3_logformat -o "$temp" "$i"
+ mv "$temp" "$i"
+done
diff --git a/_testenv/data/scripts/rename_junit_xml_classname.sh b/_testenv/data/scripts/rename_junit_xml_classname.sh
new file mode 100755
index 00000000..031ef901
--- /dev/null
+++ b/_testenv/data/scripts/rename_junit_xml_classname.sh
@@ -0,0 +1,37 @@
+#!/bin/sh -e
+# Some jenkins jobs run the same tests multiple times with different configs.
+# Then we have a conflict of test names ("classname") in the test result files.
+# This script renames the classnames in the test result files to avoid the
+# conflict. Jenkins will them show them separated in the test result analyzer.
+
+SUFFIX="$1"
+
+if [ -z "$SUFFIX" ]; then
+ echo "usage: rename_junit_xml_classname.sh SUFFIX"
+ echo "example: rename_junit_xml_classname ':hopping'"
+ exit 1
+fi
+
+xmls="$(find -name 'junit-xml*.log')"
+
+if [ -z "$xmls" ]; then
+ case "$TESTENV_CLEAN_REASON" in
+ prepare|crashed)
+ # No xml files is expected
+ exit 0
+ ;;
+ finished)
+ echo "ERROR: could not find any junit-xml*.log files!"
+ exit 1
+ ;;
+ *)
+ echo "ERROR: invalid TESTENV_CLEAN_REASON: $TESTENV_CLEAN_REASON"
+ exit 1
+ ;;
+ esac
+fi
+
+for i in $xmls; do
+ echo "Adding '$SUFFIX' to classnames in: $i"
+ sed -i "s/classname='\([^']\+\)'/classname='\1$SUFFIX'/g" $i
+done
diff --git a/_testenv/data/scripts/respawn.sh b/_testenv/data/scripts/respawn.sh
new file mode 100755
index 00000000..3b079c78
--- /dev/null
+++ b/_testenv/data/scripts/respawn.sh
@@ -0,0 +1,22 @@
+#!/bin/sh
+# Restart a given test component while the testsuite is running.
+
+trap "kill 0" EXIT
+
+SLEEP_BEFORE_RESPAWN=${SLEEP_BEFORE_RESPAWN:-0}
+
+i=0
+max_i=500
+while [ $i -lt $max_i ]; do
+ echo "respawn: $i: starting: $*"
+ $* &
+ LAST_PID=$!
+ wait $LAST_PID
+ echo "respawn: $i: stopped pid $LAST_PID with status $?"
+ if [ $SLEEP_BEFORE_RESPAWN -gt 0 ]; then
+ echo "respawn: sleeping $SLEEP_BEFORE_RESPAWN seconds..."
+ sleep $SLEEP_BEFORE_RESPAWN
+ fi
+ i=$(expr $i + 1)
+done
+echo "respawn: exiting after $max_i runs"
diff --git a/_testenv/data/scripts/testenv-podman-main.sh b/_testenv/data/scripts/testenv-podman-main.sh
new file mode 100755
index 00000000..d0bc5866
--- /dev/null
+++ b/_testenv/data/scripts/testenv-podman-main.sh
@@ -0,0 +1,20 @@
+#!/bin/sh -e
+# Simple watchdog script that exits if either:
+# * testenv doesn't create /tmp/watchdog every 10s
+# * 4 hours have passed
+# This ensures the podman container stops a few seconds after a jenkins job was
+# aborted, or if a test is stuck in a loop for hours.
+
+stop_time=$(($(date +%s) + 3600 * 4))
+
+while [ $(date +%s) -lt $stop_time ]; do
+ sleep 10
+
+ if ! [ -e /tmp/watchdog ]; then
+ break
+ fi
+
+ rm /tmp/watchdog
+done
+
+exit 1
diff --git a/_testenv/pyproject.toml b/_testenv/pyproject.toml
new file mode 100644
index 00000000..3719b74c
--- /dev/null
+++ b/_testenv/pyproject.toml
@@ -0,0 +1,2 @@
+[tool.ruff]
+line-length=120
diff --git a/_testenv/testenv.py b/_testenv/testenv.py
new file mode 100755
index 00000000..6ed1e51c
--- /dev/null
+++ b/_testenv/testenv.py
@@ -0,0 +1,126 @@
+#!/usr/bin/env python3
+# Copyright 2024 sysmocom - s.f.m.c. GmbH
+# SPDX-License-Identifier: GPL-3.0-or-later
+import logging
+import os
+import sys
+import testenv
+import testenv.cmd
+import testenv.daemons
+import testenv.osmo_dev
+import testenv.podman
+import testenv.podman_install
+import testenv.requirements
+import testenv.testdir
+import testenv.testenv_cfg
+import testenv.testsuite
+
+
+def run():
+ testenv.testenv_cfg.init()
+
+ if not testenv.args.binary_repo:
+ testenv.osmo_dev.check_init_needed()
+
+ testenv.requirements.check()
+ testenv.podman_install.init()
+ testenv.cmd.init_env()
+ testenv.testdir.init()
+ testenv.daemons.init()
+
+ if testenv.args.podman:
+ testenv.podman.init()
+ testenv.podman.start()
+
+ if not testenv.args.binary_repo:
+ testenv.osmo_dev.init()
+
+ testenv.testsuite.init()
+ testenv.testsuite.build()
+
+ # Run prepare functions of testsuites (may enable extra repos)
+ for cfg_name, cfg in testenv.testenv_cfg.cfgs.items():
+ testenv.testenv_cfg.set_current(cfg_name)
+ testenv.testsuite.run_prepare_script(cfg)
+
+ # Build all components first
+ if not testenv.args.binary_repo:
+ for cfg_name, cfg in testenv.testenv_cfg.cfgs.items():
+ testenv.testenv_cfg.set_current(cfg_name)
+ testenv.osmo_dev.make(cfg)
+
+ # Run the components + testsuite
+ cfg_count = 0
+ for cfg_name, cfg in testenv.testenv_cfg.cfgs.items():
+ testenv.testenv_cfg.set_current(cfg_name)
+
+ if testenv.args.binary_repo:
+ testenv.podman.enable_binary_repo()
+ testenv.podman_install.packages(cfg, cfg_name)
+
+ testenv.testdir.prepare(cfg_name, cfg)
+ testenv.daemons.start(cfg)
+ testenv.testsuite.run(cfg)
+ testenv.daemons.stop()
+ testenv.testdir.clean_run_scripts("finished")
+ testenv.testsuite.cat_junit_logs()
+
+ cfg_count += 1
+ testenv.set_log_prefix("[testenv]")
+
+ # Restart podman container before running with another config
+ if testenv.args.podman:
+ restart = cfg_count < len(testenv.testenv_cfg.cfgs)
+ testenv.podman.stop(restart)
+
+
+def init_podman():
+ testenv.podman.init_image_name_distro()
+ testenv.podman.image_build()
+
+
+def init_osmo_dev():
+ testenv.osmo_dev.init_clone()
+
+
+def clean():
+ cache_dirs = [
+ "git",
+ "host",
+ "podman",
+ ]
+ for cache_dir in cache_dirs:
+ path = os.path.join(testenv.cache_dir_default, cache_dir)
+ if os.path.exists(path):
+ testenv.cmd.run(["rm", "-rf", path])
+
+
+def main():
+ testenv.init_logging()
+ testenv.init_args()
+
+ action = testenv.args.action
+ if action == "run":
+ run()
+ elif action == "init":
+ if testenv.args.runtime == "osmo-dev":
+ init_osmo_dev()
+ elif testenv.args.runtime == "podman":
+ init_podman()
+ elif action == "clean":
+ clean()
+
+
+try:
+ main()
+except testenv.NoTraceException as e:
+ logging.error(e)
+ testenv.podman.stop()
+ sys.exit(2)
+except KeyboardInterrupt:
+ print("") # new line
+ testenv.podman.stop()
+ sys.exit(3)
+except:
+ testenv.podman.stop()
+ raise
diff --git a/_testenv/testenv/__init__.py b/_testenv/testenv/__init__.py
new file mode 100644
index 00000000..fe9a6129
--- /dev/null
+++ b/_testenv/testenv/__init__.py
@@ -0,0 +1,218 @@
+# Copyright 2024 sysmocom - s.f.m.c. GmbH
+# SPDX-License-Identifier: GPL-3.0-or-later
+import argparse
+import logging
+import os.path
+
+
+class NoTraceException(Exception):
+ pass
+
+
+args = None
+
+src_dir = os.environ.get("TESTENV_SRC_DIR", os.path.realpath(f"{__file__}/../../../.."))
+data_dir = os.path.join(os.path.realpath(f"{__file__}/../.."), "data")
+distro_default = "debian:bookworm"
+cache_dir_default = os.path.join(os.path.expanduser("~/.cache"), "osmo-ttcn3-testenv")
+ccache_dir_default = os.path.join(cache_dir_default, "ccache")
+
+log_prefix = "[testenv]"
+
+
+def resolve_testsuite_name_alias(name):
+ mapping = {
+ "ggsn": "ggsn_tests",
+ }
+
+ if name in mapping:
+ logging.debug(f"Using testsuite {mapping[name]} (via alias {name})")
+ return mapping[name]
+
+ return name
+
+
+def parse_args():
+ global args
+ parser = argparse.ArgumentParser(
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ description="Build/install everything for a testsuite and run it.\n"
+ "\n"
+ "examples:\n"
+ " ./testenv.py run mgw\n"
+ " ./testenv.py run mgw --test TC_crcx\n"
+ " ./testenv.py run mgw --podman --binary-repo osmocom:latest\n"
+ " ./testenv.py run mgw --io-uring\n"
+ " ./testenv.py run bts --config oml\n",
+ )
+
+ sub = parser.add_subparsers(title="action", dest="action", required=True)
+
+ sub_init = sub.add_parser("init", help="initialize osmo-dev/podman")
+ sub_init_runtime = sub_init.add_subparsers(title="runtime", required=True, dest="runtime")
+ sub_init_runtime.add_parser(
+ "osmo-dev",
+ help="prepare osmo-dev (top-level makefile scripts, for building test"
+ " components from source when using 'run' without '--binary-repo')",
+ )
+
+ sub_podman = sub_init_runtime.add_parser("podman", help="prepare the podman image (for 'run --podman')")
+ sub_podman.add_argument(
+ "-f",
+ "--force",
+ action="store_true",
+ help="build image even if it is up-to-date",
+ )
+
+ sub_run = sub.add_parser("run", help="build components and run a testsuite")
+
+ group = sub_run.add_argument_group("testsuite options")
+ group.add_argument("testsuite", help="a directory in osmo-ttcn3-hacks.git (msc, bsc, mgw, ...)")
+ group.add_argument(
+ "-t",
+ "--test",
+ help="only run one specific test (e.g. TC_selftest, BTS_Tests_OML.TC_wrong_mdisc)",
+ )
+ group.add_argument(
+ "-c",
+ "--config",
+ action="append",
+ help="which testenv.cfg to use, in case the testsuite has multiple (e.g. generic|oml|hopping for bts)",
+ )
+ group.add_argument("-i", "--io-uring", action="store_true", help="set LIBOSMO_IO_BACKEND=IO_URING")
+
+ group = sub_run.add_argument_group("source/binary options", "All components are built from source by default.")
+ group = group.add_mutually_exclusive_group()
+ group.add_argument(
+ "-b",
+ "--binary-repo",
+ metavar="OBS_PROJECT",
+ help="use binary packages from this Osmocom OBS project instead (e.g. osmocom:nightly)",
+ )
+ group = sub_run.add_argument_group(
+ "config file options",
+ "Testsuite and test component configs"
+ " for nightly/master versions of test"
+ " components are used, unless a binary"
+ " repository ending in :latest is set"
+ " or --latest is used.",
+ )
+ group.add_argument("--latest", action="store_true", help="use latest configs")
+
+ group = sub_run.add_argument_group("podman options", "All components are run directly on the host by default.")
+ group.add_argument("-p", "--podman", action="store_true", help="run all components inside podman")
+ group.add_argument(
+ "-d",
+ "--distro",
+ default=distro_default,
+ help=f"distribution for podman (default: {distro_default})",
+ )
+ group.add_argument(
+ "-s",
+ "--shell",
+ action="store_true",
+ help="run an interactive shell before stopping daemons/container",
+ )
+
+ group = sub_run.add_argument_group("output options")
+ group.add_argument("-l", "--log-dir", help="log here instead of a random dir in /tmp")
+ group.add_argument(
+ "-n",
+ "--no-tee",
+ dest="tee",
+ action="store_false",
+ help="don't send test component's output to stdout",
+ )
+
+ group = sub_run.add_argument_group("cache options")
+ group.add_argument(
+ "--cache",
+ help=f"cache path (default: {cache_dir_default})",
+ default=cache_dir_default,
+ )
+ group.add_argument(
+ "--ccache",
+ help=f"ccache path (default: {ccache_dir_default})",
+ default=ccache_dir_default,
+ )
+
+ sub.add_parser("clean", help="clean previous build artifacts")
+
+ args = parser.parse_args()
+
+ if args.action == "run":
+ args.testsuite = resolve_testsuite_name_alias(args.testsuite)
+ if args.binary_repo and args.binary_repo.endswith(":latest"):
+ logging.debug("Binary repository ends in :latest, using latest configs")
+ args.latest = True
+
+
+def verify_args_run():
+ if args.action != "run":
+ return
+
+ if args.binary_repo and not args.podman:
+ raise NoTraceException("--binary-repo requires --podman")
+
+ ttcn3_hacks_dir_src = os.path.realpath(f"{__file__}/../../..")
+ testsuite_dir = os.path.join(ttcn3_hacks_dir_src, args.testsuite)
+ if not os.path.exists(testsuite_dir):
+ raise NoTraceException(f"testsuite dir not found: {testsuite_dir}")
+
+
+def init_args():
+ parse_args()
+ verify_args_run()
+
+
+class ColorFormatter(logging.Formatter):
+ colors = {
+ "debug": "\033[37m", # light gray
+ "info": "\033[94m", # blue
+ "warning": "\033[93m", # yellow
+ "error": "\033[91m", # red
+ "critical": "\033[91m", # red
+ "reset": "\033[0m",
+ }
+
+ def __init__(self):
+ for color in self.colors.keys():
+ env_var = f"TESTENV_COLOR_{color.upper()}"
+ if env_var in os.environ:
+ self.colors[color] = os.environ.get(env_var)
+
+ super().__init__()
+
+ def format(self, record):
+ if record.levelno == logging.DEBUG:
+ color = "debug"
+ elif record.levelno == logging.INFO:
+ color = "info"
+ elif record.levelno == logging.WARNING:
+ color = "warning"
+ elif record.levelno == logging.ERROR:
+ color = "error"
+ elif record.levelno == logging.CRITICAL:
+ color = "critical"
+
+ self._style._fmt = f"{self.colors[color]}{log_prefix} %(msg)s{self.colors['reset']}"
+ result = logging.Formatter.format(self, record)
+
+ return result
+
+
+def init_logging():
+ formatter = ColorFormatter()
+ root_logger = logging.getLogger()
+ root_logger.setLevel(logging.DEBUG)
+ root_logger.handlers = []
+
+ handler = logging.StreamHandler()
+ handler.setFormatter(formatter)
+ root_logger.addHandler(handler)
+
+
+def set_log_prefix(new):
+ global log_prefix
+
+ log_prefix = new
diff --git a/_testenv/testenv/cmd.py b/_testenv/testenv/cmd.py
new file mode 100644
index 00000000..93e4e364
--- /dev/null
+++ b/_testenv/testenv/cmd.py
@@ -0,0 +1,103 @@
+# Copyright 2024 sysmocom - s.f.m.c. GmbH
+# SPDX-License-Identifier: GPL-3.0-or-later
+import logging
+import os
+import os.path
+import subprocess
+import testenv
+import testenv.testsuite
+
+env_extra = {}
+usr_dir = None
+
+
+def init_env():
+ global env_extra
+ global usr_dir
+
+ if testenv.args.podman:
+ if not testenv.args.binary_repo:
+ usr_dir = os.path.join(testenv.args.cache, "podman", "usr")
+ else:
+ usr_dir = os.path.join(testenv.args.cache, "host", "usr")
+
+ if usr_dir:
+ pkg_config_path = os.path.join(usr_dir, "lib/pkgconfig")
+ if "PKG_CONFIG_PATH" in os.environ:
+ pkg_config_path += f":{os.environ.get('PKG_CONFIG_PATH')}"
+ pkg_config_path += ":/usr/lib/pkgconfig"
+
+ ld_library_path = os.path.join(usr_dir, "lib")
+ if "LD_LIBRARY_PATH" in os.environ:
+ ld_library_path += f":{os.environ.get('LD_LIBRARY_PATH')}"
+ ld_library_path += ":/usr/lib"
+
+ env_extra["PKG_CONFIG_PATH"] = pkg_config_path
+ env_extra["LD_LIBRARY_PATH"] = ld_library_path
+
+ env_extra["CCACHE_DIR"] = testenv.args.ccache
+ env_extra["TESTENV_CACHE_DIR"] = testenv.args.cache
+ env_extra["TESTENV_SRC_DIR"] = testenv.src_dir
+
+ env_extra["TERM"] = os.environ.get("TERM", "dumb")
+
+ if testenv.args.binary_repo:
+ env_extra["TESTENV_GIT_DIR"] = testenv.podman_install.git_dir
+ else:
+ if testenv.args.podman:
+ env_extra["OSMO_DEV_MAKE_DIR"] = os.path.join(testenv.args.cache, "podman", "make")
+ else:
+ env_extra["OSMO_DEV_MAKE_DIR"] = os.path.join(testenv.args.cache, "host", "make")
+
+
+def exit_error_cmd(completed, error_msg):
+ """:param completed: return from run_cmd() below"""
+
+ logging.error(error_msg)
+ logging.debug(f"Command: {completed.args}")
+ logging.debug(f"Returncode: {completed.returncode}")
+ raise RuntimeError("shell command related error, find details right above this python trace")
+
+
+def generate_env(env={}, podman=False):
+ ret = dict(env_extra)
+ path = os.path.join(testenv.data_dir, "scripts")
+ if testenv.testsuite.ttcn3_hacks_dir:
+ path += f":{os.path.join(testenv.testsuite.ttcn3_hacks_dir, testenv.args.testsuite)}"
+
+ if usr_dir:
+ path += f":{os.path.join(usr_dir, 'bin')}"
+
+ if podman:
+ path += ":/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
+ else:
+ path += f":{os.environ.get('PATH')}"
+
+ ret["PATH"] = path
+ ret["HOME"] = os.environ.get("HOME")
+
+ for var in env:
+ ret[var] = env[var]
+ return ret
+
+
+def run(cmd, check=True, env={}, no_podman=False, stdin=subprocess.DEVNULL, *args, **kwargs):
+ if not no_podman and testenv.podman.is_running():
+ return testenv.podman.exec_cmd(cmd, check=check, env=env, *args, **kwargs)
+
+ logging.debug(f"+ {cmd}")
+
+ # Set stdin to /dev/null by default so we can still capture ^C with testenv
+ p = subprocess.run(
+ cmd,
+ env=generate_env(env),
+ shell=isinstance(cmd, str),
+ stdin=stdin,
+ *args,
+ **kwargs,
+ )
+
+ if p.returncode == 0 or not check:
+ return p
+
+ exit_error_cmd(p, "Command failed unexpectedly")
diff --git a/_testenv/testenv/daemons.py b/_testenv/testenv/daemons.py
new file mode 100644
index 00000000..792341c9
--- /dev/null
+++ b/_testenv/testenv/daemons.py
@@ -0,0 +1,120 @@
+# Copyright 2024 sysmocom - s.f.m.c. GmbH
+# SPDX-License-Identifier: GPL-3.0-or-later
+import atexit
+import logging
+import os
+import os.path
+import shlex
+import subprocess
+import testenv
+import testenv.testdir
+import time
+
+daemons = {}
+run_shell_on_stop = False
+
+
+def init():
+ global run_shell_on_stop
+
+ if not testenv.args.podman:
+ atexit.register(stop)
+ if testenv.args.shell:
+ run_shell_on_stop = True
+
+
+def start(cfg):
+ global daemons
+
+ for section in cfg:
+ if section in ["testenv", "DEFAULT"]:
+ continue
+
+ section_data = cfg[section]
+ if "program" not in section_data:
+ continue
+ if section == "testsuite":
+ # Runs in the foreground with testenv.testsuite.run()
+ continue
+
+ program = section_data["program"]
+ logging.info(f"Running {section}")
+
+ cwd = os.path.join(testenv.testdir.testdir, section)
+ os.makedirs(cwd, exist_ok=True)
+
+ log = os.path.join(testenv.testdir.testdir, section, f"{section}.log")
+
+ if testenv.args.tee:
+ pipe = f"2>&1 | tee {shlex.quote(log)}"
+ else:
+ pipe = f">{shlex.quote(log)} 2>&1"
+ cmd = ["sh", "-c", f"{program} 2>&1 {pipe}"]
+
+ env = {}
+ if testenv.args.io_uring:
+ env["LIBOSMO_IO_BACKEND"] = "IO_URING"
+
+ if testenv.podman.is_running():
+ daemons[section] = testenv.podman.exec_cmd_background(cmd, cwd=cwd, env=env)
+ else:
+ logging.debug(f"+ {cmd}")
+ daemons[section] = subprocess.Popen(cmd, cwd=cwd, env=testenv.cmd.generate_env(env))
+
+ # Wait 200ms and check if it is still running
+ time.sleep(0.2)
+ if daemons[section].poll() is not None:
+ raise testenv.NoTraceException(f"program failed to start: {program}")
+
+ # Run setup script
+ if "setup" in section_data:
+ setup = section_data["setup"]
+ logging.info(f"Running {section} setup script")
+ testenv.cmd.run(setup, cwd=cwd)
+
+
+def kill_process_tree(pid, ppids):
+ subprocess.run(
+ ["kill", "-9", str(pid)],
+ check=False,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+
+ for child_pid, child_ppid in ppids:
+ if child_ppid == str(pid):
+ kill_process_tree(child_pid, ppids)
+
+
+def kill(pid):
+ cmd = ["ps", "-e", "-o", "pid,ppid"]
+ ret = subprocess.run(cmd, check=True, stdout=subprocess.PIPE)
+ ppids = []
+ proc_entries = ret.stdout.decode("utf-8").rstrip().split("\n")[1:]
+ for row in proc_entries:
+ items = row.split()
+ if len(items) != 2:
+ raise RuntimeError("Unexpected ps output: " + row)
+ ppids.append(items)
+
+ kill_process_tree(pid, ppids)
+
+
+def stop():
+ global daemons
+ global run_shell_on_stop
+
+ if run_shell_on_stop:
+ logging.info("Running interactive shell before stopping daemons (--shell)")
+
+ # stdin=None: override stdin=/dev/null, so we can type into the shell
+ testenv.cmd.run(["bash"], cwd=testenv.testdir.testdir, stdin=None, check=False)
+
+ run_shell_on_stop = False
+
+ for daemon in daemons:
+ pid = daemons[daemon].pid
+ logging.info(f"Stopping {daemon} ({pid})")
+ kill(pid)
+
+ daemons = {}
diff --git a/_testenv/testenv/osmo_dev.py b/_testenv/testenv/osmo_dev.py
new file mode 100644
index 00000000..00782cd9
--- /dev/null
+++ b/_testenv/testenv/osmo_dev.py
@@ -0,0 +1,135 @@
+# Copyright 2024 sysmocom - s.f.m.c. GmbH
+# SPDX-License-Identifier: GPL-3.0-or-later
+import logging
+import os
+import sys
+import testenv
+import testenv.cmd
+
+make_dir = None
+init_done = False
+
+
+def get_osmo_dev_dir():
+ # Users may have used osmo-dev to clone osmo-ttcn3-hacks:
+ # osmo-dev/src/osmo-ttcn3-hacks
+ alt_path = os.path.realpath(os.path.join(testenv.src_dir, "../"))
+ if os.path.exists(os.path.join(alt_path, "gen_makefile.py")):
+ return alt_path
+
+ # Assume osmo-dev is next to osmo-ttcn3-hacks:
+ # src_dir
+ # ├── osmo-dev
+ # └── osmo-ttcn3-hacks
+ return os.path.join(testenv.src_dir, "osmo-dev")
+
+
+def init_clone():
+ osmo_dev_dir = get_osmo_dev_dir()
+
+ if os.path.exists(osmo_dev_dir):
+ logging.debug(f"osmo-dev found, nothing to do: {osmo_dev_dir}")
+ return
+
+ testenv.cmd.run(["git", "clone", "https://gerrit.osmocom.org/osmo-dev"], cwd=testenv.src_dir)
+
+
+def check_init_needed():
+ osmo_dev_dir = get_osmo_dev_dir()
+
+ if os.path.exists(osmo_dev_dir):
+ logging.debug(f"osmo-dev dir: {osmo_dev_dir}")
+ return
+
+ logging.error("Missing osmo-dev for building test components from source.")
+ logging.error("Run 'testenv.py init osmo-dev' first.")
+ logging.error("")
+ logging.error("osmo-dev and other Osmocom repositories (if they don't already exist) will be cloned to:")
+ logging.error(testenv.src_dir)
+ logging.error("")
+ logging.error("Set the environment variable TESTENV_SRC_DIR to use a different path.")
+ sys.exit(1)
+
+
+def init():
+ global init_done
+ global make_dir
+
+ if init_done:
+ return
+
+ extra_opts = []
+ if testenv.args.podman:
+ make_dir = os.path.join(testenv.args.cache, "podman", "make")
+ extra_opts = [
+ "--install-prefix",
+ os.path.join(testenv.args.cache, "podman/usr"),
+ ]
+ else:
+ make_dir = os.path.join(testenv.args.cache, "host", "make")
+ extra_opts = [
+ "--install-prefix",
+ os.path.join(testenv.args.cache, "host/usr"),
+ ]
+
+ cmd = [
+ "./gen_makefile.py",
+ "--build-debug",
+ "--make-dir",
+ make_dir,
+ "--no-ldconfig",
+ "--src-dir",
+ testenv.src_dir,
+ "default.opts",
+ "ccache.opts",
+ "iu.opts",
+ "no_dahdi.opts",
+ "no_doxygen.opts",
+ "no_systemd.opts",
+ "werror.opts",
+ os.path.join(testenv.data_dir, "osmo-dev/osmo-bts-trx.opts"),
+ ] + extra_opts
+
+ testenv.cmd.run(cmd, cwd=get_osmo_dev_dir())
+ init_done = True
+
+
+def make(cfg, limit_section=None):
+ targets = []
+
+ for section in cfg:
+ section_data = cfg[section]
+ if section == "testsuite":
+ # Gets built with testenv.testsuite.build()
+ continue
+ if limit_section and limit_section != section:
+ # When called from testenv.podman.install_packages as fallback to
+ # not having a package available, then we only want to run make
+ # for the target of one specific config section
+ continue
+
+ if "make" in section_data and section_data["make"] != "no" and section_data["make"] not in targets:
+ targets += [section_data["make"]]
+
+ if not targets:
+ logging.debug("No osmo-dev make targets found in testenv.cfg")
+ return
+
+ makefile_path = os.path.join(make_dir, "Makefile")
+ with open(makefile_path) as f:
+ makefile = f.read()
+
+ for target in targets:
+ if f"\n{target}:" in makefile:
+ continue
+ logging.error(f"Could not find make target: {target}")
+ logging.error("Add it to osmo-dev by adjusting:")
+ logging.error("* all.deps")
+ logging.error("* all.buildsystems (if buildsystem != autotools)")
+ logging.error("* all.urls (if the project is not on gerrit.osmocom.org)")
+ logging.error("Location of your osmo-dev.git clone:")
+ logging.error(os.path.join(testenv.src_dir, "osmo-dev"))
+ sys.exit(1)
+
+ logging.info("Building test components")
+ testenv.cmd.run(["make"] + targets, cwd=make_dir)
diff --git a/_testenv/testenv/podman.py b/_testenv/testenv/podman.py
new file mode 100644
index 00000000..bda6f9da
--- /dev/null
+++ b/_testenv/testenv/podman.py
@@ -0,0 +1,287 @@
+# Copyright 2024 sysmocom - s.f.m.c. GmbH
+# SPDX-License-Identifier: GPL-3.0-or-later
+import atexit
+import datetime
+import json
+import logging
+import multiprocessing
+import os
+import shlex
+import subprocess
+import testenv.cmd
+import testenv.testdir
+import time
+
+image_name = None
+distro = None
+container_name = None # instance of image
+apt_dir_var_cache = None
+apt_dir_var_lib = None
+feed_watchdog_process = None
+run_shell_on_stop = False
+
+
+def image_exists():
+ return testenv.cmd.run(["podman", "image", "exists", image_name], check=False).returncode == 0
+
+
+def image_up_to_date():
+ history = testenv.cmd.run(
+ ["podman", "history", image_name, "--format", "json"],
+ capture_output=True,
+ text=True,
+ )
+ created = json.loads(history.stdout)[0]["created"].split(".", 1)[0]
+ created = datetime.datetime.strptime(created, "%Y-%m-%dT%H:%M:%S")
+ logging.debug(f"Image creation date: {created}")
+
+ # On a local development system, we can say that the podman image is
+ # outdated if the Dockerfile is newer than the creation date. But this does
+ # not work for jenkins where Dockerfile may just be from a new git
+ # checkout. So allow to skip this check.
+ if os.environ.get("TESTENV_NO_IMAGE_UP_TO_DATE_CHECK"):
+ logging.debug("Assuming the podman image is up-to-date")
+ return True
+
+ dockerfile = os.path.join(testenv.data_dir, "podman/Dockerfile")
+ mtime = os.stat(dockerfile).st_mtime
+ mtime = datetime.datetime.utcfromtimestamp(mtime)
+ logging.debug(f"Dockerfile last modified: {str(mtime).split('.')[0]}")
+
+ return mtime < created
+
+
+def image_build():
+ if image_exists() and image_up_to_date():
+ logging.debug(f"Podman image is up-to-date: {image_name}")
+ if testenv.args.force:
+ logging.debug("Building anyway since --force was used")
+ else:
+ return
+
+ logging.info(f"Building podman image: {image_name}")
+ testenv.cmd.run(
+ [
+ "buildah",
+ "build",
+ "--build-arg",
+ f"DISTRO={distro}",
+ "-t",
+ image_name,
+ os.path.join(testenv.data_dir, "podman"),
+ ]
+ )
+
+
+def generate_env_podman(env={}):
+ ret = []
+
+ for key, val in testenv.cmd.generate_env(env, True).items():
+ ret += ["-e", f"{key}={val}"]
+
+ return ret
+
+
+def init_image_name_distro():
+ global image_name
+ global distro
+
+ distro = getattr(testenv.args, "distro", testenv.distro_default)
+ image_name = f"{distro}-osmo-ttcn3-testenv"
+ image_name = image_name.replace(":", "-").replace("_", "-")
+
+
+def init():
+ global apt_dir_var_cache
+ global apt_dir_var_lib
+ global run_shell_on_stop
+
+ apt_dir_var_cache = os.path.join(testenv.args.cache, "podman", "var-cache-apt")
+ apt_dir_var_lib = os.path.join(testenv.args.cache, "podman", "var-lib-apt")
+
+ os.makedirs(apt_dir_var_cache, exist_ok=True)
+ os.makedirs(apt_dir_var_lib, exist_ok=True)
+ os.makedirs(testenv.args.ccache, exist_ok=True)
+
+ init_image_name_distro()
+
+ if not image_exists():
+ raise testenv.NoTraceException("Missing podman image, run 'testenv.py init podman' first to build it")
+ if not image_up_to_date():
+ logging.warning("The podman image might be outdated, consider running 'testenv.py init podman' to rebuild it")
+
+ atexit.register(stop)
+
+ if testenv.args.shell:
+ run_shell_on_stop = True
+
+
+def exec_cmd(cmd, podman_opts=[], cwd=None, env={}, *args, **kwargs):
+ podman_opts = list(podman_opts)
+ podman_opts += generate_env_podman(env)
+ # Attach a fake tty (eclipse-titan won't print colored output otherwise)
+ podman_opts += ["-t"]
+
+ if cwd:
+ podman_opts += ["-w", cwd]
+
+ if isinstance(cmd, str):
+ cmd = ["sh", "-c", cmd]
+
+ testenv.cmd.run(
+ ["podman", "exec"] + podman_opts + [container_name] + cmd,
+ no_podman=True,
+ *args,
+ **kwargs,
+ )
+
+
+def exec_cmd_background(cmd, podman_opts=[], cwd=None, env={}):
+ podman_opts = list(podman_opts) + generate_env_podman(env)
+
+ if cwd:
+ podman_opts += ["-w", cwd]
+
+ if isinstance(cmd, str):
+ cmd = ["sh", "-c", cmd]
+
+ cmd = ["podman", "exec"] + podman_opts + [container_name] + cmd
+ logging.debug(f"+ {cmd}")
+
+ return subprocess.Popen(cmd)
+
+
+def feed_watchdog_loop():
+ # The script testenv-podman-main.sh checks every 10s for /tmp/watchdog and
+ # deletes the file. Create it here every 5s so the container keeps running.
+ # This ensures that if we run in jenkins and the job gets aborted, the
+ # container will terminate after a few seconds.
+ try:
+ while True:
+ time.sleep(5)
+ p = subprocess.run(["podman", "exec", container_name, "touch", "/tmp/watchdog"])
+ if p.returncode:
+ logging.error("podman container crashed!")
+ return
+ except KeyboardInterrupt:
+ pass
+
+
+def start():
+ global container_name
+ global feed_watchdog_process
+
+ testdir_topdir = testenv.testdir.testdir_topdir
+ osmo_dev_dir = testenv.osmo_dev.get_osmo_dev_dir()
+ container_name = testenv.testdir.prefix
+ # Custom seccomp profile that allows io_uring
+ seccomp = os.path.join(testenv.data_dir, "podman/seccomp.json")
+
+ cmd = [
+ "podman",
+ "run",
+ "--rm",
+ "--name",
+ container_name,
+ "--detach",
+ f"--security-opt=seccomp={seccomp}",
+ "--cap-add=NET_ADMIN", # for dumpcap, tun devices, osmo-pcap-client
+ "--cap-add=NET_RAW", # for dumpcap, osmo-pcap-client
+ "--device=/dev/net/tun", # for e.g. ggsn_tests
+ "--volume",
+ f"{apt_dir_var_cache}:/var/cache/apt",
+ "--volume",
+ f"{apt_dir_var_lib}:/var/lib/apt",
+ ]
+
+ if not testenv.args.binary_repo:
+ cmd += [
+ "--volume",
+ f"{osmo_dev_dir}:{osmo_dev_dir}",
+ ]
+
+ cmd += [
+ "--volume",
+ f"{testdir_topdir}:{testdir_topdir}",
+ "--volume",
+ f"{testenv.args.cache}:{testenv.args.cache}",
+ "--volume",
+ f"{testenv.args.ccache}:{testenv.args.ccache}",
+ "--volume",
+ f"{testenv.src_dir}:{testenv.src_dir}",
+ image_name,
+ os.path.join(testenv.data_dir, "scripts/testenv-podman-main.sh"),
+ ]
+
+ testenv.cmd.run(cmd, no_podman=True)
+
+ feed_watchdog_process = multiprocessing.Process(target=feed_watchdog_loop)
+ feed_watchdog_process.start()
+
+ exec_cmd(["rm", "/etc/apt/apt.conf.d/docker-clean"])
+
+ pkgcache = os.path.join(apt_dir_var_cache, "pkgcache.bin")
+ if not os.path.exists(pkgcache):
+ exec_cmd(["apt-get", "-q", "update"])
+
+
+def distro_to_repo_dir(distro):
+ if distro == "debian:bookworm":
+ return "Debian_12"
+ raise RuntimeError(f"Can't translate distro {distro} to repo_dir!")
+
+
+def enable_binary_repo():
+ config = "deb [signed-by=/obs.key]"
+ config += " https://downloads.osmocom.org/packages/"
+ config += testenv.args.binary_repo.replace(":", ":/")
+ config += "/"
+ config += distro_to_repo_dir(distro)
+ config += "/ ./"
+
+ path = "/etc/apt/sources.list.d/osmocom.list"
+
+ exec_cmd(["sh", "-c", f"echo {shlex.quote(config)} > {path}"])
+ exec_cmd(["apt-get", "-q", "update"])
+
+
+def is_running():
+ if container_name is None:
+ return False
+
+ cmd = ["podman", "ps", "-q", "--filter", f"name={container_name}"]
+ if not subprocess.run(cmd, capture_output=True, text=True).stdout:
+ return False
+
+ return True
+
+
+def stop(restart=False):
+ global container_name
+ global run_shell_on_stop
+
+ if not is_running():
+ return
+
+ if not restart and run_shell_on_stop:
+ logging.info("Running interactive shell before stopping container (--shell)")
+
+ # stdin=None: override stdin=/dev/null, so we can type into the shell
+ exec_cmd(["bash"], ["-i"], cwd=testenv.testdir.testdir, stdin=None, check=False)
+
+ run_shell_on_stop = False
+
+ restart_msg = " (restart)" if restart else ""
+ logging.info(f"Stopping podman container{restart_msg}")
+ testenv.cmd.run(["podman", "kill", container_name], no_podman=True)
+
+ if feed_watchdog_process:
+ feed_watchdog_process.terminate()
+
+ if restart:
+ testenv.cmd.run(["podman", "wait", container_name], no_podman=True, check=False)
+
+ container_name = None
+
+ if restart:
+ start()
diff --git a/_testenv/testenv/podman_install.py b/_testenv/testenv/podman_install.py
new file mode 100644
index 00000000..50ebea8e
--- /dev/null
+++ b/_testenv/testenv/podman_install.py
@@ -0,0 +1,153 @@
+# Copyright 2024 sysmocom - s.f.m.c. GmbH
+# SPDX-License-Identifier: GPL-3.0-or-later
+import logging
+import multiprocessing
+import os
+import sys
+import testenv.cmd
+import testenv.podman
+
+git_dir = None
+bb_dir = None
+trxcon_dir = None
+sccp_dir = None
+jobs = None
+
+
+def init():
+ global git_dir
+ global bb_dir
+ global trxcon_dir
+ global sccp_dir
+ global jobs
+
+ git_dir = os.path.join(testenv.args.cache, "git")
+ bb_dir = os.path.join(git_dir, "osmocom-bb")
+ trxcon_dir = os.path.join(bb_dir, "src/host/trxcon")
+ sccp_dir = os.path.join(git_dir, "libosmo-sccp")
+ jobs = multiprocessing.cpu_count() + 1
+
+ os.makedirs(git_dir, exist_ok=True)
+
+
+def apt_install(pkgs):
+ if not pkgs:
+ return
+
+ # Remove duplicates
+ pkgs = list(set(pkgs))
+
+ logging.info(f"Installing packages: {', '.join(pkgs)}")
+ testenv.cmd.run(["apt-get", "-q", "install", "-y", "--no-install-recommends"] + pkgs)
+
+
+def clone_osmocom_bb():
+ if os.path.exists(bb_dir):
+ logging.debug("osmocom-bb: already cloned")
+ return
+
+ testenv.cmd.run(
+ [
+ "git",
+ "-C",
+ git_dir,
+ "clone",
+ "--depth",
+ "1",
+ "https://gerrit.osmocom.org/osmocom-bb",
+ ]
+ )
+
+
+def clone_libosmo_sccp():
+ if os.path.exists(sccp_dir):
+ logging.debug("libosmo-sccp: already cloned")
+ return
+
+ testenv.cmd.run(
+ [
+ "git",
+ "-C",
+ git_dir,
+ "clone",
+ "--depth",
+ "1",
+ "https://gerrit.osmocom.org/libosmo-sccp",
+ ]
+ )
+
+
+def from_source_trxcon():
+ trxcon_in_srcdir = os.path.join(trxcon_dir, "src/trxcon")
+
+ if not os.path.exists(trxcon_in_srcdir):
+ clone_osmocom_bb()
+ apt_install(["libosmocore-dev"])
+ logging.info("Building trxcon")
+ testenv.cmd.run(["autoreconf", "-fi"], cwd=trxcon_dir)
+ testenv.cmd.run(["./configure"], cwd=trxcon_dir)
+ testenv.cmd.run(["make", "-j", f"{jobs}"], cwd=trxcon_dir)
+
+ testenv.cmd.run(["ln", "-s", trxcon_in_srcdir, "/usr/local/bin/trxcon"])
+
+
+def from_source_sccp_demo_user():
+ sccp_demo_user_path = os.path.join(sccp_dir, "examples/sccp_demo_user")
+
+ # Install libraries even if not building sccp_demo_user, because it gets
+ # linked dynamically against them.
+ apt_install(
+ [
+ "libosmo-netif-dev",
+ "libosmocore-dev",
+ ]
+ )
+
+ if not os.path.exists(sccp_demo_user_path):
+ clone_libosmo_sccp()
+ logging.info("Building sccp_demo_user")
+ testenv.cmd.run(["autoreconf", "-fi"], cwd=sccp_dir)
+ testenv.cmd.run(["./configure"], cwd=sccp_dir)
+ testenv.cmd.run(
+ ["make", "-j", f"{jobs}", "libosmo-sigtran.la"],
+ cwd=os.path.join(sccp_dir, "src"),
+ )
+ testenv.cmd.run(
+ ["make", "-j", f"{jobs}", "sccp_demo_user"],
+ cwd=os.path.join(sccp_dir, "examples"),
+ )
+
+ testenv.cmd.run(["ln", "-s", sccp_demo_user_path, "/usr/local/bin/sccp_demo_user"])
+
+
+def from_source(cfg, cfg_name, section):
+ program = cfg[section]["program"]
+ if program == "trxcon":
+ return from_source_trxcon()
+ if program == "run_fake_trx.sh":
+ return clone_osmocom_bb()
+ if program == "run_sccp_demo_user.sh":
+ return from_source_sccp_demo_user()
+
+ logging.error(f"Can't install {section}! Fix this by either:")
+ logging.error(f"* Adding package= to [{section}] in {cfg_name}")
+ logging.error(" (if it can be installed from binary packages)")
+ logging.error("* Editing from_source() in testenv/podman_install.py")
+ sys.exit(1)
+
+
+def packages(cfg, cfg_name):
+ packages = []
+
+ for section in cfg:
+ if section in ["DEFAULT", "testsuite"]:
+ continue
+ section_data = cfg[section]
+ if "package" in section_data:
+ if section_data["package"] == "no":
+ continue
+ packages += section_data["package"].split(" ")
+ else:
+ from_source(cfg, cfg_name, section)
+
+ apt_install(packages)
diff --git a/_testenv/testenv/requirements.py b/_testenv/testenv/requirements.py
new file mode 100644
index 00000000..7badbe95
--- /dev/null
+++ b/_testenv/testenv/requirements.py
@@ -0,0 +1,83 @@
+# Copyright 2024 sysmocom - s.f.m.c. GmbH
+# SPDX-License-Identifier: GPL-3.0-or-later
+import logging
+import os.path
+import shutil
+import sys
+import testenv
+import testenv.cmd
+import testenv.testsuite
+
+
+def check_programs():
+ programs = [
+ "git",
+ ]
+
+ if testenv.args.podman:
+ programs += [
+ "buildah",
+ "podman",
+ "rsync",
+ ]
+ else:
+ programs += [
+ "autoconf",
+ "automake",
+ "ccache",
+ "dumpcap",
+ "g++",
+ "gcc",
+ "make",
+ "pkg-config",
+ "setcap",
+ "ttcn3_compiler",
+ "wget",
+ ]
+
+ abort = False
+ for program in programs:
+ if not shutil.which(program):
+ logging.error(f"Missing program: {program}")
+
+ if program == "ttcn3_compiler":
+ logging.error(" Install eclipse-titan, e.g. from osmocom:latest:")
+ logging.error(" https://osmocom.org/projects/cellular-infrastructure/wiki/Latest_Builds")
+ abort = True
+
+ if abort:
+ sys.exit(1)
+
+
+def check_fftranscode():
+ cmd = [
+ "grep",
+ "-q",
+ "fftranscode",
+ os.path.join(
+ testenv.testsuite.ttcn3_hacks_dir_src,
+ testenv.args.testsuite,
+ "regen_makefile.sh",
+ ),
+ ]
+ if testenv.cmd.run(cmd, check=False).returncode == 1:
+ return
+
+ cmd = ["pkg-config", "--modversion", "libfftranscode"]
+ if testenv.cmd.run(cmd, check=False).returncode == 0:
+ return
+
+ logging.error("Missing library: libfftranscode")
+ logging.error(
+ " https://osmocom.org/projects/cellular-infrastructure/wiki/Titan_TTCN3_Testsuites#Proprietary-APERlt-gtBER-transcoding-library-for-Iu-tests"
+ )
+ logging.error(" Consider installing it from here:")
+ logging.error(" https://ftp.osmocom.org/binaries/libfftranscode/")
+ sys.exit(1)
+
+
+def check():
+ check_programs()
+
+ if not testenv.args.podman:
+ check_fftranscode()
diff --git a/_testenv/testenv/testdir.py b/_testenv/testenv/testdir.py
new file mode 100644
index 00000000..f7abe2ed
--- /dev/null
+++ b/_testenv/testenv/testdir.py
@@ -0,0 +1,177 @@
+# Copyright 2024 sysmocom - s.f.m.c. GmbH
+# SPDX-License-Identifier: GPL-3.0-or-later
+import atexit
+import datetime
+import glob
+import logging
+import os
+import os.path
+import tempfile
+import testenv
+import testenv.cmd
+import testenv.testsuite
+import uuid
+
+
+# Some testsuites have multiple configurations (like bts: generic, hopping, …).
+# For each configuration, prepare() gets called. testdir is the one for the
+# current configuration.
+testdir = None
+testdir_topdir = None
+prefix = None
+clean_scripts = {}
+
+
+def init():
+ global testdir_topdir
+ global prefix
+
+ prefix = f"testenv-{testenv.args.testsuite}-"
+ if testenv.args.config:
+ prefix += f"{'-'.join(testenv.args.config)}-"
+ if testenv.args.binary_repo:
+ prefix += f"{testenv.args.binary_repo.replace(':','-')}-"
+ prefix += datetime.datetime.now().strftime("%Y%m%d-%H%M")
+ prefix += f"-{str(uuid.uuid4()).split('-', 1)[0]}"
+
+ if testenv.args.log_dir:
+ testdir_topdir = testenv.args.log_dir
+ os.makedirs(testdir_topdir)
+ else:
+ testdir_topdir = tempfile.mkdtemp(prefix=f"{prefix}-")
+
+ atexit.register(clean)
+
+ logging.info(f"Logging to: {testdir_topdir}")
+
+ # Add a convenience symlink
+ if not testenv.args.log_dir:
+ if os.path.exists("/tmp/logs"):
+ testenv.cmd.run(["rm", "/tmp/logs"], no_podman=True)
+ testenv.cmd.run(["ln", "-sf", testdir_topdir, "/tmp/logs"], no_podman=True)
+
+
+def prepare(cfg_name, cfg):
+ global testdir
+ global clean_scripts
+
+ if len(testenv.testenv_cfg.cfgs) == 1:
+ testdir = testdir_topdir
+ else:
+ testdir = os.path.join(testdir_topdir, cfg_name.replace("testenv_", "").replace(".cfg", ""))
+
+ logging.info(f"Preparing testdir: {testdir}")
+ testsuite_dir = os.path.join(testenv.testsuite.ttcn3_hacks_dir, testenv.args.testsuite)
+
+ atexit.register(clean_run_scripts)
+
+ for section in cfg:
+ if section in ["DEFAULT"]:
+ continue
+
+ section_data = cfg[section]
+ section_dir = os.path.join(testdir, section)
+ os.makedirs(section_dir)
+
+ if "config" in section_data and section == "testsuite":
+ file = section_data["config"]
+ path = os.path.join(testsuite_dir, file)
+ path_dest = os.path.join(section_dir, file)
+ testenv.cmd.run(["install", "-Dm644", path, path_dest])
+
+ if "copy" in section_data:
+ for file in section_data["copy"].split(" "):
+ path = os.path.join(testsuite_dir, file)
+ path_dest = os.path.join(section_dir, file)
+ mode = 755 if os.access(path, os.X_OK) else 644
+ testenv.cmd.run(["install", f"-Dm{mode}", path, path_dest])
+
+ if "clean" in section_data:
+ logging.info(f"Running {section} clean script (reason: prepare)")
+ clean_scripts[section] = section_data["clean"]
+ env = {"TESTENV_CLEAN_REASON": "prepare"}
+ testenv.cmd.run(section_data["clean"], cwd=section_dir, env=env)
+
+ if "prepare" in section_data:
+ logging.info(f"Running {section} prepare script")
+ testenv.cmd.run(section_data["prepare"], cwd=section_dir)
+
+ # Referenced in testsuite cfgs: *.default
+ pattern = os.path.join(testsuite_dir, "*.default")
+ for path in glob.glob(pattern):
+ path_dest = os.path.join(testdir, "testsuite", os.path.basename(path))
+ testenv.cmd.run(["install", "-Dm644", path, path_dest])
+
+ # Referenced in testsuite cfgs: Common.cfg
+ common_cfg = os.path.join(testdir, "testsuite", "Common.cfg")
+ path = os.path.join(testenv.testsuite.ttcn3_hacks_dir, "Common.cfg")
+ testenv.cmd.run(["install", "-Dm644", path, common_cfg])
+ testenv.cmd.run(
+ [
+ "sed",
+ "-i",
+ f's#TTCN3_HACKS_PATH := .*#TTCN3_HACKS_PATH := "{testenv.testsuite.ttcn3_hacks_dir}"#',
+ common_cfg,
+ ]
+ )
+
+ # Adjust testsuite config: set mp_osmo_repo, set Common.cfg path in the
+ # testsuite's config and in all configs it may include
+ mp_osmo_repo = "latest" if testenv.args.latest else "nightly"
+ line = f'Misc_Helpers.mp_osmo_repo := "{mp_osmo_repo}"'
+
+ patterns = [
+ os.path.join(testdir, "testsuite/**/*.cfg"),
+ os.path.join(testdir, "testsuite/**/*.default"),
+ ]
+ for pattern in patterns:
+ for cfg_file in glob.glob(pattern, recursive=True):
+ logging.debug(f"Adjusting testsuite config: {cfg_file}")
+ testenv.cmd.run(
+ [
+ "sed",
+ "-i",
+ "-e",
+ f"s/\\[MODULE_PARAMETERS\\]/\\[MODULE_PARAMETERS\\]\\n{line}/g",
+ "-e",
+ "s#../Common.cfg#Common.cfg#",
+ cfg_file,
+ ]
+ )
+
+
+def clean():
+ """Don't leave behind an empty testdir_topdir, e.g. if testenv.py aborted
+ during build of components."""
+ # Show log dir path/link if it isn't empty
+ if os.listdir(testdir_topdir):
+ msg = "Logs saved to:"
+
+ url = os.environ.get("BUILD_URL") # Jenkins sets this
+ if url:
+ # Add a space at the end, so jenkins can transform this into a link
+ # without adding the color reset escape code to it
+ msg += f" {url}artifact/logs/ "
+ else:
+ msg += f" {testdir_topdir}"
+ if not testenv.args.log_dir:
+ msg += " (symlink: /tmp/logs)"
+ logging.info(msg)
+ return
+
+ logging.debug("Remving empty log dir")
+ testenv.cmd.run(["rm", "-d", testdir_topdir], no_podman=True)
+
+ # Remove broken symlink
+ if not testenv.args.log_dir and os.path.lexists("/tmp/logs") and not os.path.exists("/tmp/logs"):
+ testenv.cmd.run(["rm", "/tmp/logs"], no_podman=True)
+
+
+def clean_run_scripts(reason="crashed"):
+ global clean_scripts
+
+ for section, script in clean_scripts.items():
+ logging.info(f"Running {section} clean script (reason: {reason})")
+ env = {"TESTENV_CLEAN_REASON": reason}
+ testenv.cmd.run(script, cwd=os.path.join(testdir, section), env=env)
+ clean_scripts = {}
diff --git a/_testenv/testenv/testenv_cfg.py b/_testenv/testenv/testenv_cfg.py
new file mode 100644
index 00000000..64f2423b
--- /dev/null
+++ b/_testenv/testenv/testenv_cfg.py
@@ -0,0 +1,189 @@
+# Copyright 2024 sysmocom - s.f.m.c. GmbH
+# SPDX-License-Identifier: GPL-3.0-or-later
+import configparser
+import glob
+import logging
+import os.path
+import sys
+import testenv
+import testenv.testsuite
+
+cfgs = {}
+current = None
+
+
+def set_current(cfg_name):
+ global current
+ current = cfg_name
+
+ if cfg_name == "testenv.cfg":
+ testenv.set_log_prefix("[testenv]")
+ else:
+ cfg_name = cfg_name.replace("testenv_", "")
+ cfg_name = cfg_name.replace(".cfg", "")
+ testenv.set_log_prefix(f"[testenv][{cfg_name}]")
+
+
+def exit_error_readme():
+ readme = os.path.join(testenv.testsuite.ttcn3_hacks_dir_src, "_testenv/README.md")
+ logging.error(f"More information: {readme}")
+ sys.exit(1)
+
+
+def handle_latest(cfg, path):
+ """Remove _latest keys from cfg or use them instead of the regular keys,
+ if --latest is set."""
+
+ for section in cfg:
+ for key in cfg[section]:
+ if not key.endswith("_latest"):
+ continue
+
+ if testenv.args.latest:
+ key_regular = key.replace("_latest", "")
+ logging.debug(f"{path}: [{section}]: using {key} instead of {key_regular} (--latest is set)")
+ cfg[section][key_regular] = cfg[section][key]
+ else:
+ logging.debug(f"{path}: [{section}]: ignoring {key} (--latest is not set)")
+
+ del cfg[section][key]
+
+
+def verify(cfg, path):
+ keys_valid_testsuite = [
+ "clean",
+ "config",
+ "copy",
+ "program",
+ ]
+ keys_valid_component = [
+ "clean",
+ "copy",
+ "make",
+ "package",
+ "prepare",
+ "program",
+ "setup",
+ ]
+ keys_invalid = {
+ "configs": "config",
+ "packages": "package",
+ "programs": "program",
+ }
+
+ if "testsuite" not in cfg:
+ logging.error(f"{path}: missing [testsuite] section")
+ exit_error_readme()
+ if "program" not in cfg["testsuite"]:
+ logging.error(f"{path}: missing program= in [testsuite]")
+ exit_error_readme()
+ if " " in cfg["testsuite"]["program"]:
+ logging.error(f"{path}: program= in [testsuite] must not have arguments")
+ exit_error_readme()
+ if " " in cfg["testsuite"]["config"]:
+ logging.error(f"{path}: config= in [testsuite] must not have spaces")
+ exit_error_readme()
+ if "config" not in cfg["testsuite"]:
+ logging.error(f"{path}: missing config= in [testsuite]")
+ exit_error_readme()
+
+ for section in cfg:
+ for key in cfg[section].keys():
+ valid = keys_valid_component
+ if section == "testsuite":
+ valid = keys_valid_testsuite
+
+ if key in valid:
+ continue
+
+ msg = f"{path}: [{section}]: {key}= is invalid"
+ if key in keys_invalid and keys_invalid[key] in valid:
+ msg += f", did you mean {keys_invalid[key]}=?"
+
+ logging.error(msg)
+ exit_error_readme()
+
+ if section not in ["DEFAULT", "testsuite"] and "make" not in cfg[section]:
+ logging.error(f"{path}: missing make= in section [{section}].")
+ logging.error("If this is on purpose, set make=no.")
+ exit_error_readme()
+
+
+def raise_error_config_arg(glob_result):
+ valid = []
+ for path in glob_result:
+ basename = os.path.basename(path)
+ if basename != "testenv.cfg":
+ valid += [basename.split("_", 1)[1].split(".", -1)[0]]
+
+ msg = f"Invalid parameter for --config: {testenv.args.config}"
+
+ if valid:
+ msg += f" (valid: all, {', '.join(valid)})"
+ else:
+ msg += f" (the {testenv.args.testsuite} testsuite only has one testenv.cfg file, therefore just omit --config)"
+
+ raise testenv.NoTraceException(msg)
+
+
+def find_configs():
+ dir_testsuite = os.path.join(testenv.testsuite.ttcn3_hacks_dir_src, testenv.args.testsuite)
+ pattern = os.path.join(dir_testsuite, "testenv*.cfg")
+ ret = glob.glob(pattern)
+
+ if not ret:
+ logging.error(f"Missing testenv.cfg in: {dir_testsuite}")
+ exit_error_readme()
+ sys.exit(1)
+
+ if len(ret) > 1 and os.path.exists(os.path.join(dir_testsuite, "testenv.cfg")):
+ logging.error("Found multiple testenv*.cfg, and a testenv.cfg.")
+ logging.error("The testenv.cfg file must be renamed, consider naming it testenv_generic.cfg.")
+ sys.exit(1)
+
+ if len(ret) == 1 and not os.path.exists(os.path.join(dir_testsuite, "testenv.cfg")):
+ logging.error("There is only one testenv*.cfg file, so please rename it:")
+ logging.error(f"$ mv {os.path.basename(ret[0])} testenv.cfg")
+ sys.exit(1)
+
+ if len(ret) > 1 and not testenv.args.config:
+ logging.error("Found multiple testenv.cfg files:")
+ for path in ret:
+ logging.error(f" * {os.path.basename(path)}")
+ example = os.path.basename(ret[0]).split("_", 1)[1].split(".cfg", 1)[0]
+ logging.error(f"Select a specific config (e.g. '-c {example}') or all ('-c all')")
+ sys.exit(1)
+
+ return ret
+
+
+def init():
+ global cfgs
+
+ config_paths = find_configs()
+
+ for path in config_paths:
+ basename = os.path.basename(path)
+ if basename != "testenv.cfg" and not basename.startswith("testenv_"):
+ raise testenv.NoTraceException(
+ f"Invalid filename, expected either testenv.cfg or testenv_*.cfg: {basename}"
+ )
+ if basename == "testenv_all.cfg":
+ raise testenv.NoTraceException(f"Invalid filename: {basename}")
+
+ cfg = configparser.ConfigParser()
+ cfg.read(path)
+ handle_latest(cfg, path)
+ verify(cfg, path)
+
+ if not testenv.args.config:
+ cfgs[basename] = cfg
+ continue
+
+ for config_arg in testenv.args.config:
+ if config_arg == "all" or f"testenv_{config_arg}.cfg" == basename:
+ cfgs[basename] = cfg
+ break
+
+ if not cfgs:
+ raise_error_config_arg(config_paths)
diff --git a/_testenv/testenv/testsuite.py b/_testenv/testenv/testsuite.py
new file mode 100644
index 00000000..88ea4677
--- /dev/null
+++ b/_testenv/testenv/testsuite.py
@@ -0,0 +1,218 @@
+# Copyright 2024 sysmocom - s.f.m.c. GmbH
+# SPDX-License-Identifier: GPL-3.0-or-later
+import atexit
+import glob
+import logging
+import multiprocessing
+import os
+import os.path
+import shlex
+import shutil
+import subprocess
+import testenv
+import testenv.cmd
+import time
+
+ttcn3_hacks_dir = None
+ttcn3_hacks_dir_src = os.path.realpath(f"{__file__}/../../..")
+testsuite_proc = None
+
+
+def update_deps():
+ deps_marker = os.path.join(testenv.args.cache, "ttcn3-deps-updated")
+ if os.path.exists(deps_marker):
+ return
+
+ logging.info("Updating osmo-ttcn3-hacks/deps")
+ deps_dir = os.path.join(ttcn3_hacks_dir_src, "deps")
+ testenv.cmd.run(["make", "-C", deps_dir])
+ testenv.cmd.run(["touch", deps_marker])
+
+
+def copy_ttcn3_hacks_dir():
+ """Copy source files of osmo-ttcn3-hacks.git to the cache dir, so we don't
+ mix binary objects from host and inside podman that are very likely to
+ be incompatible"""
+ global ttcn3_hacks_dir
+
+ ttcn3_hacks_dir = os.path.join(testenv.args.cache, "podman", "osmo-ttcn3-hacks")
+
+ logging.info(f"Copying osmo-ttcn3-hacks sources to: {ttcn3_hacks_dir}")
+
+ # Rsync can't directly parse the .gitignore with ! rules, so create a list
+ # of files to be copied with git
+ copy_list = os.path.join(testenv.args.cache, "podman", "ttcn3-copy-list")
+ testenv.cmd.run(
+ f"git ls-files -o -c --exclude-standard > {shlex.quote(copy_list)}",
+ cwd=ttcn3_hacks_dir_src,
+ no_podman=True,
+ )
+
+ # Copy source files, excluding binary objects
+ testenv.cmd.run(
+ [
+ "rsync",
+ "--links",
+ "--recursive",
+ f"--files-from={copy_list}",
+ f"{ttcn3_hacks_dir_src}/",
+ f"{ttcn3_hacks_dir}/",
+ ],
+ no_podman=True,
+ )
+
+ # The "deps" dir is in gitignore, copy it separately
+ testenv.cmd.run(
+ [
+ "rsync",
+ "--links",
+ "--recursive",
+ "--exclude",
+ "/.git",
+ f"{ttcn3_hacks_dir_src}/deps/",
+ f"{ttcn3_hacks_dir}/deps/",
+ ],
+ no_podman=True,
+ )
+
+
+def prepare_testsuite_dir():
+ testsuite_dir = f"{ttcn3_hacks_dir}/{testenv.args.testsuite}"
+ logging.info(f"Generating links and Makefile for {testenv.args.testsuite}")
+ testenv.cmd.run(["./gen_links.sh"], cwd=testsuite_dir)
+ testenv.cmd.run("USE_CCACHE=1 ./regen_makefile.sh", cwd=testsuite_dir)
+
+
+def init():
+ global ttcn3_hacks_dir
+
+ atexit.register(stop)
+
+ update_deps()
+
+ if testenv.args.podman:
+ copy_ttcn3_hacks_dir()
+ else:
+ ttcn3_hacks_dir = ttcn3_hacks_dir_src
+
+ prepare_testsuite_dir()
+
+
+def build():
+ logging.info("Building testsuite")
+ testsuite_dir = f"{ttcn3_hacks_dir}/{testenv.args.testsuite}"
+ testenv.cmd.run(["make", "compile"], cwd=testsuite_dir)
+
+ jobs = multiprocessing.cpu_count() + 1
+ testenv.cmd.run(["make", "-j", f"{jobs}"], cwd=testsuite_dir)
+
+
+def is_running(pid):
+ # Check if a process is still running, or if it is dead / a zombie. We
+ # can't just use proc.poll() because this gets called from another thread.
+ cmdline = f"/proc/{pid}/cmdline"
+ if not os.path.exists(cmdline):
+ return False
+
+ # The cmdline file is empty if it is a zombie
+ with open(cmdline) as f:
+ return f.read() != ""
+
+
+def merge_log_files(cfg):
+ section_data = cfg["testsuite"]
+ cwd = os.path.join(testenv.testdir.testdir, "testsuite")
+
+ logging.info("Merging log files")
+ log_merge = os.path.join(ttcn3_hacks_dir, "log_merge.sh")
+ # stdout of this script is very verbose, making it harder to see the output
+ # that matters (tests failed or not), so redirect it to /dev/null
+ cmd = f"{shlex.quote(log_merge)} {shlex.quote(section_data['program'])} --rm >/dev/null"
+ testenv.cmd.run(cmd, cwd=cwd)
+
+
+def format_log_files(cfg):
+ cwd = os.path.join(testenv.testdir.testdir, "testsuite")
+
+ logging.info("Formatting log files")
+ cmd = os.path.join(testenv.data_dir, "scripts/log_format.sh")
+ testenv.cmd.run(cmd, cwd=cwd)
+
+
+def cat_junit_logs():
+ tool = "cat"
+
+ if testenv.args.podman or shutil.which("source-highlight"):
+ colors = os.environ.get("TESTENV_SOURCE_HIGHLIGHT_COLORS", "esc256")
+ tool = f"source-highlight -f {shlex.quote(colors)} -s xml -i"
+
+ pattern = os.path.join(testenv.testdir.testdir, "testsuite", "junit-*.log")
+ for path in glob.glob(pattern):
+ cmd = f"echo && {tool} {shlex.quote(path)} && echo"
+ logging.info(f"Showing {os.path.basename(path)}")
+ testenv.cmd.run(cmd)
+
+
+def run(cfg):
+ global testsuite_proc
+
+ section_data = cfg["testsuite"]
+
+ cwd = os.path.join(testenv.testdir.testdir, "testsuite")
+ start_testsuite = os.path.join(ttcn3_hacks_dir, "start-testsuite.sh")
+ suite = os.path.join(ttcn3_hacks_dir, testenv.args.testsuite, section_data["program"])
+
+ env = {
+ "TTCN3_PCAP_PATH": os.path.join(testenv.testdir.testdir, "testsuite"),
+ }
+
+ cmd = [start_testsuite, suite, section_data["config"]]
+
+ test_arg = testenv.args.test
+ if test_arg:
+ if "." in test_arg:
+ cmd += [test_arg]
+ else:
+ cmd += [f"{section_data['program']}.{test_arg}"]
+
+ logging.info("Running testsuite")
+
+ if testenv.podman.is_running():
+ testsuite_proc = testenv.podman.exec_cmd_background(cmd, cwd=cwd, env=env)
+ else:
+ logging.debug(f"+ {cmd}")
+ testsuite_proc = subprocess.Popen(cmd, cwd=cwd, env=env)
+
+ # Ensure all daemons run until the testsuite stops
+ while True:
+ time.sleep(1)
+
+ if not is_running(testsuite_proc.pid):
+ logging.debug("Testsuite is done")
+ stop()
+ break
+
+ for daemon_name, daemon_proc in testenv.daemons.daemons.items():
+ if not is_running(daemon_proc.pid):
+ raise testenv.NoTraceException(f"{daemon_name} crashed!")
+
+ merge_log_files(cfg)
+ format_log_files(cfg)
+
+
+def run_prepare_script(cfg):
+ section_data = cfg["testsuite"]
+ if "prepare" not in section_data:
+ return
+
+ logging.info("Running testsuite prepare script")
+ testenv.cmd.run(section_data["prepare"])
+
+
+def stop():
+ global testsuite_proc
+
+ if testsuite_proc:
+ logging.info(f"Stopping testsuite ({testsuite_proc.pid})")
+ testenv.daemons.kill(testsuite_proc.pid)
+ testsuite_proc = None
diff --git a/testenv.py b/testenv.py
new file mode 120000
index 00000000..36c795e9
--- /dev/null
+++ b/testenv.py
@@ -0,0 +1 @@
+_testenv/testenv.py \ No newline at end of file