aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorNeels Hofmeyr <neels@hofmeyr.de>2017-03-28 12:16:58 +0200
committerNeels Hofmeyr <neels@hofmeyr.de>2017-03-28 12:37:17 +0200
commitdae3d3c47906379061d57854fd140e8a7a12a25c (patch)
tree120abdd29437f4d78e971685aa21300439c6f449
parent0f2f19e9aa736f86a5a7cc3cbd2f5b1e325b0202 (diff)
initial import0.1
The original osmo-gsm-tester was an internal development at sysmocom, mostly by D. Laszlo Sitzer <dlsitzer@sysmocom.de>, of which this public osmo-gsm-tester is a refactoring / rewrite. This imports an early state of the refactoring and is not functional yet. Bits from the earlier osmo-gsm-tester will be added as needed. The earlier commit history is not imported.
-rw-r--r--Makefile15
-rwxr-xr-xcheck_dependencies.py26
-rwxr-xr-xcontrib/jenkins-openbsc-build.sh140
-rwxr-xr-xcontrib/jenkins-osmo-bts-octphy.sh94
-rwxr-xr-xcontrib/jenkins-osmo-bts-sysmo.sh68
-rwxr-xr-xcontrib/jenkins-osmo-bts-trx.sh61
-rwxr-xr-xcontrib/ts-dir-cleanup.sh30
-rw-r--r--doc/README-sysmobts.txt59
-rw-r--r--doc/README.txt92
-rw-r--r--install/ofono.service11
-rw-r--r--install/org.ofono.conf28
-rw-r--r--install/osmo-gsm-tester-limits.conf4
-rw-r--r--install/osmo-gsm-tester.service11
-rw-r--r--src/osmo_gsm_tester/__init__.py29
-rw-r--r--src/osmo_gsm_tester/config.py161
-rw-r--r--src/osmo_gsm_tester/log.py405
-rw-r--r--src/osmo_gsm_tester/process.py23
-rw-r--r--src/osmo_gsm_tester/resource.py51
-rw-r--r--src/osmo_gsm_tester/suite.py150
-rw-r--r--src/osmo_gsm_tester/template.py56
-rw-r--r--src/osmo_gsm_tester/templates/osmo-bts.cfg.tmpl21
-rw-r--r--src/osmo_gsm_tester/templates/osmo-nitb.cfg.tmpl87
-rw-r--r--src/osmo_gsm_tester/templates/osmo-pcu.cfg.tmpl6
-rw-r--r--src/osmo_gsm_tester/templates/osmo-sgsn.cfg.tmpl26
-rw-r--r--src/osmo_gsm_tester/templates/sysmobts-mgr.cfg.tmpl24
-rw-r--r--src/osmo_gsm_tester/test.py43
-rw-r--r--src/osmo_gsm_tester/utils.py118
-rwxr-xr-xsrc/run_once.py48
-rw-r--r--test/Makefile9
-rw-r--r--test/_prep.py16
-rwxr-xr-xtest/all_tests.py111
-rw-r--r--test/config_test.err0
-rw-r--r--test/config_test.ok46
-rwxr-xr-xtest/config_test.py70
-rw-r--r--test/config_test/test.cfg39
-rw-r--r--test/lock_test.err0
-rw-r--r--test/lock_test.ok8
-rwxr-xr-xtest/lock_test.sh10
-rw-r--r--test/lock_test_help.py17
-rw-r--r--test/log_test.err0
-rw-r--r--test/log_test.ok41
-rwxr-xr-xtest/log_test.py160
-rw-r--r--test/resource_test.err0
-rw-r--r--test/resource_test.ok0
-rwxr-xr-xtest/resource_test.py20
-rw-r--r--test/resource_test/etc/resources.conf115
-rw-r--r--test/suite_test.err0
-rw-r--r--test/suite_test.ok24
-rwxr-xr-xtest/suite_test.py29
-rw-r--r--test/suite_test/empty_dir/.unrelated_file0
-rw-r--r--test/suite_test/test_suite/hello_world.py3
-rw-r--r--test/suite_test/test_suite/mo_mt_sms.py18
-rw-r--r--test/suite_test/test_suite/mo_sms.py20
-rw-r--r--test/suite_test/test_suite/suite.conf9
-rw-r--r--test/suite_test/test_suite/test_error.py2
-rwxr-xr-xtest/suite_test/test_suite/test_error2.py8
-rw-r--r--test/template_test.err0
-rw-r--r--test/template_test.ok151
-rwxr-xr-xtest/template_test.py76
-rw-r--r--test/template_test/osmo-nitb.cfg.tmpl87
-rwxr-xr-xupdate_version.sh10
61 files changed, 2986 insertions, 0 deletions
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..f972675
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,15 @@
+all: deps version check
+
+.PHONY: version check
+
+deps:
+ ./check_dependencies.py
+
+version:
+ ./update_version.sh
+
+check:
+ $(MAKE) -C test check
+ @echo "make check: success"
+
+# vim: noexpandtab tabstop=8 shiftwidth=8
diff --git a/check_dependencies.py b/check_dependencies.py
new file mode 100755
index 0000000..d56e53b
--- /dev/null
+++ b/check_dependencies.py
@@ -0,0 +1,26 @@
+#!/usr/bin/env python3
+
+# just import all python3 modules used by osmo-gsm-tester to make sure they are
+# installed.
+
+from inspect import getframeinfo, stack
+from mako.lookup import TemplateLookup
+from mako.template import Template
+import argparse
+import contextlib
+import copy
+import difflib
+import fcntl
+import inspect
+import io
+import os
+import pprint
+import re
+import subprocess
+import sys
+import tempfile
+import time
+import traceback
+import yaml
+
+print('ok')
diff --git a/contrib/jenkins-openbsc-build.sh b/contrib/jenkins-openbsc-build.sh
new file mode 100755
index 0000000..e242927
--- /dev/null
+++ b/contrib/jenkins-openbsc-build.sh
@@ -0,0 +1,140 @@
+set -e -x
+
+prefix_base="`pwd`"
+prefix_dirname="inst-openbsc"
+prefix="$prefix_base/$prefix_dirname"
+
+reposes="
+libosmocore
+libosmo-abis
+libosmo-netif
+openggsn
+libsmpp34
+libosmo-sccp
+openbsc/openbsc
+"
+
+osmo_gsm_tester_host=root@10.9.1.190
+osmo_gsm_tester_dir="/var/tmp/osmo-gsm-tester"
+tmp_dir="/var/tmp/prep-osmo-gsm-tester"
+arch="x86_64"
+archive_name="openbsc-$arch-build-$BUILD_NUMBER"
+archive="$archive_name.tgz"
+manifest="manifest.txt"
+test_report="test-report.xml"
+test_timeout_sec=120
+
+rm -rf $prefix
+mkdir -p $prefix
+
+opt_prefix=""
+if [ -n "$prefix" ]; then
+ export LD_LIBRARY_PATH="$prefix"/lib
+ export PKG_CONFIG_PATH="$prefix"/lib/pkgconfig
+ opt_prefix="--prefix=$prefix"
+fi
+
+for r in $reposes; do
+ make -C "$r" clean || true
+done
+
+for r in $reposes; do
+
+ cd "$r"
+
+ echo "$(git rev-parse HEAD) $r" >> "$prefix/openbsc_git_hashes.txt"
+
+ autoreconf -fi
+
+ opt_enable=""
+ if [ "$r" = 'openbsc/openbsc' ]; then
+ opt_enable="--enable-smpp --enable-osmo-bsc --enable-nat"
+ fi
+
+ ./configure "$opt_prefix" $opt_enable
+
+ make -j || make || make
+ if [ "$r" != asn1c ]; then
+ if [ "$r" = 'libosmo-netif' ]; then
+ # skip clock dependent test in libosmo-netif
+ make check TESTSUITEFLAGS='-k !osmux_test'
+ else
+ make check
+ fi
+ fi
+ make install
+ cd ..
+done
+
+# create test session directory, archive and manifest
+
+cd $prefix_base
+
+ts_name="$NODE_NAME-$BUILD_TAG"
+local_ts_base="./compose_ts"
+local_ts_dir="$local_ts_base/$ts_name"
+
+rm -rf "$local_ts_base" || true
+mkdir -p "$local_ts_dir"
+
+# create archive of openbsc build
+tar czf "$local_ts_dir/$archive" "$prefix_dirname"/*
+# move archived bts builds into test session directory
+mv $WORKSPACE/osmo-bts-*.tgz "$local_ts_dir"
+cd "$local_ts_dir"
+md5sum *.tgz > $manifest
+cd -
+
+# transfer test session directory to temporary dir on osmo-gsm-tester host
+# when transfer is complete, move the directory to its final location (where
+# the osmo-gsm-tester will recognize the session directory and start the session
+
+ssh $osmo_gsm_tester_host "mkdir -p $tmp_dir"
+scp -r "$local_ts_dir" $osmo_gsm_tester_host:$tmp_dir/
+ssh $osmo_gsm_tester_host "mv $tmp_dir/$ts_name $osmo_gsm_tester_dir"
+
+# poll for test status
+ts_dir="$osmo_gsm_tester_dir/$ts_name"
+
+set +x
+ts_log=$ts_dir/test-session.log
+echo "Waiting for test session log to be created"
+while /bin/true; do
+ if ssh $osmo_gsm_tester_host "test -e $ts_log"; then
+ break
+ fi
+ sleep 1
+done
+
+echo "Following test session log"
+# NOTE this will leave dead ssh session with tail running
+ssh $osmo_gsm_tester_host "tail -f $ts_log" &
+
+echo "Waiting for test session to complete"
+while /bin/true; do
+# if [ "$test_timeout_sec" = "0" ]; then
+# echo "TIMEOUT test execution timeout ($test_timeout_sec seconds) exceeded!"
+# exit 1
+# fi
+ if ssh $osmo_gsm_tester_host "test -e $ts_dir/$test_report"; then
+ break
+ fi
+ sleep 1
+# test_timeout_sec="$(($test_timeout_sec - 1))"
+done
+set -x
+
+# use pgrep to terminate the ssh/tail (if it still exists)
+remote_tail_pid=`ssh $osmo_gsm_tester_host "pgrep -fx 'tail -f $ts_log'"`
+echo "remote_tail_pid = $remote_tail_pid"
+ssh $osmo_gsm_tester_host "kill $remote_tail_pid"
+
+# copy contents of test session directory back and remove it from the osmo-gsm-tester host
+
+rsync -av -e ssh --exclude='inst-*' --exclude='tmp*' $osmo_gsm_tester_host:$ts_dir/ "$local_ts_dir/"
+
+ssh $osmo_gsm_tester_host "/usr/local/src/osmo-gsm-tester/contrib/ts-dir-cleanup.sh"
+
+# touch test-report.xml (to make up for clock drift between jenkins and build slave)
+
+touch "$local_ts_dir/$test_report"
diff --git a/contrib/jenkins-osmo-bts-octphy.sh b/contrib/jenkins-osmo-bts-octphy.sh
new file mode 100755
index 0000000..a966083
--- /dev/null
+++ b/contrib/jenkins-osmo-bts-octphy.sh
@@ -0,0 +1,94 @@
+#!/bin/sh
+
+set -e
+
+OPTION_DO_CLONE=0
+OPTION_DO_CLEAN=0
+OPTION_DO_TEST=1
+
+PREFIX=`pwd`/inst-osmo-bts-octphy
+
+# NOTE Make sure either 'octphy-2g-headers' (prefered) or
+# 'octsdr-2g' is listed among the repositories
+
+octbts_repos="libosmocore
+libosmo-abis
+openbsc/openbsc
+octphy-2g-headers
+osmo-bts"
+
+clone_repos() {
+ repos="$1"
+ for repo in $repos; do
+ if [ -e $repo ]; then
+ continue
+ fi
+ if [ "$repo" = "libosmocore" ]; then
+ url="git://git.osmocom.org/libosmocore.git"
+ elif [ "$repo" = "libosmo-abis" ]; then
+ url="git://git.osmocom.org/libosmo-abis.git"
+ elif [ "$repo" = "libosmo-netif" ]; then
+ url="git://git.osmocom.org/libosmo-netif.git"
+ elif [ "$repo" = "openbsc/openbsc" ]; then
+ url="git://git.osmocom.org/openbsc"
+ elif [ "$repo" = "octphy-2g-headers" ]; then
+ url="git://git.osmocom.org/octphy-2g-headers"
+ elif [ "$repo" = "octsdr-2g" ]; then
+ # NOTE acutally we only need the headers from the octphy-2g-headers
+ # repository but this (private) repository contains more recent versions
+ url="ssh://git@git.admin.sysmocom.de/octasic/octsdr-2g"
+ elif [ "$repo" = "osmo-bts" ]; then
+ url="git://git.osmocom.org/osmo-bts.git"
+ else
+ exit 2
+ fi
+ git clone $url
+ done
+}
+
+main() {
+ repos="$1"
+ if [ $OPTION_DO_CLONE -eq 1 ]; then clone_repos "$repos"; fi
+ rm -rf $PREFIX
+ mkdir -p $PREFIX
+ for repo in $repos; do
+ if [ "$repo" = "openbsc/openbsc" ]; then
+ continue
+ fi
+ if [ "$repo" = "octphy-2g-headers" ]; then
+ OCTPHY_INCDIR=`pwd`/octphy-2g-headers
+ continue
+ fi
+ if [ "$repo" = "octsdr-2g" ]; then
+ cd $repo
+ git checkout 5c7166bab0a0f2d8a9664213d18642ae305e7004
+ cd -
+ OCTPHY_INCDIR=`pwd`/octsdr-2g/software/include
+ continue
+ fi
+ cd $repo
+ if [ $OPTION_DO_CLEAN -eq 1 ]; then git clean -dxf; fi
+ echo "$(git rev-parse HEAD) $repo" >> "$PREFIX/osmo-bts-octphy_git_hashes.txt"
+ autoreconf -fi
+ if [ "$repo" != "libosmocore" ]; then
+ export PKG_CONFIG_PATH=$PREFIX/lib/pkgconfig
+ export LD_LIBRARY_PATH=$PREFIX/lib:/usr/local/lib
+ fi
+ config_opts=""
+ case "$repo" in
+ 'osmo-bts') config_opts="$config_opts --enable-octphy --with-octsdr-2g=$OCTPHY_INCDIR"
+ esac
+ ./configure --prefix=$PREFIX $config_opts
+ make -j8
+ if [ $OPTION_DO_TEST -eq 1 ]; then make check; fi
+ make install
+ cd ..
+ done
+}
+
+set -x
+main "$octbts_repos"
+
+# build the archive that is going to be copied to the tester and then to the BTS
+rm -f $WORKSPACE/osmo-bts-octphy*.tgz
+tar czf $WORKSPACE/osmo-bts-octphy-build-$BUILD_NUMBER.tgz inst-osmo-bts-octphy
diff --git a/contrib/jenkins-osmo-bts-sysmo.sh b/contrib/jenkins-osmo-bts-sysmo.sh
new file mode 100755
index 0000000..142eddd
--- /dev/null
+++ b/contrib/jenkins-osmo-bts-sysmo.sh
@@ -0,0 +1,68 @@
+set -e -x
+
+deps="
+libosmocore
+libosmo-abis
+osmo-bts
+"
+
+base="$PWD"
+
+have_repo() {
+ repo="$1"
+ cd "$base"
+ if [ ! -e "$repo" ]; then
+ set +x
+ echo "MISSING REPOSITORY: $repo"
+ echo "should be provided by the jenkins workspace"
+ exit 1
+ fi
+ cd "$repo"
+ git clean -dxf
+ cd "$base"
+}
+
+for dep in $deps; do
+ have_repo "$dep"
+done
+
+# for gsm_data_shared.h
+have_repo openbsc
+
+. /opt/poky/1.5.4/environment-setup-armv5te-poky-linux-gnueabi
+
+export DESTDIR=/opt/poky/1.5.4/sysroots/armv5te-poky-linux-gnueabi
+
+prefix_base="/usr/local/jenkins-build"
+prefix_base_real="$DESTDIR$prefix_base"
+rm -rf "$prefix_base_real"
+
+prefix="$prefix_base/inst-osmo-bts-sysmo"
+prefix_real="$DESTDIR$prefix"
+mkdir -p "$prefix_real"
+
+for dep in $deps; do
+ cd "$base/$dep"
+
+ echo "$(git rev-parse HEAD) $dep" >> "$prefix_real/osmo-bts-sysmo_git_hashes.txt"
+
+ autoreconf -fi
+
+ config_opts=""
+ case "$dep" in
+ 'libosmocore') config_opts="--disable-pcsc" ;;
+ 'osmo-bts') config_opts="--enable-sysmocom-bts --with-openbsc=$base/openbsc/openbsc/include" ;;
+ esac
+
+ ./configure --prefix="$prefix" $CONFIGURE_FLAGS $config_opts
+ make -j8
+ make install
+done
+
+# build the archive that is going to be copied to the tester and then to the BTS
+tar_name="osmo-bts-sysmo-build-"
+if ls "$base/$tar_name"* ; then
+ rm -f "$base/$tar_name"*
+fi
+cd "$prefix_base_real"
+tar cvzf "$base/$tar_name${BUILD_NUMBER}.tgz" *
diff --git a/contrib/jenkins-osmo-bts-trx.sh b/contrib/jenkins-osmo-bts-trx.sh
new file mode 100755
index 0000000..b2b215b
--- /dev/null
+++ b/contrib/jenkins-osmo-bts-trx.sh
@@ -0,0 +1,61 @@
+set -x -e
+
+base="$PWD"
+inst="inst-osmo-bts-trx"
+prefix="$base/$inst"
+
+deps="
+libosmocore
+libosmo-abis
+osmo-trx
+osmo-bts
+"
+
+have_repo() {
+ repo="$1"
+ cd "$base"
+ if [ ! -e "$repo" ]; then
+ set +x
+ echo "MISSING REPOSITORY: $repo"
+ echo "should be provided by the jenkins workspace"
+ exit 1
+ fi
+ cd "$repo"
+ git clean -dxf
+ cd "$base"
+}
+
+# for gsm_data_shared.*
+have_repo openbsc
+
+
+rm -rf "$prefix"
+mkdir -p "$prefix"
+
+export PKG_CONFIG_PATH="$prefix/lib/pkgconfig"
+export LD_LIBRARY_PATH="$prefix/lib"
+
+for dep in $deps; do
+ have_repo "$dep"
+ cd "$dep"
+
+ echo "$(git rev-parse HEAD) $dep" >> "$prefix/osmo-bts-trx_osmo-trx_git_hashes.txt"
+
+ autoreconf -fi
+
+ config_opts=""
+
+ case "$repo" in
+ 'osmo-bts') config_opts="--enable-trx --with-openbsc=$base/openbsc/openbsc/include" ;;
+ 'osmo-trx') config_opts="--without-sse" ;;
+ esac
+
+ ./configure --prefix="$prefix" $config_opts
+ make -j8
+ make install
+done
+
+# build the archive that is going to be copied to the tester
+cd "$base"
+rm -f osmo-bts-trx*.tgz
+tar czf "osmo-bts-trx-build-${BUILD_NUMBER}.tgz" "$inst"
diff --git a/contrib/ts-dir-cleanup.sh b/contrib/ts-dir-cleanup.sh
new file mode 100755
index 0000000..ae5ea04
--- /dev/null
+++ b/contrib/ts-dir-cleanup.sh
@@ -0,0 +1,30 @@
+#!/bin/sh
+# Remove all but the N newest test run dirs (that have been started)
+
+ts_rx_dir="$1"
+ts_prep_dir="$2"
+if [ -z "$ts_rx_dir" ]; then
+ ts_rx_dir="/var/tmp/osmo-gsm-tester"
+fi
+if [ -z "$ts_prep_dir" ]; then
+ ts_prep_dir="/var/tmp/prep-osmo-gsm-tester"
+fi
+
+mkdir -p "$ts_prep_dir"
+
+rm_ts() {
+ ts_dir="$1"
+ ts_name="$(basename "$ts_dir")"
+ echo "Removing: $(ls -ld "$ts_dir")"
+ # ensure atomic removal, so that the gsm-tester doesn't take it as a
+ # newly added dir (can happen when the 'SEEN' marker is removed first).
+ mv "$ts_dir" "$ts_prep_dir/"
+ rm -rf "$ts_prep_dir/$ts_name"
+}
+
+# keep the N newest test session dirs that have been started: find all that
+# have been started sorted by time, then discard all but the N newest ones.
+
+for seen in $(ls -1t "$ts_rx_dir"/*/SEEN | tail -n +31); do
+ rm_ts "$(dirname "$seen")"
+done
diff --git a/doc/README-sysmobts.txt b/doc/README-sysmobts.txt
new file mode 100644
index 0000000..695c685
--- /dev/null
+++ b/doc/README-sysmobts.txt
@@ -0,0 +1,59 @@
+SETTING UP sysmobts
+
+PACKAGE VERSIONS
+
+Depending on the code to be tested, select the stable, testing or nightly opkg
+feed:
+
+To change the feed and packages installed on the sysmobts edit the
+following files in /etc/opkg/
+
+* all-feed.conf
+* armv5te-feed.conf
+* sysmobts-v2-feed.conf
+
+and adjust the URL. For example, to move to the testing feeds:
+
+ sed -i 's/201310/201310-testing/g' /etc/opkg/*.conf
+
+Then run 'opkg update', 'opkg upgrade' and finally 'reboot'.
+
+
+DISABLE SERVICES
+
+To use the sysmobts together with the tester, the following systemd services must be disabled
+but using the mask and not using the disable option. You can use the following lines:
+
+systemctl mask osmo-nitb
+systemctl mask sysmobts
+systemctl mask sysmobts-mgr
+
+
+SSH ACCESS
+
+Copy the SSH public key from the system/user that runs the tester to the BTS
+authorized keys file so the tester will be able to deploy binaries.
+
+It is also advisable to configure the eth0 network interface of the BTS to a
+static IP address instead of using DHCP. To do so adjust /etc/network/interfaces
+and change the line
+
+ iface eth0 inet dhcp
+
+to
+
+ iface eth0 inet static
+ address 10.42.42.114
+ netmask 255.255.255.0
+ gateway 10.42.42.1
+
+Set the name server in /etc/resolve.conf (most likely to the IP of the
+gateway).
+
+
+ALLOW CORE FILES
+
+In case a binary run for the test crashes, we allow it to write a core file, to
+be able to analyze the crash later. This requires a limit rule:
+
+ scp install/osmo-gsm-tester-limits.conf sysmobts:/etc/security/limits.d/
diff --git a/doc/README.txt b/doc/README.txt
new file mode 100644
index 0000000..9d2b91a
--- /dev/null
+++ b/doc/README.txt
@@ -0,0 +1,92 @@
+INSTALLATION
+
+So far the osmo-gsm-tester directory is manually placed in /usr/local/src
+
+
+DEPENDENCIES
+
+Packages required to run the osmo-gsm-tester:
+
+ dbus
+ python3
+ python3-dbus
+ python3-pip
+ python3-mako
+ tcpdump
+ smpplib (pip install git+git://github.com/podshumok/python-smpplib.git)
+ ofono
+
+To build ofono:
+ libglib2.0-dev
+ libdbus-1-dev
+ libudev-dev
+ mobile-broadband-provider-info
+
+
+INSTALLATION
+
+Place a copy of the osmo-gsm-tester repository in /usr/local/src/
+
+ cp install/osmo-gsm-tester-limits.conf /etc/security/limits.d/
+ cp install/*.service /lib/systemd/system/
+ cp install/org.ofono.conf /etc/dbus-1/system.d/
+ systemctl daemon-reload
+
+To run:
+
+ systemctl enable ofono
+ systemctl start ofono
+ systemctl status ofono
+
+ systemctl enable osmo-gsm-tester
+ systemctl start osmo-gsm-tester
+ systemctl status osmo-gsm-tester
+
+
+To stop:
+
+ systemctl stop osmo-gsm-tester
+
+After ofonod has been started and modems have been connected to the system,
+you can run the 'list-modems' script located in /usr/local/src/ofono/test to get
+a list of the modems that have been detected by ofono.
+
+
+CONFIGURATION
+
+Host System configuration
+
+Create the /var/tmp/osmo-gsm-tester directory. It will be used to accept new test jobs.
+
+Test resources (NITB, BTS and modems) are currently configured in the test_manager.py.
+
+For every nitb resource that can be allocated, one alias IP address needs
+to be set up in /etc/network/interfaces on the interface that is connected to the BTSes.
+By add the following lines for each nitb instance that can be allocated (while making
+sure each interface alias and IP is unique)
+
+ auto eth1:0
+ allow-hotplug eth1:0
+ iface eth1:0 inet static
+ address 10.42.42.2
+ netmask 255.255.255.0
+
+Also make sure, the user executing the tester is allowed to run tcpdump. If
+the user is not root, we have used the folloing line to get proper permissions:
+
+ groupadd pcap
+ addgroup <your-user-name> pcap
+ setcap cap_net_raw,cap_net_admin=eip /usr/sbin/tcpdump
+ chgroup pcap /usr/sbin/tcpdump
+ chmod 0750 /usr/sbin/tcpdump
+
+The tester main unit must be able to ssh without password to the sysmobts (and
+possibly other) hardware: place the main unit's public SSH key on the sysmoBTS.
+Log in via SSH at least once to accept the BTS' host key.
+
+
+LAUNCHING A TEST RUN
+
+osmo-gsm-tester watches /var/tmp/osmo-gsm-tester for instructions to launch
+test runs. A test run is triggered by a subdirectory containing binaries and a
+manifest file, typically created by jenkins using the enclosed scripts.
diff --git a/install/ofono.service b/install/ofono.service
new file mode 100644
index 0000000..0aa9fbe
--- /dev/null
+++ b/install/ofono.service
@@ -0,0 +1,11 @@
+# systemd service file for the ofono daemon
+[Unit]
+Description=oFono
+
+[Service]
+ExecStart=/usr/local/src/ofono/src/ofonod -n
+Restart=always
+StartLimitInterval=0
+
+[Install]
+WantedBy=multi-user.target
diff --git a/install/org.ofono.conf b/install/org.ofono.conf
new file mode 100644
index 0000000..8b13c75
--- /dev/null
+++ b/install/org.ofono.conf
@@ -0,0 +1,28 @@
+<!-- This configuration file specifies the required security policies
+ for oFono core daemon to work. It lives in /etc/dbus-1/system.d/ -->
+
+<!DOCTYPE busconfig PUBLIC "-//freedesktop//DTD D-BUS Bus Configuration 1.0//EN"
+ "http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
+<busconfig>
+
+ <!-- ../system.conf have denied everything, so we just punch some holes -->
+
+ <policy user="root">
+ <allow own="org.ofono"/>
+ <allow send_destination="org.ofono"/>
+ <allow send_interface="org.ofono.SimToolkitAgent"/>
+ <allow send_interface="org.ofono.PushNotificationAgent"/>
+ <allow send_interface="org.ofono.SmartMessagingAgent"/>
+ <allow send_interface="org.ofono.PositioningRequestAgent"/>
+ <allow send_interface="org.ofono.HandsfreeAudioAgent"/>
+ </policy>
+
+ <policy at_console="true">
+ <allow send_destination="org.ofono"/>
+ </policy>
+
+ <policy context="default">
+ <deny send_destination="org.ofono"/>
+ </policy>
+
+</busconfig>
diff --git a/install/osmo-gsm-tester-limits.conf b/install/osmo-gsm-tester-limits.conf
new file mode 100644
index 0000000..1fb0738
--- /dev/null
+++ b/install/osmo-gsm-tester-limits.conf
@@ -0,0 +1,4 @@
+# place this file in /etc/security/limits.d to allow core files when a program
+# crashes; for osmo-gsm-tester.
+root - core unlimited
+* - core unlimited
diff --git a/install/osmo-gsm-tester.service b/install/osmo-gsm-tester.service
new file mode 100644
index 0000000..02225d7
--- /dev/null
+++ b/install/osmo-gsm-tester.service
@@ -0,0 +1,11 @@
+# systemd service file for the osmo-gsm-tester daemon
+[Unit]
+Description=Osmocom GSM Tester
+
+[Service]
+ExecStart=/usr/local/src/osmo-gsm-tester/osmo-gsm-tester
+Restart=on-abort
+StartLimitInterval=0
+
+[Install]
+WantedBy=multi-user.target
diff --git a/src/osmo_gsm_tester/__init__.py b/src/osmo_gsm_tester/__init__.py
new file mode 100644
index 0000000..6b6b46e
--- /dev/null
+++ b/src/osmo_gsm_tester/__init__.py
@@ -0,0 +1,29 @@
+# osmo_gsm_tester: automated cellular network hardware tests
+#
+# Copyright (C) 2016-2017 by sysmocom - s.f.m.c. GmbH
+#
+# Authors: D. Lazlo Sitzer <dlsitzer@sysmocom.de>
+# Neels Hofmeyr <neels@hofmeyr.de>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+__version__ = 'UNKNOWN'
+
+try:
+ from ._version import _version
+ __version__ = _version
+except:
+ pass
+
+# vim: expandtab tabstop=4 shiftwidth=4
diff --git a/src/osmo_gsm_tester/config.py b/src/osmo_gsm_tester/config.py
new file mode 100644
index 0000000..18b209e
--- /dev/null
+++ b/src/osmo_gsm_tester/config.py
@@ -0,0 +1,161 @@
+# osmo_gsm_tester: read and validate config files
+#
+# Copyright (C) 2016-2017 by sysmocom - s.f.m.c. GmbH
+#
+# Author: Neels Hofmeyr <neels@hofmeyr.de>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+# discussion for choice of config file format:
+#
+# Python syntax is insane, because it allows the config file to run arbitrary
+# python commands.
+#
+# INI file format is nice and simple, but it doesn't allow having the same
+# section numerous times (e.g. to define several modems or BTS models) and does
+# not support nesting.
+#
+# JSON has too much braces and quotes to be easy to type
+#
+# YAML formatting is lean, but too powerful. The normal load() allows arbitrary
+# code execution. There is safe_load(). But YAML also allows several
+# alternative ways of formatting, better to have just one authoritative style.
+# Also it would be better to receive every setting as simple string rather than
+# e.g. an IMSI as an integer.
+#
+# The Python ConfigParserShootout page has numerous contestants, but it we want
+# to use widely used, standardized parsing code without re-inventing the wheel.
+# https://wiki.python.org/moin/ConfigParserShootout
+#
+# The optimum would be a stripped down YAML format.
+# In the lack of that, we shall go with yaml.load_safe() + a round trip
+# (feeding back to itself), converting keys to lowercase and values to string.
+
+import yaml
+import re
+import os
+
+from . import log
+
+def read(path, schema=None):
+ with log.Origin(path):
+ with open(path, 'r') as f:
+ config = yaml.safe_load(f)
+ config = _standardize(config)
+ if schema:
+ validate(config, schema)
+ return config
+
+def tostr(config):
+ return _tostr(_standardize(config))
+
+def _tostr(config):
+ return yaml.dump(config, default_flow_style=False)
+
+def _standardize_item(item):
+ if isinstance(item, (tuple, list)):
+ return [_standardize_item(i) for i in item]
+ if isinstance(item, dict):
+ return dict([(key.lower(), _standardize_item(val)) for key,val in item.items()])
+ return str(item)
+
+def _standardize(config):
+ config = yaml.safe_load(_tostr(_standardize_item(config)))
+ return config
+
+
+KEY_RE = re.compile('[a-zA-Z][a-zA-Z0-9_]*')
+
+def band(val):
+ if val in ('GSM-1800', 'GSM-1900'):
+ return
+ raise ValueError('Unknown GSM band: %r' % val)
+
+INT = 'int'
+STR = 'str'
+BAND = 'band'
+SCHEMA_TYPES = {
+ INT: int,
+ STR: str,
+ BAND: band,
+ }
+
+def is_dict(l):
+ return isinstance(l, dict)
+
+def is_list(l):
+ return isinstance(l, (list, tuple))
+
+def validate(config, schema):
+ '''Make sure the given config dict adheres to the schema.
+ The schema is a dict of 'dict paths' in dot-notation with permitted
+ value type. All leaf nodes are validated, nesting dicts are implicit.
+
+ validate( { 'a': 123, 'b': { 'b1': 'foo', 'b2': [ 1, 2, 3 ] } },
+ { 'a': int,
+ 'b.b1': str,
+ 'b.b2[]': int } )
+
+ Raise a ValueError in case the schema is violated.
+ '''
+
+ def validate_item(path, value, schema):
+ want_type = schema.get(path)
+
+ if is_list(value):
+ if want_type:
+ raise ValueError('config item is a list, should be %r: %r' % (want_type, path))
+ path = path + '[]'
+ want_type = schema.get(path)
+
+ if not want_type:
+ if is_dict(value):
+ nest(path, value, schema)
+ return
+ if is_list(value) and value:
+ for list_v in value:
+ validate_item(path, list_v, schema)
+ return
+ raise ValueError('config item not known: %r' % path)
+
+ if want_type not in SCHEMA_TYPES:
+ raise ValueError('unknown type %r at %r' % (want_type, path))
+
+ if is_dict(value):
+ raise ValueError('config item is dict but should be a leaf node of type %r: %r'
+ % (want_type, path))
+
+ if is_list(value):
+ for list_v in value:
+ validate_item(path, list_v, schema)
+ return
+
+ with log.Origin(item=path):
+ type_validator = SCHEMA_TYPES.get(want_type)
+ type_validator(value)
+
+ def nest(parent_path, config, schema):
+ if parent_path:
+ parent_path = parent_path + '.'
+ else:
+ parent_path = ''
+ for k,v in config.items():
+ if not KEY_RE.fullmatch(k):
+ raise ValueError('invalid config key: %r' % k)
+ path = parent_path + k
+ validate_item(path, v, schema)
+
+ nest(None, config, schema)
+
+# vim: expandtab tabstop=4 shiftwidth=4
diff --git a/src/osmo_gsm_tester/log.py b/src/osmo_gsm_tester/log.py
new file mode 100644
index 0000000..27194a9
--- /dev/null
+++ b/src/osmo_gsm_tester/log.py
@@ -0,0 +1,405 @@
+# osmo_gsm_tester: global logging
+#
+# Copyright (C) 2016-2017 by sysmocom - s.f.m.c. GmbH
+#
+# Author: Neels Hofmeyr <neels@hofmeyr.de>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import os
+import sys
+import time
+import traceback
+import contextlib
+from inspect import getframeinfo, stack
+
+L_ERR = 30
+L_LOG = 20
+L_DBG = 10
+L_TRACEBACK = 'TRACEBACK'
+
+C_NET = 'net'
+C_RUN = 'run'
+C_TST = 'tst'
+C_CNF = 'cnf'
+C_DEFAULT = '---'
+
+LONG_DATEFMT = '%Y-%m-%d_%H:%M:%S'
+DATEFMT = '%H:%M:%S'
+
+class LogTarget:
+ do_log_time = None
+ do_log_category = None
+ do_log_level = None
+ do_log_origin = None
+ do_log_traceback = None
+ do_log_src = None
+ origin_width = None
+ origin_fmt = None
+
+ # redirected by logging test
+ get_time_str = lambda self: time.strftime(self.log_time_fmt)
+
+ # sink that gets each complete logging line
+ log_sink = sys.stderr.write
+
+ category_levels = None
+
+ def __init__(self):
+ self.category_levels = {}
+ self.style()
+
+ def style(self, time=True, time_fmt=DATEFMT, category=True, level=True, origin=True, origin_width=0, src=True, trace=False):
+ '''
+ set all logging format aspects, to defaults if not passed:
+ time: log timestamps;
+ time_fmt: format of timestamps;
+ category: print the logging category (three letters);
+ level: print the logging level, unless it is L_LOG;
+ origin: print which object(s) the message originated from;
+ origin_width: fill up the origin string with whitespace to this witdh;
+ src: log the source file and line number the log comes from;
+ trace: on exceptions, log the full stack trace;
+ '''
+ self.log_time_fmt = time_fmt
+ self.do_log_time = bool(time)
+ if not self.log_time_fmt:
+ self.do_log_time = False
+ self.do_log_category = bool(category)
+ self.do_log_level = bool(level)
+ self.do_log_origin = bool(origin)
+ self.origin_width = int(origin_width)
+ self.origin_fmt = '{:>%ds}' % self.origin_width
+ self.do_log_src = src
+ self.do_log_traceback = trace
+
+ def style_change(self, time=None, time_fmt=None, category=None, level=None, origin=None, origin_width=None, src=None, trace=None):
+ 'modify only the given aspects of the logging format'
+ self.style(
+ time=(time if time is not None else self.do_log_time),
+ time_fmt=(time_fmt if time_fmt is not None else self.log_time_fmt),
+ category=(category if category is not None else self.do_log_category),
+ level=(level if level is not None else self.do_log_level),
+ origin=(origin if origin is not None else self.do_log_origin),
+ origin_width=(origin_width if origin_width is not None else self.origin_width),
+ src=(src if src is not None else self.do_log_src),
+ trace=(trace if trace is not None else self.do_log_traceback),
+ )
+
+ def set_level(self, category, level):
+ 'set global logging log.L_* level for a given log.C_* category'
+ self.category_levels[category] = level
+
+ def is_enabled(self, category, level):
+ if level == L_TRACEBACK:
+ return self.do_log_traceback
+ is_level = self.category_levels.get(category)
+ if is_level is None:
+ is_level = L_LOG
+ if level < is_level:
+ return False
+ return True
+
+ def log(self, origin, category, level, src, messages, named_items):
+ if category and len(category) != 3:
+ self.log_sink('WARNING: INVALID LOG SUBSYSTEM %r\n' % category)
+ self.log_sink('origin=%r category=%r level=%r\n' % (origin, category, level));
+
+ if not category:
+ category = C_DEFAULT
+ if not self.is_enabled(category, level):
+ return
+
+ log_pre = []
+ if self.do_log_time:
+ log_pre.append(self.get_time_str())
+
+ if self.do_log_category:
+ log_pre.append(category)
+
+ if self.do_log_origin:
+ if origin is None:
+ name = '-'
+ elif isinstance(origin, str):
+ name = origin or None
+ elif hasattr(origin, '_name'):
+ name = origin._name
+ if not name:
+ name = str(origin.__class__.__name__)
+ log_pre.append(self.origin_fmt.format(name))
+
+ if self.do_log_level and level != L_LOG:
+ log_pre.append(level_str(level) or ('loglevel=' + str(level)) )
+
+ log_line = [str(m) for m in messages]
+
+ if named_items:
+ # unfortunately needs to be sorted to get deterministic results
+ log_line.append('{%s}' %
+ (', '.join(['%s=%r' % (k,v)
+ for k,v in sorted(named_items.items())])))
+
+ if self.do_log_src and src:
+ log_line.append(' [%s]' % str(src))
+
+ log_str = '%s%s%s' % (' '.join(log_pre),
+ ': ' if log_pre else '',
+ ' '.join(log_line))
+
+ self.log_sink(log_str.strip() + '\n')
+
+
+targets = [ LogTarget() ]
+
+def level_str(level):
+ if level == L_TRACEBACK:
+ return L_TRACEBACK
+ if level <= L_DBG:
+ return 'DBG'
+ if level <= L_LOG:
+ return 'LOG'
+ return 'ERR'
+
+def _log_all_targets(origin, category, level, src, messages, named_items=None):
+ global targets
+ if isinstance(src, int):
+ src = get_src_from_caller(src + 1)
+ for target in targets:
+ target.log(origin, category, level, src, messages, named_items)
+
+def get_src_from_caller(levels_up=1):
+ caller = getframeinfo(stack()[levels_up][0])
+ return '%s:%d' % (os.path.basename(caller.filename), caller.lineno)
+
+def get_src_from_tb(tb, levels_up=1):
+ ftb = traceback.extract_tb(tb)
+ f,l,m,c = ftb[-levels_up]
+ f = os.path.basename(f)
+ return '%s:%s: %s' % (f, l, c)
+
+
+class Origin:
+ '''
+ Base class for all classes that want to log,
+ and to add an origin string to a code path:
+ with log.Origin('my name'):
+ raise Problem()
+ This will log 'my name' as an origin for the Problem.
+ '''
+
+ _log_category = None
+ _src = None
+ _name = None
+ _log_line_buf = None
+ _prev_stdout = None
+
+ _global_current_origin = None
+ _parent_origin = None
+
+ def __init__(self, *name_items, category=None, **detail_items):
+ self.set_log_category(category)
+ self.set_name(*name_items, **detail_items)
+
+ def set_name(self, *name_items, **detail_items):
+ if name_items:
+ name = '-'.join([str(i) for i in name_items])
+ elif not detail_items:
+ name = self.__class__.__name__
+ else:
+ name = ''
+ if detail_items:
+ details = '(%s)' % (', '.join([("%s=%r" % (k,v))
+ for k,v in sorted(detail_items.items())]))
+ else:
+ details = ''
+ self._name = name + details
+
+ def name(self):
+ return self._name
+
+ def set_log_category(self, category):
+ self._log_category = category
+
+ def _log(self, level, messages, named_items=None, src_levels_up=3, origins=None):
+ src = self._src or src_levels_up
+ origin = origins or self.gather_origins()
+ _log_all_targets(origin, self._log_category, level, src, messages, named_items)
+
+ def dbg(self, *messages, **named_items):
+ self._log(L_DBG, messages, named_items)
+
+ def log(self, *messages, **named_items):
+ self._log(L_LOG, messages, named_items)
+
+ def err(self, *messages, **named_items):
+ self._log(L_ERR, messages, named_items)
+
+ def log_exn(self, exc_info=None):
+ log_exn(self, self._log_category, exc_info)
+
+ def __enter__(self):
+ if self._parent_origin is not None:
+ return
+ if Origin._global_current_origin == self:
+ return
+ self._parent_origin, Origin._global_current_origin = Origin._global_current_origin, self
+
+ def __exit__(self, *exc_info):
+ rc = None
+ if exc_info[0] is not None:
+ rc = exn_add_info(exc_info, self)
+ Origin._global_current_origin, self._parent_origin = self._parent_origin, None
+ return rc
+
+ def redirect_stdout(self):
+ return contextlib.redirect_stdout(self)
+
+ def write(self, message):
+ 'to redirect stdout to the log'
+ lines = message.splitlines()
+ if not lines:
+ return
+ if self._log_line_buf:
+ lines[0] = self._log_line_buf + lines[0]
+ self._log_line_buf = None
+ if not message.endswith('\n'):
+ self._log_line_buf = lines[-1]
+ lines = lines[:-1]
+ origins = self.gather_origins()
+ for line in lines:
+ self._log(L_LOG, (line,), origins=origins)
+
+ def flush(self):
+ pass
+
+ def gather_origins(self):
+ origins = Origins()
+ origin = self
+ while origin:
+ origins.add(origin)
+ origin = origin._parent_origin
+ return str(origins)
+
+
+
+def dbg(origin, category, *messages, **named_items):
+ _log_all_targets(origin, category, L_DBG, 2, messages, named_items)
+
+def log(origin, category, *messages, **named_items):
+ _log_all_targets(origin, category, L_LOG, 2, messages, named_items)
+
+def err(origin, category, *messages, **named_items):
+ _log_all_targets(origin, category, L_ERR, 2, messages, named_items)
+
+def trace(origin, category, exc_info):
+ _log_all_targets(origin, category, L_TRACEBACK, None,
+ traceback.format_exception(*exc_info))
+
+def resolve_category(origin, category):
+ if category is not None:
+ return category
+ if not hasattr(origin, '_log_category'):
+ return None
+ return origin._log_category
+
+def exn_add_info(exc_info, origin, category=None):
+ etype, exception, tb = exc_info
+ if not hasattr(exception, 'origins'):
+ exception.origins = Origins()
+ if not hasattr(exception, 'category'):
+ # only remember the deepest category
+ exception.category = resolve_category(origin, category)
+ if not hasattr(exception, 'src'):
+ exception.src = get_src_from_tb(tb)
+ exception.origins.add(origin)
+ return False
+
+
+
+def log_exn(origin=None, category=None, exc_info=None):
+ if not (exc_info is not None and len(exc_info) == 3):
+ exc_info = sys.exc_info()
+ if not (exc_info is not None and len(exc_info) == 3):
+ raise RuntimeError('invalid call to log_exn() -- no valid exception info')
+
+ etype, exception, tb = exc_info
+
+ # if there are origins recorded with the Exception, prefer that
+ if hasattr(exception, 'origins'):
+ origin = str(exception.origins)
+
+ # if there is a category recorded with the Exception, prefer that
+ if hasattr(exception, 'category'):
+ category = exception.category
+
+ if hasattr(exception, 'msg'):
+ msg = exception.msg
+ else:
+ msg = str(exception)
+
+ if hasattr(exception, 'src'):
+ src = exception.src
+ else:
+ src = 2
+
+ trace(origin, category, exc_info)
+ _log_all_targets(origin, category, L_ERR, src,
+ ('%s:' % str(etype.__name__), msg))
+
+
+class Origins(list):
+ def __init__(self, origin=None):
+ if origin is not None:
+ self.add(origin)
+ def add(self, origin):
+ if hasattr(origin, '_name'):
+ origin_str = origin._name
+ else:
+ origin_str = str(origin)
+ self.insert(0, origin_str)
+ def __str__(self):
+ return '->'.join(self)
+
+
+
+def set_level(category, level):
+ global targets
+ for target in targets:
+ target.set_level(category, level)
+
+def style(**kwargs):
+ global targets
+ for target in targets:
+ target.style(**kwargs)
+
+def style_change(**kwargs):
+ global targets
+ for target in targets:
+ target.style_change(**kwargs)
+
+class TestsTarget(LogTarget):
+ 'LogTarget producing deterministic results for regression tests'
+ def __init__(self, out=sys.stdout):
+ super().__init__()
+ self.style(time=False, src=False)
+ self.log_sink = out.write
+
+def run_logging_exceptions(func, *func_args, return_on_failure=None, **func_kwargs):
+ try:
+ return func(*func_args, **func_kwargs)
+ except:
+ log_exn()
+ return return_on_failure
+
+# vim: expandtab tabstop=4 shiftwidth=4
diff --git a/src/osmo_gsm_tester/process.py b/src/osmo_gsm_tester/process.py
new file mode 100644
index 0000000..2e0ff52
--- /dev/null
+++ b/src/osmo_gsm_tester/process.py
@@ -0,0 +1,23 @@
+# osmo_gsm_tester: process management
+#
+# Copyright (C) 2016-2017 by sysmocom - s.f.m.c. GmbH
+#
+# Author: Neels Hofmeyr <neels@hofmeyr.de>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+
+
+# vim: expandtab tabstop=4 shiftwidth=4
diff --git a/src/osmo_gsm_tester/resource.py b/src/osmo_gsm_tester/resource.py
new file mode 100644
index 0000000..bebc82d
--- /dev/null
+++ b/src/osmo_gsm_tester/resource.py
@@ -0,0 +1,51 @@
+# osmo_gsm_tester: manage resources
+#
+# Copyright (C) 2016-2017 by sysmocom - s.f.m.c. GmbH
+#
+# Author: Neels Hofmeyr <neels@hofmeyr.de>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import os
+
+from . import log
+from . import config
+from .utils import listdict, FileLock
+
+class Resources(log.Origin):
+
+ def __init__(self, config_path, lock_dir):
+ self.config_path = config_path
+ self.lock_dir = lock_dir
+ self.set_name(conf=self.config_path, lock=self.lock_dir)
+
+ def ensure_lock_dir_exists(self):
+ if not os.path.isdir(self.lock_dir):
+ os.makedirs(self.lock_dir)
+
+
+global_resources = listdict()
+
+def register(kind, instance):
+ global global_resources
+ global_resources.add(kind, instance)
+
+def reserve(user, config):
+ asdf
+
+def read_conf(path):
+ with open(path, 'r') as f:
+ conf = f.read()
+
+# vim: expandtab tabstop=4 shiftwidth=4
diff --git a/src/osmo_gsm_tester/suite.py b/src/osmo_gsm_tester/suite.py
new file mode 100644
index 0000000..fb7c34d
--- /dev/null
+++ b/src/osmo_gsm_tester/suite.py
@@ -0,0 +1,150 @@
+# osmo_gsm_tester: test suite
+#
+# Copyright (C) 2016-2017 by sysmocom - s.f.m.c. GmbH
+#
+# Author: Neels Hofmeyr <neels@hofmeyr.de>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import os
+from . import config, log, template, utils
+
+class Suite(log.Origin):
+ '''A test suite reserves resources for a number of tests.
+ Each test requires a specific number of modems, BTSs etc., which are
+ reserved beforehand by a test suite. This way several test suites can be
+ scheduled dynamically without resource conflicts arising halfway through
+ the tests.'''
+
+ CONF_FILENAME = 'suite.conf'
+
+ CONF_SCHEMA = {
+ 'resources.nitb_iface': config.INT,
+ 'resources.nitb': config.INT,
+ 'resources.bts': config.INT,
+ 'resources.msisdn': config.INT,
+ 'resources.modem': config.INT,
+ 'defaults.timeout': config.STR,
+ }
+
+ class Results:
+ def __init__(self):
+ self.passed = []
+ self.failed = []
+ self.all_passed = None
+
+ def add_pass(self, test):
+ self.passed.append(test)
+
+ def add_fail(self, test):
+ self.failed.append(test)
+
+ def conclude(self):
+ self.all_passed = bool(self.passed) and not bool(self.failed)
+ return self
+
+ def __init__(self, suite_dir):
+ self.set_log_category(log.C_CNF)
+ self.suite_dir = suite_dir
+ self.set_name(os.path.basename(self.suite_dir))
+ self.read_conf()
+
+ def read_conf(self):
+ with self:
+ if not os.path.isdir(self.suite_dir):
+ raise RuntimeError('No such directory: %r' % self.suite_dir)
+ self.conf = config.read(os.path.join(self.suite_dir,
+ Suite.CONF_FILENAME),
+ Suite.CONF_SCHEMA)
+ self.load_tests()
+
+ def load_tests(self):
+ with self:
+ self.tests = []
+ for basename in os.listdir(self.suite_dir):
+ if not basename.endswith('.py'):
+ continue
+ self.tests.append(Test(self, basename))
+
+ def add_test(self, test):
+ with self:
+ if not isinstance(test, Test):
+ raise ValueError('add_test(): pass a Test() instance, not %s' % type(test))
+ if test.suite is None:
+ test.suite = self
+ if test.suite is not self:
+ raise ValueError('add_test(): test already belongs to another suite')
+ self.tests.append(test)
+
+ def run_tests(self):
+ results = Suite.Results()
+ for test in self.tests:
+ self._run_test(test, results)
+ return results.conclude()
+
+ def run_tests_by_name(self, *names):
+ results = Suite.Results()
+ for name in names:
+ basename = name
+ if not basename.endswith('.py'):
+ basename = name + '.py'
+ for test in self.tests:
+ if basename == test.basename:
+ self._run_test(test, results)
+ break
+ return results.conclude()
+
+ def _run_test(self, test, results):
+ try:
+ with self:
+ test.run()
+ results.add_pass(test)
+ except:
+ results.add_fail(test)
+ self.log_exn()
+
+class Test(log.Origin):
+
+ def __init__(self, suite, test_basename):
+ self.suite = suite
+ self.basename = test_basename
+ self.set_name(self.basename)
+ self.set_log_category(log.C_TST)
+ self.path = os.path.join(self.suite.suite_dir, self.basename)
+ with self:
+ with open(self.path, 'r') as f:
+ self.script = f.read()
+
+ def run(self):
+ with self:
+ self.code = compile(self.script, self.path, 'exec')
+ with self.redirect_stdout():
+ exec(self.code, self.test_globals())
+ self._success = True
+
+ def test_globals(self):
+ test_globals = {
+ 'this': utils.dict2obj({
+ 'suite': self.suite.suite_dir,
+ 'test': self.basename,
+ }),
+ 'resources': utils.dict2obj({
+ }),
+ }
+ return test_globals
+
+def load(suite_dir):
+ return Suite(suite_dir)
+
+# vim: expandtab tabstop=4 shiftwidth=4
diff --git a/src/osmo_gsm_tester/template.py b/src/osmo_gsm_tester/template.py
new file mode 100644
index 0000000..434ab62
--- /dev/null
+++ b/src/osmo_gsm_tester/template.py
@@ -0,0 +1,56 @@
+# osmo_gsm_tester: automated cellular network hardware tests
+# Proxy to templating engine to handle files
+#
+# Copyright (C) 2016-2017 by sysmocom - s.f.m.c. GmbH
+#
+# Author: Neels Hofmeyr <neels@hofmeyr.de>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import os, sys
+from mako.template import Template
+from mako.lookup import TemplateLookup
+
+from . import log
+from .utils import dict2obj
+
+_lookup = None
+_logger = log.Origin('no templates dir set')
+
+def set_templates_dir(*templates_dirs):
+ global _lookup
+ global _logger
+ if not templates_dirs:
+ # default templates dir is relative to this source file
+ templates_dirs = [os.path.join(os.path.dirname(__file__), 'templates')]
+ for d in templates_dirs:
+ if not os.path.isdir(d):
+ raise RuntimeError('templates dir is not a dir: %r'
+ % os.path.abspath(d))
+ _lookup = TemplateLookup(directories=templates_dirs)
+ _logger = log.Origin('Templates', category=log.C_CNF)
+
+def render(name, values):
+ '''feed values dict into template and return rendered result.
+ ".tmpl" is added to the name to look it up in the templates dir.'''
+ global _lookup
+ if _lookup is None:
+ set_templates_dir()
+ with _logger:
+ tmpl_name = name + '.tmpl'
+ template = _lookup.get_template(tmpl_name)
+ _logger.dbg('rendering', tmpl_name)
+ return template.render(**dict2obj(values))
+
+# vim: expandtab tabstop=4 shiftwidth=4
diff --git a/src/osmo_gsm_tester/templates/osmo-bts.cfg.tmpl b/src/osmo_gsm_tester/templates/osmo-bts.cfg.tmpl
new file mode 100644
index 0000000..20fa57f
--- /dev/null
+++ b/src/osmo_gsm_tester/templates/osmo-bts.cfg.tmpl
@@ -0,0 +1,21 @@
+!
+! OsmoBTS () configuration saved from vty
+!!
+!
+log stderr
+ logging color 1
+ logging timestamp 1
+ logging print extended-timestamp 1
+ logging print category 1
+ logging level all debug
+ logging level l1c info
+ logging level linp info
+!
+phy 0
+ instance 0
+bts 0
+ band {band}
+ ipa unit-id {ipa_unit_id} 0
+ oml remote-ip {oml_remote_ip}
+ trx 0
+ phy 0 instance 0
diff --git a/src/osmo_gsm_tester/templates/osmo-nitb.cfg.tmpl b/src/osmo_gsm_tester/templates/osmo-nitb.cfg.tmpl
new file mode 100644
index 0000000..3404b7f
--- /dev/null
+++ b/src/osmo_gsm_tester/templates/osmo-nitb.cfg.tmpl
@@ -0,0 +1,87 @@
+!
+! OpenBSC configuration saved from vty
+!
+password foo
+!
+log stderr
+ logging filter all 1
+ logging color 0
+ logging print category 0
+ logging print extended-timestamp 1
+ logging level all debug
+!
+line vty
+ no login
+ bind ${vty_bind_ip}
+!
+e1_input
+ e1_line 0 driver ipa
+ ipa bind ${abis_bind_ip}
+network
+ network country code ${mcc}
+ mobile network code ${mnc}
+ short name ${net_name_short}
+ long name ${net_name_long}
+ auth policy ${net_auth_policy}
+ location updating reject cause 13
+ encryption a5 ${encryption}
+ neci 1
+ rrlp mode none
+ mm info 1
+ handover 0
+ handover window rxlev averaging 10
+ handover window rxqual averaging 1
+ handover window rxlev neighbor averaging 10
+ handover power budget interval 6
+ handover power budget hysteresis 3
+ handover maximum distance 9999
+ timer t3101 10
+ timer t3103 0
+ timer t3105 0
+ timer t3107 0
+ timer t3109 4
+ timer t3111 0
+ timer t3113 60
+ timer t3115 0
+ timer t3117 0
+ timer t3119 0
+ timer t3141 0
+smpp
+ local-tcp-ip ${smpp_bind_ip} 2775
+ system-id test
+ policy closed
+ esme test
+ password test
+ default-route
+ctrl
+ bind ${ctrl_bind_ip}
+%for bts in bts_list:
+ bts ${loop.index}
+ type ${bts.type}
+ band ${bts.band}
+ cell_identity 0
+ location_area_code ${bts.location_area_code}
+ training_sequence_code 7
+ base_station_id_code ${bts.base_station_id_code}
+ ms max power 15
+ cell reselection hysteresis 4
+ rxlev access min 0
+ channel allocator ascending
+ rach tx integer 9
+ rach max transmission 7
+ ip.access unit_id ${bts.unit_id} 0
+ oml ip.access stream_id ${bts.stream_id} line 0
+ gprs mode none
+% for trx in bts.trx_list:
+ trx ${loop.index}
+ rf_locked 0
+ arfcn ${trx.arfcn}
+ nominal power 23
+ max_power_red ${trx.max_power_red}
+ rsl e1 tei 0
+% for ts in trx.timeslot_list:
+ timeslot ${loop.index}
+ phys_chan_config ${ts.phys_chan_config}
+% endfor
+% endfor
+%endfor
diff --git a/src/osmo_gsm_tester/templates/osmo-pcu.cfg.tmpl b/src/osmo_gsm_tester/templates/osmo-pcu.cfg.tmpl
new file mode 100644
index 0000000..b88e6e7
--- /dev/null
+++ b/src/osmo_gsm_tester/templates/osmo-pcu.cfg.tmpl
@@ -0,0 +1,6 @@
+pcu
+ flow-control-interval 10
+ cs 2
+ alloc-algorithm dynamic
+ alpha 0
+ gamma 0
diff --git a/src/osmo_gsm_tester/templates/osmo-sgsn.cfg.tmpl b/src/osmo_gsm_tester/templates/osmo-sgsn.cfg.tmpl
new file mode 100644
index 0000000..4955983
--- /dev/null
+++ b/src/osmo_gsm_tester/templates/osmo-sgsn.cfg.tmpl
@@ -0,0 +1,26 @@
+!
+! Osmocom SGSN configuration
+!
+!
+line vty
+ no login
+!
+sgsn
+ gtp local-ip 127.0.0.1
+ ggsn 0 remote-ip 127.0.0.1
+ ggsn 0 gtp-version 1
+!
+ns
+ timer tns-block 3
+ timer tns-block-retries 3
+ timer tns-reset 3
+ timer tns-reset-retries 3
+ timer tns-test 30
+ timer tns-alive 3
+ timer tns-alive-retries 10
+ encapsulation udp local-ip 127.0.0.1
+ encapsulation udp local-port 23000
+ encapsulation framerelay-gre enabled 0
+!
+bssgp
+!
diff --git a/src/osmo_gsm_tester/templates/sysmobts-mgr.cfg.tmpl b/src/osmo_gsm_tester/templates/sysmobts-mgr.cfg.tmpl
new file mode 100644
index 0000000..3b28d78
--- /dev/null
+++ b/src/osmo_gsm_tester/templates/sysmobts-mgr.cfg.tmpl
@@ -0,0 +1,24 @@
+!
+! SysmoMgr (0.3.0.141-33e5) configuration saved from vty
+!!
+!
+log stderr
+ logging filter all 1
+ logging color 1
+ logging timestamp 0
+ logging level all everything
+ logging level temp info
+ logging level fw info
+ logging level find info
+ logging level lglobal notice
+ logging level llapd notice
+ logging level linp notice
+ logging level lmux notice
+ logging level lmi notice
+ logging level lmib notice
+ logging level lsms notice
+!
+line vty
+ no login
+!
+sysmobts-mgr
diff --git a/src/osmo_gsm_tester/test.py b/src/osmo_gsm_tester/test.py
new file mode 100644
index 0000000..fd5a640
--- /dev/null
+++ b/src/osmo_gsm_tester/test.py
@@ -0,0 +1,43 @@
+# osmo_gsm_tester: prepare a test run and provide test API
+#
+# Copyright (C) 2016-2017 by sysmocom - s.f.m.c. GmbH
+#
+# Author: Neels Hofmeyr <neels@hofmeyr.de>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import sys, os
+import pprint
+import inspect
+
+from . import suite as _suite
+from . import log
+from . import resource
+
+# load the configuration for the test
+suite = _suite.Suite(sys.path[0])
+test = _suite.Test(suite, os.path.basename(inspect.stack()[-1][1]))
+
+def test_except_hook(*exc_info):
+ log.exn_add_info(exc_info, test)
+ log.exn_add_info(exc_info, suite)
+ log.log_exn(exc_info=exc_info)
+
+sys.excepthook = test_except_hook
+
+orig_stdout, sys.stdout = sys.stdout, test
+
+resources = {}
+
+# vim: expandtab tabstop=4 shiftwidth=4
diff --git a/src/osmo_gsm_tester/utils.py b/src/osmo_gsm_tester/utils.py
new file mode 100644
index 0000000..9992d44
--- /dev/null
+++ b/src/osmo_gsm_tester/utils.py
@@ -0,0 +1,118 @@
+# osmo_gsm_tester: language snippets
+#
+# Copyright (C) 2016-2017 by sysmocom - s.f.m.c. GmbH
+#
+# Author: Neels Hofmeyr <neels@hofmeyr.de>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import os
+import fcntl
+
+class listdict:
+ 'a dict of lists { "a": [1, 2, 3], "b": [1, 2] }'
+ def __getattr__(ld, name):
+ if name == 'add':
+ return ld.__getattribute__(name)
+ return ld.__dict__.__getattribute__(name)
+
+ def add(ld, name, item):
+ l = ld.__dict__.get(name)
+ if not l:
+ l = []
+ ld.__dict__[name] = l
+ l.append(item)
+ return l
+
+ def add_dict(ld, d):
+ for k,v in d.items():
+ ld.add(k, v)
+
+ def __setitem__(ld, name, val):
+ return ld.__dict__.__setitem__(name, val)
+
+ def __getitem__(ld, name):
+ return ld.__dict__.__getitem__(name)
+
+ def __str__(ld):
+ return ld.__dict__.__str__()
+
+
+class DictProxy:
+ '''
+ allow accessing dict entries like object members
+ syntactical sugar, adapted from http://stackoverflow.com/a/31569634
+ so that e.g. templates can do ${bts.member} instead of ${bts['member']}
+ '''
+ def __init__(self, obj):
+ self.obj = obj
+
+ def __getitem__(self, key):
+ return dict2obj(self.obj[key])
+
+ def __getattr__(self, key):
+ try:
+ return dict2obj(getattr(self.obj, key))
+ except AttributeError:
+ try:
+ return self[key]
+ except KeyError:
+ raise AttributeError(key)
+
+class ListProxy:
+ 'allow nesting for DictProxy'
+ def __init__(self, obj):
+ self.obj = obj
+
+ def __getitem__(self, key):
+ return dict2obj(self.obj[key])
+
+def dict2obj(value):
+ if isinstance(value, dict):
+ return DictProxy(value)
+ if isinstance(value, (tuple, list)):
+ return ListProxy(value)
+ return value
+
+
+class FileLock:
+ def __init__(self, path, owner):
+ self.path = path
+ self.owner = owner
+ self.f = None
+
+ def __enter__(self):
+ if self.f is not None:
+ return
+ self.fd = os.open(self.path, os.O_CREAT | os.O_WRONLY | os.O_TRUNC)
+ fcntl.flock(self.fd, fcntl.LOCK_EX)
+ os.truncate(self.fd, 0)
+ os.write(self.fd, str(self.owner).encode('utf-8'))
+ os.fsync(self.fd)
+
+ def __exit__(self, *exc_info):
+ #fcntl.flock(self.fd, fcntl.LOCK_UN)
+ os.truncate(self.fd, 0)
+ os.fsync(self.fd)
+ os.close(self.fd)
+ self.fd = -1
+
+ def lock(self):
+ self.__enter__()
+
+ def unlock(self):
+ self.__exit__()
+
+
+# vim: expandtab tabstop=4 shiftwidth=4
diff --git a/src/run_once.py b/src/run_once.py
new file mode 100755
index 0000000..ff15204
--- /dev/null
+++ b/src/run_once.py
@@ -0,0 +1,48 @@
+#!/usr/bin/env python3
+
+# osmo_gsm_tester: invoke a single test run
+#
+# Copyright (C) 2016-2017 by sysmocom - s.f.m.c. GmbH
+#
+# Author: Neels Hofmeyr <neels@hofmeyr.de>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+'''osmo_gsm_tester: invoke a single test run.
+
+./run_once.py ~/path/to/test_package/
+
+Upon launch, a 'test_package/run-<date>' directory will be created.
+When complete, a symbolic link 'test_package/last_run' will point at this dir.
+The run dir then contains logs and test results.
+'''
+
+import osmo_gsm_tester
+
+if __name__ == '__main__':
+ import argparse
+
+ parser = argparse.ArgumentParser()
+ parser.add_argument('-V', '--version', action='store_true',
+ help='Show version')
+ parser.add_argument('test_package', nargs='*',
+ help='Directory containing binaries to test')
+ args = parser.parse_args()
+
+ if args.version:
+ print(osmo_gsm_tester.__version__)
+ exit(0)
+
+
+# vim: expandtab tabstop=4 shiftwidth=4
diff --git a/test/Makefile b/test/Makefile
new file mode 100644
index 0000000..692c971
--- /dev/null
+++ b/test/Makefile
@@ -0,0 +1,9 @@
+.PHONY: check update
+
+check:
+ ./all_tests.py
+
+update:
+ ./all_tests.py -u
+
+# vim: noexpandtab tabstop=8 shiftwidth=8
diff --git a/test/_prep.py b/test/_prep.py
new file mode 100644
index 0000000..bfbe7b8
--- /dev/null
+++ b/test/_prep.py
@@ -0,0 +1,16 @@
+import sys, os
+
+script_dir = sys.path[0]
+top_dir = os.path.join(script_dir, '..')
+src_dir = os.path.join(top_dir, 'src')
+
+# to find the osmo_gsm_tester py module
+sys.path.append(src_dir)
+
+from osmo_gsm_tester import log
+
+log.targets = [ log.TestsTarget() ]
+
+if '-v' in sys.argv:
+ log.style_change(trace=True)
+
diff --git a/test/all_tests.py b/test/all_tests.py
new file mode 100755
index 0000000..f09fc0e
--- /dev/null
+++ b/test/all_tests.py
@@ -0,0 +1,111 @@
+#!/usr/bin/env python3
+
+import os
+import sys
+import subprocess
+import time
+import difflib
+import argparse
+
+parser = argparse.ArgumentParser()
+parser.add_argument('testdir_or_test', nargs='*',
+ help='subdir name or test script name')
+parser.add_argument('-u', '--update', action='store_true',
+ help='Update test expecations instead of verifying them')
+args = parser.parse_args()
+
+def run_test(path):
+ print(path)
+ p = subprocess.Popen(path, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ o,e = p.communicate()
+ while True:
+ retval = p.poll()
+ if retval is not None:
+ break;
+ p.kill()
+ time.sleep(.1)
+ return retval, o.decode('utf-8'), e.decode('utf-8')
+
+def udiff(expect, got, expect_path):
+ expect = expect.splitlines(1)
+ got = got.splitlines(1)
+ for line in difflib.unified_diff(expect, got,
+ fromfile=expect_path, tofile='got'):
+ sys.stderr.write(line)
+ if not line.endswith('\n'):
+ sys.stderr.write('[no-newline]\n')
+
+def verify_output(got, expect_file, update=False):
+ if os.path.isfile(expect_file):
+ if update:
+ with open(expect_file, 'w') as f:
+ f.write(got)
+ return True
+
+ with open(expect_file, 'r') as f:
+ expect = f.read()
+ if expect != got:
+ udiff(expect, got, expect_file)
+ sys.stderr.write('output mismatch: %r\n'
+ % os.path.basename(expect_file))
+ return False
+ return True
+
+
+script_dir = sys.path[0]
+
+tests = []
+for f in os.listdir(script_dir):
+ file_path = os.path.join(script_dir, f)
+ if not os.path.isfile(file_path):
+ continue
+
+ if not (file_path.endswith('_test.py') or file_path.endswith('_test.sh')):
+ continue
+ tests.append(file_path)
+
+ran = []
+errors = []
+
+for test in sorted(tests):
+
+ if args.testdir_or_test:
+ if not any([t in test for t in args.testdir_or_test]):
+ continue
+
+ ran.append(test)
+
+ success = True
+
+ name, ext = os.path.splitext(test)
+ ok_file = name + '.ok'
+ err_file = name + '.err'
+
+ rc, out, err = run_test(test)
+
+ if rc != 0:
+ sys.stderr.write('%r: returned %d\n' % (os.path.basename(test), rc))
+ success = False
+
+ if not verify_output(out, ok_file, args.update):
+ success = False
+ if not verify_output(err, err_file, args.update):
+ success = False
+
+ if not success:
+ sys.stderr.write('--- stdout ---\n')
+ sys.stderr.write(out)
+ sys.stderr.write('--- stderr ---\n')
+ sys.stderr.write(err)
+ sys.stderr.write('---\n')
+ sys.stderr.write('Test failed: %r\n\n' % os.path.basename(test))
+ errors.append(test)
+
+if errors:
+ print('%d of %d TESTS FAILED:\n %s' % (len(errors), len(ran), '\n '.join(errors)))
+ exit(1)
+
+print('%d tests ok' % len(ran))
+exit(0)
+
+# vim: expandtab tabstop=4 shiftwidth=4
diff --git a/test/config_test.err b/test/config_test.err
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/test/config_test.err
diff --git a/test/config_test.ok b/test/config_test.ok
new file mode 100644
index 0000000..dc88ae2
--- /dev/null
+++ b/test/config_test.ok
@@ -0,0 +1,46 @@
+{'bts': [{'addr': '10.42.42.114',
+ 'name': 'sysmoBTS 1002',
+ 'trx': [{'band': 'GSM-1800',
+ 'timeslots': ['CCCH+SDCCH4',
+ 'SDCCH8',
+ 'TCH/F_TCH/H_PDCH',
+ 'TCH/F_TCH/H_PDCH',
+ 'TCH/F_TCH/H_PDCH',
+ 'TCH/F_TCH/H_PDCH',
+ 'TCH/F_TCH/H_PDCH',
+ 'TCH/F_TCH/H_PDCH']},
+ {'band': 'GSM-1900',
+ 'timeslots': ['SDCCH8',
+ 'PDCH',
+ 'PDCH',
+ 'PDCH',
+ 'PDCH',
+ 'PDCH',
+ 'PDCH',
+ 'PDCH']}],
+ 'type': 'sysmobts'}],
+ 'modems': [{'dbus_path': '/sierra_0',
+ 'imsi': '901700000009001',
+ 'ki': 'D620F48487B1B782DA55DF6717F08FF9',
+ 'msisdn': '7801'},
+ {'dbus_path': '/sierra_1',
+ 'imsi': '901700000009002',
+ 'ki': 'D620F48487B1B782DA55DF6717F08FF9',
+ 'msisdn': '7802'}]}
+- expect validation success:
+Validation: OK
+- unknown item:
+--- - ERR: ValueError: config item not known: 'bts[].unknown_item'
+Validation: Error
+- wrong type modems[].imsi:
+--- - ERR: ValueError: config item is dict but should be a leaf node of type 'str': 'modems[].imsi'
+Validation: Error
+- invalid key with space:
+--- - ERR: ValueError: invalid config key: 'imsi '
+Validation: Error
+- list instead of dict:
+--- - ERR: ValueError: config item not known: 'a_dict[]'
+Validation: Error
+- unknown band:
+--- (item='bts[].trx[].band') ERR: ValueError: Unknown GSM band: 'what'
+Validation: Error
diff --git a/test/config_test.py b/test/config_test.py
new file mode 100755
index 0000000..de4ffb9
--- /dev/null
+++ b/test/config_test.py
@@ -0,0 +1,70 @@
+#!/usr/bin/env python3
+
+import _prep
+
+import sys
+import os
+import io
+import pprint
+import copy
+
+from osmo_gsm_tester import config, log
+
+example_config_file = 'test.cfg'
+example_config = os.path.join(_prep.script_dir, 'config_test', example_config_file)
+cfg = config.read(example_config)
+
+pprint.pprint(cfg)
+
+test_schema = {
+ 'modems[].dbus_path': config.STR,
+ 'modems[].msisdn': config.STR,
+ 'modems[].imsi': config.STR,
+ 'modems[].ki': config.STR,
+ 'bts[].name' : config.STR,
+ 'bts[].type' : config.STR,
+ 'bts[].addr' : config.STR,
+ 'bts[].trx[].timeslots[]' : config.STR,
+ 'bts[].trx[].band' : config.BAND,
+ 'a_dict.foo' : config.INT,
+ }
+
+def val(which):
+ try:
+ config.validate(which, test_schema)
+ print('Validation: OK')
+ except ValueError:
+ log.log_exn()
+ print('Validation: Error')
+
+print('- expect validation success:')
+val(cfg)
+
+print('- unknown item:')
+c = copy.deepcopy(cfg)
+c['bts'][0]['unknown_item'] = 'no'
+val(c)
+
+print('- wrong type modems[].imsi:')
+c = copy.deepcopy(cfg)
+c['modems'][0]['imsi'] = {'no':'no'}
+val(c)
+
+print('- invalid key with space:')
+c = copy.deepcopy(cfg)
+c['modems'][0]['imsi '] = '12345'
+val(c)
+
+print('- list instead of dict:')
+c = copy.deepcopy(cfg)
+c['a_dict'] = [ 1, 2, 3 ]
+val(c)
+
+print('- unknown band:')
+c = copy.deepcopy(cfg)
+c['bts'][0]['trx'][0]['band'] = 'what'
+val(c)
+
+exit(0)
+
+# vim: expandtab tabstop=4 shiftwidth=4
diff --git a/test/config_test/test.cfg b/test/config_test/test.cfg
new file mode 100644
index 0000000..c6d61bf
--- /dev/null
+++ b/test/config_test/test.cfg
@@ -0,0 +1,39 @@
+modems:
+
+- dbus_path: /sierra_0
+ msisdn: 7801
+ imsi: 901700000009001
+ ki: D620F48487B1B782DA55DF6717F08FF9
+
+- dbus_path: /sierra_1
+ msisdn: '7802'
+ imsi: '901700000009002'
+ ki: D620F48487B1B782DA55DF6717F08FF9
+
+# comment
+BTS:
+
+- name: sysmoBTS 1002
+ TYPE: sysmobts
+ addr: 10.42.42.114
+ trx:
+ - timeslots:
+ - CCCH+SDCCH4
+ - SDCCH8
+ - TCH/F_TCH/H_PDCH
+ - TCH/F_TCH/H_PDCH
+ - TCH/F_TCH/H_PDCH
+ - TCH/F_TCH/H_PDCH
+ - TCH/F_TCH/H_PDCH
+ - TCH/F_TCH/H_PDCH
+ band: GSM-1800
+ - timeslots:
+ - SDCCH8
+ - PDCH
+ - PDCH
+ - PDCH
+ - PDCH
+ - PDCH
+ - PDCH
+ - PDCH
+ band: GSM-1900
diff --git a/test/lock_test.err b/test/lock_test.err
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/test/lock_test.err
diff --git a/test/lock_test.ok b/test/lock_test.ok
new file mode 100644
index 0000000..2c0f31b
--- /dev/null
+++ b/test/lock_test.ok
@@ -0,0 +1,8 @@
+acquired lock: 'long_name'
+launched first, locked by: long_name
+launched second, locked by: long_name
+leaving lock: 'long_name'
+acquired lock: 'shorter'
+waited, locked by: shorter
+leaving lock: 'shorter'
+waited more, locked by:
diff --git a/test/lock_test.sh b/test/lock_test.sh
new file mode 100755
index 0000000..c82d141
--- /dev/null
+++ b/test/lock_test.sh
@@ -0,0 +1,10 @@
+#!/bin/sh
+python3 ./lock_test_help.py long name &
+sleep .2
+echo "launched first, locked by: $(cat /tmp/lock_test)"
+python3 ./lock_test_help.py shorter &
+echo "launched second, locked by: $(cat /tmp/lock_test)"
+sleep .4
+echo "waited, locked by: $(cat /tmp/lock_test)"
+sleep .5
+echo "waited more, locked by: $(cat /tmp/lock_test)"
diff --git a/test/lock_test_help.py b/test/lock_test_help.py
new file mode 100644
index 0000000..720e100
--- /dev/null
+++ b/test/lock_test_help.py
@@ -0,0 +1,17 @@
+import sys
+import time
+
+import _prep
+
+from osmo_gsm_tester.utils import FileLock
+
+fl = FileLock('/tmp/lock_test', '_'.join(sys.argv[1:]))
+
+with fl:
+ print('acquired lock: %r' % fl.owner)
+ sys.stdout.flush()
+ time.sleep(0.5)
+ print('leaving lock: %r' % fl.owner)
+ sys.stdout.flush()
+
+# vim: expandtab tabstop=4 shiftwidth=4
diff --git a/test/log_test.err b/test/log_test.err
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/test/log_test.err
diff --git a/test/log_test.ok b/test/log_test.ok
new file mode 100644
index 0000000..70257d5
--- /dev/null
+++ b/test/log_test.ok
@@ -0,0 +1,41 @@
+- Testing global log functions
+01:02:03 tst <origin>: from log.log()
+01:02:03 tst <origin> DBG: from log.dbg()
+01:02:03 tst <origin> ERR: from log.err()
+- Testing log.Origin functions
+01:02:03 tst some-name(some='detail'): hello log
+01:02:03 tst some-name(some='detail') ERR: hello err
+01:02:03 tst some-name(some='detail'): message {int=3, none=None, str='str\n', tuple=('foo', 42)}
+01:02:03 tst some-name(some='detail') DBG: hello dbg
+- Testing log.style()
+01:02:03: only time
+tst: only category
+DBG: only level
+some-name(some='detail'): only origin
+only src [log_test.py:69]
+- Testing log.style_change()
+no log format
+01:02:03: add time
+but no time format
+01:02:03 DBG: add level
+01:02:03 tst DBG: add category
+01:02:03 tst DBG: add src [log_test.py:84]
+01:02:03 tst some-name(some='detail') DBG: add origin [log_test.py:86]
+- Testing origin_width
+01:02:03 tst shortname: origin str set to 23 chars [log_test.py:93]
+01:02:03 tst very long name(and_some=(3, 'things', 'in a tuple'), some='details'): long origin str [log_test.py:95]
+01:02:03 tst very long name(and_some=(3, 'things', 'in a tuple'), some='details') DBG: long origin str dbg [log_test.py:96]
+01:02:03 tst very long name(and_some=(3, 'things', 'in a tuple'), some='details') ERR: long origin str err [log_test.py:97]
+- Testing log.Origin with omitted info
+01:02:03 tst LogTest: hello log, name implicit from class name [log_test.py:102]
+01:02:03 --- explicit_name: hello log, no category set [log_test.py:106]
+01:02:03 --- LogTest: hello log, no category nor name set [log_test.py:109]
+01:02:03 --- LogTest DBG: debug message, no category nor name set [log_test.py:112]
+- Testing logging of Exceptions, tracing origins
+Not throwing an exception in 'with:' works.
+nested print just prints
+01:02:03 tst level1->level2->level3: nested log() [log_test.py:144]
+01:02:03 tst level1->level2: nested l2 log() from within l3 scope [log_test.py:145]
+01:02:03 tst level1->level2->level3 ERR: ValueError: bork [log_test.py:146: raise ValueError('bork')]
+- Enter the same Origin context twice
+01:02:03 tst level1->level2: nested log [log_test.py:158]
diff --git a/test/log_test.py b/test/log_test.py
new file mode 100755
index 0000000..6eca6aa
--- /dev/null
+++ b/test/log_test.py
@@ -0,0 +1,160 @@
+#!/usr/bin/env python3
+
+# osmo_gsm_tester: logging tests
+#
+# Copyright (C) 2016-2017 by sysmocom - s.f.m.c. GmbH
+#
+# Author: Neels Hofmeyr <neels@hofmeyr.de>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import _prep
+
+import sys
+import os
+
+from osmo_gsm_tester import log
+
+#log.targets[0].get_time_str = lambda: '01:02:03'
+fake_time = '01:02:03'
+log.style_change(time=True, time_fmt=fake_time)
+
+print('- Testing global log functions')
+log.log('<origin>', log.C_TST, 'from log.log()')
+log.dbg('<origin>', log.C_TST, 'from log.dbg(), not seen')
+log.set_level(log.C_TST, log.L_DBG)
+log.dbg('<origin>', log.C_TST, 'from log.dbg()')
+log.set_level(log.C_TST, log.L_LOG)
+log.err('<origin>', log.C_TST, 'from log.err()')
+
+print('- Testing log.Origin functions')
+class LogTest(log.Origin):
+ pass
+
+t = LogTest()
+t.set_log_category(log.C_TST)
+t.set_name('some', 'name', some="detail")
+
+t.log("hello log")
+t.err("hello err")
+t.dbg("hello dbg not visible")
+
+t.log("message", int=3, tuple=('foo', 42), none=None, str='str\n')
+
+log.set_level(log.C_TST, log.L_DBG)
+t.dbg("hello dbg")
+
+print('- Testing log.style()')
+
+log.style(time=True, category=False, level=False, origin=False, src=False, time_fmt=fake_time)
+t.dbg("only time")
+log.style(time=False, category=True, level=False, origin=False, src=False, time_fmt=fake_time)
+t.dbg("only category")
+log.style(time=False, category=False, level=True, origin=False, src=False, time_fmt=fake_time)
+t.dbg("only level")
+log.style(time=False, category=False, level=False, origin=True, src=False, time_fmt=fake_time)
+t.dbg("only origin")
+log.style(time=False, category=False, level=False, origin=False, src=True, time_fmt=fake_time)
+t.dbg("only src")
+
+print('- Testing log.style_change()')
+log.style(time=False, category=False, level=False, origin=False, src=False, time_fmt=fake_time)
+t.dbg("no log format")
+log.style_change(time=True)
+t.dbg("add time")
+log.style_change(time=True, time_fmt=0)
+t.dbg("but no time format")
+log.style_change(time=True, time_fmt=fake_time)
+log.style_change(level=True)
+t.dbg("add level")
+log.style_change(category=True)
+t.dbg("add category")
+log.style_change(src=True)
+t.dbg("add src")
+log.style_change(origin=True)
+t.dbg("add origin")
+
+print('- Testing origin_width')
+t = LogTest()
+t.set_log_category(log.C_TST)
+t.set_name('shortname')
+log.style(origin_width=23, time_fmt=fake_time)
+t.log("origin str set to 23 chars")
+t.set_name('very long name', some='details', and_some=(3, 'things', 'in a tuple'))
+t.log("long origin str")
+t.dbg("long origin str dbg")
+t.err("long origin str err")
+
+print('- Testing log.Origin with omitted info')
+t = LogTest()
+t.set_log_category(log.C_TST)
+t.log("hello log, name implicit from class name")
+
+t = LogTest()
+t.set_name('explicit_name')
+t.log("hello log, no category set")
+
+t = LogTest()
+t.log("hello log, no category nor name set")
+t.dbg("hello log, no category nor name set, not seen")
+log.set_level(log.C_DEFAULT, log.L_DBG)
+t.dbg("debug message, no category nor name set")
+
+print('- Testing logging of Exceptions, tracing origins')
+log.style(time_fmt=fake_time)
+
+class Thing(log.Origin):
+ def __init__(self, some_path):
+ self.set_log_category(log.C_TST)
+ self.set_name(some_path)
+
+ def say(self, msg):
+ print(msg)
+
+#log.style_change(trace=True)
+
+with Thing('print_redirected'):
+ print("Not throwing an exception in 'with:' works.")
+
+def l1():
+ level1 = Thing('level1')
+ with level1:
+ l2()
+
+def l2():
+ level2 = Thing('level2')
+ with level2:
+ l3(level2)
+
+def l3(level2):
+ level3 = Thing('level3')
+ with level3:
+ print('nested print just prints')
+ level3.log('nested log()')
+ level2.log('nested l2 log() from within l3 scope')
+ raise ValueError('bork')
+
+try:
+ l1()
+except Exception:
+ log.log_exn()
+
+print('- Enter the same Origin context twice')
+with Thing('level1'):
+ l2 = Thing('level2')
+ with l2:
+ with l2:
+ l2.log('nested log')
+
+# vim: expandtab tabstop=4 shiftwidth=4
diff --git a/test/resource_test.err b/test/resource_test.err
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/test/resource_test.err
diff --git a/test/resource_test.ok b/test/resource_test.ok
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/test/resource_test.ok
diff --git a/test/resource_test.py b/test/resource_test.py
new file mode 100755
index 0000000..87e0473
--- /dev/null
+++ b/test/resource_test.py
@@ -0,0 +1,20 @@
+#!/usr/bin/env python3
+
+import tempfile
+import os
+
+import _prep
+
+from osmo_gsm_tester import config, log, resource
+
+
+workdir = tempfile.mkdtemp()
+try:
+
+ r = resource.Resources(os.path.join(_prep.script_dir, 'etc', 'resources.conf'),
+ workdir)
+
+finally:
+ os.removedirs(workdir)
+
+# vim: expandtab tabstop=4 shiftwidth=4
diff --git a/test/resource_test/etc/resources.conf b/test/resource_test/etc/resources.conf
new file mode 100644
index 0000000..b6de84a
--- /dev/null
+++ b/test/resource_test/etc/resources.conf
@@ -0,0 +1,115 @@
+# all hardware and interfaces available to this osmo-gsm-tester
+
+nitb_iface:
+- 10.42.42.1
+- 10.42.42.2
+- 10.42.42.3
+
+bts:
+- label: sysmoBTS 1002
+ type: sysmo
+ unit_id: 1
+ addr: 10.42.42.114
+ trx:
+ - band: GSM-1800
+
+- label: octBTS 3000
+ type: oct
+ unit_id: 5
+ addr: 10.42.42.115
+ trx:
+ - band: GSM-1800
+ hwaddr: 00:0c:90:32:b5:8a
+
+- label: nanoBTS 1900
+ type: nanobts
+ unit_id: 1902
+ addr: 10.42.42.190
+ trx:
+ - band: GSM-1900
+ hwaddr: 00:02:95:00:41:b3
+
+arfcn:
+- GSM-1800: [512, 514, 516, 518, 520]
+- GSM-1900: [540, 542, 544, 546, 548]
+
+modem:
+- label: m7801
+ path: '/wavecom_0'
+ imsi: 901700000007801
+ ki: D620F48487B1B782DA55DF6717F08FF9
+
+- label: m7802
+ path: '/wavecom_1'
+ imsi: 901700000007802
+ ki: 47FDB2D55CE6A10A85ABDAD034A5B7B3
+
+- label: m7803
+ path: '/wavecom_2'
+ imsi: 901700000007803
+ ki: ABBED4C91417DF710F60675B6EE2C8D2
+
+- label: m7804
+ path: '/wavecom_3'
+ imsi: 901700000007804
+ ki: 8BA541179156F2BF0918CA3CFF9351B0
+
+- label: m7805
+ path: '/wavecom_4'
+ imsi: 901700000007805
+ ki: 82BEC24B5B50C9FAA69D17DEC0883A23
+
+- label: m7806
+ path: '/wavecom_5'
+ imsi: 901700000007806
+ ki: DAF6BD6A188F7A4F09866030BF0F723D
+
+- label: m7807
+ path: '/wavecom_6'
+ imsi: 901700000007807
+ ki: AEB411CFE39681A6352A1EAE4DDC9DBA
+
+- label: m7808
+ path: '/wavecom_7'
+ imsi: 901700000007808
+ ki: F5DEF8692B305D7A65C677CA9EEE09C4
+
+- label: m7809
+ path: '/wavecom_8'
+ imsi: 901700000007809
+ ki: A644F4503E812FD75329B1C8D625DA44
+
+- label: m7810
+ path: '/wavecom_9'
+ imsi: 901700000007810
+ ki: EF663BDF3477DCD18D3D2293A2BAED67
+
+- label: m7811
+ path: '/wavecom_10'
+ imsi: 901700000007811
+ ki: E88F37F048A86A9BC4D652539228C039
+
+- label: m7812
+ path: '/wavecom_11'
+ imsi: 901700000007812
+ ki: E8D940DD66FCF6F1CD2C0F8F8C45633D
+
+- label: m7813
+ path: '/wavecom_12'
+ imsi: 901700000007813
+ ki: DBF534700C10141C49F699B0419107E3
+
+- label: m7814
+ path: '/wavecom_13'
+ imsi: 901700000007814
+ ki: B36021DEB90C4EA607E408A92F3B024D
+
+- label: m7815
+ path: '/wavecom_14'
+ imsi: 901700000007815
+ ki: 1E209F6F839F9195778C4F96BE281A24
+
+- label: m7816
+ path: '/wavecom_15'
+ imsi: 901700000007816
+ ki: BF827D219E739DD189F6F59E60D6455C
diff --git a/test/suite_test.err b/test/suite_test.err
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/test/suite_test.err
diff --git a/test/suite_test.ok b/test/suite_test.ok
new file mode 100644
index 0000000..173fee9
--- /dev/null
+++ b/test/suite_test.ok
@@ -0,0 +1,24 @@
+- non-existing suite dir
+cnf does_not_exist ERR: RuntimeError: No such directory: 'does_not_exist'
+- no suite.conf
+--- empty_dir->suite_test/empty_dir/suite.conf ERR: FileNotFoundError: [Errno 2] No such file or directory: 'suite_test/empty_dir/suite.conf'
+- valid suite dir
+defaults:
+ timeout: 60s
+resources:
+ bts: '1'
+ modem: '2'
+ msisdn: '2'
+ nitb: '1'
+ nitb_iface: '1'
+
+- run hello world test
+tst test_suite->hello_world.py: hello world
+tst test_suite->hello_world.py: I am 'suite_test/test_suite' / 'hello_world.py'
+tst test_suite->hello_world.py: one
+tst test_suite->hello_world.py: two
+tst test_suite->hello_world.py: three
+- a test with an error
+tst test_suite->test_error.py: I am 'test_error.py' [test_error.py:1]
+tst test_suite->test_error.py ERR: AssertionError: [test_error.py:2: assert(False)]
+- graceful exit.
diff --git a/test/suite_test.py b/test/suite_test.py
new file mode 100755
index 0000000..5e6c312
--- /dev/null
+++ b/test/suite_test.py
@@ -0,0 +1,29 @@
+#!/usr/bin/env python3
+import os
+import _prep
+from osmo_gsm_tester import log, suite, config
+
+#log.style_change(trace=True)
+
+print('- non-existing suite dir')
+assert(log.run_logging_exceptions(suite.load, 'does_not_exist') == None)
+
+print('- no suite.conf')
+assert(log.run_logging_exceptions(suite.load, os.path.join('suite_test', 'empty_dir')) == None)
+
+print('- valid suite dir')
+example_suite_dir = os.path.join('suite_test', 'test_suite')
+s = suite.load(example_suite_dir)
+assert(isinstance(s, suite.Suite))
+print(config.tostr(s.conf))
+
+print('- run hello world test')
+s.run_tests_by_name('hello_world')
+
+log.style_change(src=True)
+#log.style_change(trace=True)
+print('- a test with an error')
+s.run_tests_by_name('test_error')
+
+print('- graceful exit.')
+# vim: expandtab tabstop=4 shiftwidth=4
diff --git a/test/suite_test/empty_dir/.unrelated_file b/test/suite_test/empty_dir/.unrelated_file
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/test/suite_test/empty_dir/.unrelated_file
diff --git a/test/suite_test/test_suite/hello_world.py b/test/suite_test/test_suite/hello_world.py
new file mode 100644
index 0000000..c992139
--- /dev/null
+++ b/test/suite_test/test_suite/hello_world.py
@@ -0,0 +1,3 @@
+print('hello world')
+print('I am %r / %r' % (this.suite, this.test))
+print('one\ntwo\nthree')
diff --git a/test/suite_test/test_suite/mo_mt_sms.py b/test/suite_test/test_suite/mo_mt_sms.py
new file mode 100644
index 0000000..cf44357
--- /dev/null
+++ b/test/suite_test/test_suite/mo_mt_sms.py
@@ -0,0 +1,18 @@
+nitb_iface = resources.nitb_iface()
+nitb = resources.nitb()
+bts = resources.bts()
+ms_mo = resources.modem()
+ms_mt = resources.modem()
+
+nitb.start(nitb_iface)
+bts.start(nitb)
+
+nitb.add_subscriber(ms_mo, resources.msisdn())
+nitb.add_subscriber(ms_mt, resources.msisdn())
+
+ms_mo.start()
+ms_mt.start()
+wait(nitb.subscriber_attached, ms_mo, ms_mt)
+
+sms = ms_mo.sms_send(ms_mt.msisdn)
+wait(nitb.sms_received, sms)
diff --git a/test/suite_test/test_suite/mo_sms.py b/test/suite_test/test_suite/mo_sms.py
new file mode 100644
index 0000000..d9517dd
--- /dev/null
+++ b/test/suite_test/test_suite/mo_sms.py
@@ -0,0 +1,20 @@
+nitb_iface = resources.nitb_iface()
+nitb = resources.nitb()
+bts = resources.bts()
+ms_ext = resources.msisdn()
+fake_ext = resources.msisdn()
+ms = resources.modem()
+
+nitb.configure(nitb_iface, bts)
+bts.configure(nitb)
+
+nitb.start()
+bts.start()
+
+nitb.add_fake_ext(fake_ext)
+nitb.add_subscriber(ms, ms_ext)
+
+ms.start()
+wait(nitb.subscriber_attached, ms)
+sms = ms.sms_send(fake_ext)
+wait(nitb.sms_received, sms)
diff --git a/test/suite_test/test_suite/suite.conf b/test/suite_test/test_suite/suite.conf
new file mode 100644
index 0000000..7596ca0
--- /dev/null
+++ b/test/suite_test/test_suite/suite.conf
@@ -0,0 +1,9 @@
+resources:
+ nitb_iface: 1
+ nitb: 1
+ bts: 1
+ msisdn: 2
+ modem: 2
+
+defaults:
+ timeout: 60s
diff --git a/test/suite_test/test_suite/test_error.py b/test/suite_test/test_suite/test_error.py
new file mode 100644
index 0000000..a45f7a6
--- /dev/null
+++ b/test/suite_test/test_suite/test_error.py
@@ -0,0 +1,2 @@
+print('I am %r' % this.test)
+assert(False)
diff --git a/test/suite_test/test_suite/test_error2.py b/test/suite_test/test_suite/test_error2.py
new file mode 100755
index 0000000..7e04588
--- /dev/null
+++ b/test/suite_test/test_suite/test_error2.py
@@ -0,0 +1,8 @@
+#!/usr/bin/env python3
+
+from osmo_gsm_tester import test
+from osmo_gsm_tester.test import resources
+
+print('I am %r / %r' % (test.suite.name(), test.test.name()))
+
+assert(False)
diff --git a/test/template_test.err b/test/template_test.err
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/test/template_test.err
diff --git a/test/template_test.ok b/test/template_test.ok
new file mode 100644
index 0000000..0ccc23a
--- /dev/null
+++ b/test/template_test.ok
@@ -0,0 +1,151 @@
+- Testing: fill a config file with values
+cnf Templates DBG: rendering osmo-nitb.cfg.tmpl
+!
+! OpenBSC configuration saved from vty
+!
+password foo
+!
+log stderr
+ logging filter all 1
+ logging color 0
+ logging print category 0
+ logging print extended-timestamp 1
+ logging level all debug
+!
+line vty
+ no login
+ bind val_vty_bind_ip
+!
+e1_input
+ e1_line 0 driver ipa
+ ipa bind val_abis_bind_ip
+network
+ network country code val_mcc
+ mobile network code val_mnc
+ short name val_net_name_short
+ long name val_net_name_long
+ auth policy val_net_auth_policy
+ location updating reject cause 13
+ encryption a5 val_encryption
+ neci 1
+ rrlp mode none
+ mm info 1
+ handover 0
+ handover window rxlev averaging 10
+ handover window rxqual averaging 1
+ handover window rxlev neighbor averaging 10
+ handover power budget interval 6
+ handover power budget hysteresis 3
+ handover maximum distance 9999
+ timer t3101 10
+ timer t3103 0
+ timer t3105 0
+ timer t3107 0
+ timer t3109 4
+ timer t3111 0
+ timer t3113 60
+ timer t3115 0
+ timer t3117 0
+ timer t3119 0
+ timer t3141 0
+smpp
+ local-tcp-ip val_smpp_bind_ip 2775
+ system-id test
+ policy closed
+ esme test
+ password test
+ default-route
+ctrl
+ bind val_ctrl_bind_ip
+ bts 0
+ type val_type_bts0
+ band val_band_bts0
+ cell_identity 0
+ location_area_code val_bts.location_area_code_bts0
+ training_sequence_code 7
+ base_station_id_code val_bts.base_station_id_code_bts0
+ ms max power 15
+ cell reselection hysteresis 4
+ rxlev access min 0
+ channel allocator ascending
+ rach tx integer 9
+ rach max transmission 7
+ ip.access unit_id val_bts.unit_id_bts0 0
+ oml ip.access stream_id val_bts.stream_id_bts0 line 0
+ gprs mode none
+ trx 0
+ rf_locked 0
+ arfcn val_trx_arfcn_trx0
+ nominal power 23
+ max_power_red val_trx_max_power_red_trx0
+ rsl e1 tei 0
+ timeslot 0
+ phys_chan_config val_phys_chan_config_0
+ timeslot 1
+ phys_chan_config val_phys_chan_config_1
+ timeslot 2
+ phys_chan_config val_phys_chan_config_2
+ timeslot 3
+ phys_chan_config val_phys_chan_config_3
+ trx 1
+ rf_locked 0
+ arfcn val_trx_arfcn_trx1
+ nominal power 23
+ max_power_red val_trx_max_power_red_trx1
+ rsl e1 tei 0
+ timeslot 0
+ phys_chan_config val_phys_chan_config_0
+ timeslot 1
+ phys_chan_config val_phys_chan_config_1
+ timeslot 2
+ phys_chan_config val_phys_chan_config_2
+ timeslot 3
+ phys_chan_config val_phys_chan_config_3
+ bts 1
+ type val_type_bts1
+ band val_band_bts1
+ cell_identity 0
+ location_area_code val_bts.location_area_code_bts1
+ training_sequence_code 7
+ base_station_id_code val_bts.base_station_id_code_bts1
+ ms max power 15
+ cell reselection hysteresis 4
+ rxlev access min 0
+ channel allocator ascending
+ rach tx integer 9
+ rach max transmission 7
+ ip.access unit_id val_bts.unit_id_bts1 0
+ oml ip.access stream_id val_bts.stream_id_bts1 line 0
+ gprs mode none
+ trx 0
+ rf_locked 0
+ arfcn val_trx_arfcn_trx0
+ nominal power 23
+ max_power_red val_trx_max_power_red_trx0
+ rsl e1 tei 0
+ timeslot 0
+ phys_chan_config val_phys_chan_config_0
+ timeslot 1
+ phys_chan_config val_phys_chan_config_1
+ timeslot 2
+ phys_chan_config val_phys_chan_config_2
+ timeslot 3
+ phys_chan_config val_phys_chan_config_3
+ trx 1
+ rf_locked 0
+ arfcn val_trx_arfcn_trx1
+ nominal power 23
+ max_power_red val_trx_max_power_red_trx1
+ rsl e1 tei 0
+ timeslot 0
+ phys_chan_config val_phys_chan_config_0
+ timeslot 1
+ phys_chan_config val_phys_chan_config_1
+ timeslot 2
+ phys_chan_config val_phys_chan_config_2
+ timeslot 3
+ phys_chan_config val_phys_chan_config_3
+
+- Testing: expect to fail on invalid templates dir
+sucess: setting non-existing templates dir raised RuntimeError
+
diff --git a/test/template_test.py b/test/template_test.py
new file mode 100755
index 0000000..38495bf
--- /dev/null
+++ b/test/template_test.py
@@ -0,0 +1,76 @@
+#!/usr/bin/env python3
+
+import _prep
+
+import sys
+import os
+
+from osmo_gsm_tester import template, log
+
+log.set_level(log.C_CNF, log.L_DBG)
+
+print('- Testing: fill a config file with values')
+
+mock_timeslot_list=(
+ { 'phys_chan_config': 'val_phys_chan_config_0' },
+ { 'phys_chan_config': 'val_phys_chan_config_1' },
+ { 'phys_chan_config': 'val_phys_chan_config_2' },
+ { 'phys_chan_config': 'val_phys_chan_config_3' },
+ )
+
+mock_bts = {
+ 'type': 'val_type',
+ 'band': 'val_band',
+ 'location_area_code': 'val_bts.location_area_code',
+ 'base_station_id_code': 'val_bts.base_station_id_code',
+ 'unit_id': 'val_bts.unit_id',
+ 'stream_id': 'val_bts.stream_id',
+ 'trx_list': (
+ dict(arfcn='val_trx_arfcn_trx0',
+ max_power_red='val_trx_max_power_red_trx0',
+ timeslot_list=mock_timeslot_list),
+ dict(arfcn='val_trx_arfcn_trx1',
+ max_power_red='val_trx_max_power_red_trx1',
+ timeslot_list=mock_timeslot_list),
+ )
+}
+
+def clone_mod(d, val_ext):
+ c = dict(d)
+ for name in c.keys():
+ if isinstance(c[name], str):
+ c[name] = c[name] + val_ext
+ elif isinstance(c[name], dict):
+ c[name] = clone_mod(c[name], val_ext)
+ return c
+
+mock_bts0 = clone_mod(mock_bts, '_bts0')
+mock_bts1 = clone_mod(mock_bts, '_bts1')
+
+vals = dict(
+ vty_bind_ip='val_vty_bind_ip',
+ abis_bind_ip='val_abis_bind_ip',
+ mcc='val_mcc',
+ mnc='val_mnc',
+ net_name_short='val_net_name_short',
+ net_name_long='val_net_name_long',
+ net_auth_policy='val_net_auth_policy',
+ encryption='val_encryption',
+ smpp_bind_ip='val_smpp_bind_ip',
+ ctrl_bind_ip='val_ctrl_bind_ip',
+ bts_list=(mock_bts0, mock_bts1)
+ )
+
+print(template.render('osmo-nitb.cfg', vals))
+
+print('- Testing: expect to fail on invalid templates dir')
+try:
+ template.set_templates_dir('non-existing dir')
+ sys.stderr.write('Error: setting non-existing templates dir should raise RuntimeError\n')
+ assert(False)
+except RuntimeError:
+ # not logging exception to omit non-constant path name from expected output
+ print('sucess: setting non-existing templates dir raised RuntimeError\n')
+ pass
+
+# vim: expandtab tabstop=4 shiftwidth=4
diff --git a/test/template_test/osmo-nitb.cfg.tmpl b/test/template_test/osmo-nitb.cfg.tmpl
new file mode 100644
index 0000000..3404b7f
--- /dev/null
+++ b/test/template_test/osmo-nitb.cfg.tmpl
@@ -0,0 +1,87 @@
+!
+! OpenBSC configuration saved from vty
+!
+password foo
+!
+log stderr
+ logging filter all 1
+ logging color 0
+ logging print category 0
+ logging print extended-timestamp 1
+ logging level all debug
+!
+line vty
+ no login
+ bind ${vty_bind_ip}
+!
+e1_input
+ e1_line 0 driver ipa
+ ipa bind ${abis_bind_ip}
+network
+ network country code ${mcc}
+ mobile network code ${mnc}
+ short name ${net_name_short}
+ long name ${net_name_long}
+ auth policy ${net_auth_policy}
+ location updating reject cause 13
+ encryption a5 ${encryption}
+ neci 1
+ rrlp mode none
+ mm info 1
+ handover 0
+ handover window rxlev averaging 10
+ handover window rxqual averaging 1
+ handover window rxlev neighbor averaging 10
+ handover power budget interval 6
+ handover power budget hysteresis 3
+ handover maximum distance 9999
+ timer t3101 10
+ timer t3103 0
+ timer t3105 0
+ timer t3107 0
+ timer t3109 4
+ timer t3111 0
+ timer t3113 60
+ timer t3115 0
+ timer t3117 0
+ timer t3119 0
+ timer t3141 0
+smpp
+ local-tcp-ip ${smpp_bind_ip} 2775
+ system-id test
+ policy closed
+ esme test
+ password test
+ default-route
+ctrl
+ bind ${ctrl_bind_ip}
+%for bts in bts_list:
+ bts ${loop.index}
+ type ${bts.type}
+ band ${bts.band}
+ cell_identity 0
+ location_area_code ${bts.location_area_code}
+ training_sequence_code 7
+ base_station_id_code ${bts.base_station_id_code}
+ ms max power 15
+ cell reselection hysteresis 4
+ rxlev access min 0
+ channel allocator ascending
+ rach tx integer 9
+ rach max transmission 7
+ ip.access unit_id ${bts.unit_id} 0
+ oml ip.access stream_id ${bts.stream_id} line 0
+ gprs mode none
+% for trx in bts.trx_list:
+ trx ${loop.index}
+ rf_locked 0
+ arfcn ${trx.arfcn}
+ nominal power 23
+ max_power_red ${trx.max_power_red}
+ rsl e1 tei 0
+% for ts in trx.timeslot_list:
+ timeslot ${loop.index}
+ phys_chan_config ${ts.phys_chan_config}
+% endfor
+% endfor
+%endfor
diff --git a/update_version.sh b/update_version.sh
new file mode 100755
index 0000000..3d5fe42
--- /dev/null
+++ b/update_version.sh
@@ -0,0 +1,10 @@
+#!/bin/sh
+set -e
+git describe --abbrev=8 --dirty | sed 's/v\([^-]*\)-\([^-]*\)-\(.*\)/\1.dev\2.\3/' > version
+cat version
+echo "# osmo-gsm-tester version.
+# Automatically generated by update_version.sh.
+# Gets imported by __init__.py.
+
+_version = '$(cat version)'" \
+ > src/osmo_gsm_tester/_version.py