initial import

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.
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