aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore3
-rw-r--r--Makefile2
-rwxr-xr-xcheck_dependencies.py3
-rwxr-xr-xcontrib/jenkins-openbsc-build.sh8
-rwxr-xr-xcontrib/jenkins-osmo-bts-octphy.sh2
-rwxr-xr-xcontrib/jenkins-osmo-bts-sysmo.sh7
-rwxr-xr-xcontrib/jenkins-osmo-bts-trx.sh4
-rw-r--r--doc/README.txt2
-rw-r--r--selftest/Makefile (renamed from test/Makefile)5
-rw-r--r--selftest/_prep.py (renamed from test/_prep.py)1
-rwxr-xr-xselftest/all_tests.py (renamed from test/all_tests.py)24
-rw-r--r--selftest/conf/paths.conf2
-rw-r--r--selftest/conf/resources.conf (renamed from test/resource_test/etc/resources.conf)40
-rw-r--r--selftest/config_test.err (renamed from test/config_test.err)0
-rw-r--r--selftest/config_test.ok95
-rwxr-xr-xselftest/config_test.py115
-rw-r--r--selftest/config_test/test.cfg (renamed from test/config_test/test.cfg)20
-rwxr-xr-xselftest/dbus_test/dbus_server.py44
-rwxr-xr-xselftest/dbus_test/ofono_client.py57
-rw-r--r--selftest/dbus_test/ofono_client_one_thread.py71
-rw-r--r--selftest/lock_test.err (renamed from test/lock_test.err)0
-rw-r--r--selftest/lock_test.ok (renamed from test/lock_test.ok)0
-rwxr-xr-xselftest/lock_test.sh (renamed from test/lock_test.sh)0
-rw-r--r--selftest/lock_test_help.py (renamed from test/lock_test_help.py)2
-rw-r--r--selftest/log_test.err (renamed from test/log_test.err)0
-rw-r--r--selftest/log_test.ok41
-rwxr-xr-xselftest/log_test.py (renamed from test/log_test.py)1
-rwxr-xr-xselftest/misc.py7
-rw-r--r--selftest/process_test.err (renamed from test/resource_test.err)0
-rw-r--r--selftest/process_test.ok33
-rw-r--r--selftest/process_test.ok.ign7
-rwxr-xr-xselftest/process_test.py51
-rwxr-xr-xselftest/process_test/foo.py25
-rwxr-xr-xselftest/py_import_test/invocation.py24
-rw-r--r--selftest/py_import_test/subdir/script.py9
-rw-r--r--selftest/py_import_test/support.py2
-rw-r--r--selftest/real_suite/README.txt18
-rw-r--r--selftest/real_suite/default.conf31
-rw-r--r--selftest/real_suite/env4
-rw-r--r--selftest/real_suite/paths.conf3
-rw-r--r--selftest/real_suite/resources.conf139
-rw-r--r--selftest/real_suite/scenarios/trx.conf3
-rwxr-xr-xselftest/real_suite/suites/sms/mo_mt_sms.py26
-rw-r--r--selftest/real_suite/suites/sms/suite.conf10
-rw-r--r--selftest/resource_test.err (renamed from test/resource_test.ok)0
-rw-r--r--selftest/resource_test.ok207
-rwxr-xr-xselftest/resource_test.py97
-rw-r--r--selftest/suite_test.err (renamed from test/suite_test.err)0
-rw-r--r--selftest/suite_test.ok40
-rwxr-xr-xselftest/suite_test.py34
-rw-r--r--selftest/suite_test/empty_dir/.unrelated_file (renamed from test/suite_test/empty_dir/.unrelated_file)0
-rw-r--r--selftest/suite_test/test_suite/hello_world.py5
-rw-r--r--selftest/suite_test/test_suite/mo_mt_sms.py (renamed from test/suite_test/test_suite/mo_mt_sms.py)0
-rw-r--r--selftest/suite_test/test_suite/mo_sms.py (renamed from test/suite_test/test_suite/mo_sms.py)0
-rw-r--r--selftest/suite_test/test_suite/suite.conf10
-rwxr-xr-xselftest/suite_test/test_suite/test_error.py5
-rw-r--r--selftest/template_test.err (renamed from test/template_test.err)0
-rw-r--r--selftest/template_test.ok (renamed from test/template_test.ok)36
-rwxr-xr-xselftest/template_test.py (renamed from test/template_test.py)28
-rw-r--r--selftest/template_test/osmo-nitb.cfg.tmpl (renamed from test/template_test/osmo-nitb.cfg.tmpl)0
-rw-r--r--selftest/trial_test.err0
-rw-r--r--selftest/trial_test.ok16
-rw-r--r--selftest/trial_test.ok.ign2
-rwxr-xr-xselftest/trial_test.py49
-rw-r--r--selftest/trial_test/invalid_checksum/checksums.md53
-rw-r--r--selftest/trial_test/invalid_checksum/file11
-rw-r--r--selftest/trial_test/invalid_checksum/file21
-rw-r--r--selftest/trial_test/invalid_checksum/file31
-rw-r--r--selftest/trial_test/missing_file/checksums.md53
-rw-r--r--selftest/trial_test/missing_file/file11
-rw-r--r--selftest/trial_test/missing_file/file31
-rw-r--r--selftest/trial_test/valid_checksums/checksums.md53
-rw-r--r--selftest/trial_test/valid_checksums/file11
-rw-r--r--selftest/trial_test/valid_checksums/file21
-rw-r--r--selftest/trial_test/valid_checksums/file31
-rw-r--r--selftest/util_test.err0
-rw-r--r--selftest/util_test.ok5
-rwxr-xr-xselftest/util_test.py12
-rw-r--r--src/osmo_gsm_tester/bts_model.py29
-rw-r--r--src/osmo_gsm_tester/bts_osmotrx.py104
-rw-r--r--src/osmo_gsm_tester/bts_sysmo.py69
-rw-r--r--src/osmo_gsm_tester/config.py275
-rw-r--r--src/osmo_gsm_tester/log.py162
-rw-r--r--src/osmo_gsm_tester/ofono_client.py117
-rw-r--r--src/osmo_gsm_tester/osmo_ctrl.py88
-rw-r--r--src/osmo_gsm_tester/osmo_nitb.py155
-rw-r--r--src/osmo_gsm_tester/process.py183
-rw-r--r--src/osmo_gsm_tester/resource.py445
-rw-r--r--src/osmo_gsm_tester/schema.py144
-rw-r--r--src/osmo_gsm_tester/suite.py324
-rw-r--r--src/osmo_gsm_tester/template.py8
-rw-r--r--src/osmo_gsm_tester/templates/osmo-bts-sysmo.cfg.tmpl (renamed from src/osmo_gsm_tester/templates/osmo-bts.cfg.tmpl)11
-rw-r--r--src/osmo_gsm_tester/templates/osmo-bts-trx.cfg.tmpl22
-rw-r--r--src/osmo_gsm_tester/templates/osmo-nitb.cfg.tmpl42
-rw-r--r--src/osmo_gsm_tester/test.py50
-rw-r--r--src/osmo_gsm_tester/trial.py160
-rw-r--r--src/osmo_gsm_tester/util.py332
-rw-r--r--src/osmo_gsm_tester/utils.py118
-rwxr-xr-xsrc/run_once.py112
-rw-r--r--test/config_test.ok46
-rwxr-xr-xtest/config_test.py70
-rw-r--r--test/log_test.ok41
-rwxr-xr-xtest/resource_test.py20
-rw-r--r--test/suite_test.ok24
-rwxr-xr-xtest/suite_test.py29
-rw-r--r--test/suite_test/test_suite/hello_world.py3
-rw-r--r--test/suite_test/test_suite/suite.conf9
-rw-r--r--test/suite_test/test_suite/test_error.py2
-rwxr-xr-xtest/suite_test/test_suite/test_error2.py8
109 files changed, 3988 insertions, 718 deletions
diff --git a/.gitignore b/.gitignore
index d792320..b968e92 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,6 @@ pid
version
_version.py
tags
+set_pythonpath
+test_work
+state
diff --git a/Makefile b/Makefile
index f972675..eb2f8d8 100644
--- a/Makefile
+++ b/Makefile
@@ -9,7 +9,7 @@ version:
./update_version.sh
check:
- $(MAKE) -C test check
+ $(MAKE) -C selftest check
@echo "make check: success"
# vim: noexpandtab tabstop=8 shiftwidth=8
diff --git a/check_dependencies.py b/check_dependencies.py
index d56e53b..2c9e5c3 100755
--- a/check_dependencies.py
+++ b/check_dependencies.py
@@ -22,5 +22,6 @@ import tempfile
import time
import traceback
import yaml
+import pydbus
-print('ok')
+print('dependencies ok')
diff --git a/contrib/jenkins-openbsc-build.sh b/contrib/jenkins-openbsc-build.sh
index e242927..f2be853 100755
--- a/contrib/jenkins-openbsc-build.sh
+++ b/contrib/jenkins-openbsc-build.sh
@@ -18,9 +18,9 @@ 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_name="openbsc.$arch-build-$BUILD_NUMBER"
archive="$archive_name.tgz"
-manifest="manifest.txt"
+manifest="checksums.md5"
test_report="test-report.xml"
test_timeout_sec=120
@@ -78,7 +78,9 @@ rm -rf "$local_ts_base" || true
mkdir -p "$local_ts_dir"
# create archive of openbsc build
-tar czf "$local_ts_dir/$archive" "$prefix_dirname"/*
+cd "$prefix_dirname"
+tar czf "../$local_ts_dir/$archive" *
+cd ..
# move archived bts builds into test session directory
mv $WORKSPACE/osmo-bts-*.tgz "$local_ts_dir"
cd "$local_ts_dir"
diff --git a/contrib/jenkins-osmo-bts-octphy.sh b/contrib/jenkins-osmo-bts-octphy.sh
index a966083..0a5a88c 100755
--- a/contrib/jenkins-osmo-bts-octphy.sh
+++ b/contrib/jenkins-osmo-bts-octphy.sh
@@ -91,4 +91,4 @@ 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
+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
index 142eddd..6edb7d3 100755
--- a/contrib/jenkins-osmo-bts-sysmo.sh
+++ b/contrib/jenkins-osmo-bts-sysmo.sh
@@ -60,9 +60,6 @@ for dep in $deps; do
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
+rm -f "$base/osmo-bts-sysmo.*.tgz"
cd "$prefix_base_real"
-tar cvzf "$base/$tar_name${BUILD_NUMBER}.tgz" *
+tar cvzf "$base/osmo-bts-sysmo.build-${BUILD_NUMBER}.tgz" *
diff --git a/contrib/jenkins-osmo-bts-trx.sh b/contrib/jenkins-osmo-bts-trx.sh
index b2b215b..713f974 100755
--- a/contrib/jenkins-osmo-bts-trx.sh
+++ b/contrib/jenkins-osmo-bts-trx.sh
@@ -56,6 +56,6 @@ for dep in $deps; do
done
# build the archive that is going to be copied to the tester
+rm -f "$base/osmo-bts-trx*.tgz"
cd "$base"
-rm -f osmo-bts-trx*.tgz
-tar czf "osmo-bts-trx-build-${BUILD_NUMBER}.tgz" "$inst"
+tar czf "osmo-bts-trx.build-${BUILD_NUMBER}.tgz" "$inst"
diff --git a/doc/README.txt b/doc/README.txt
index 9d2b91a..989bf68 100644
--- a/doc/README.txt
+++ b/doc/README.txt
@@ -89,4 +89,4 @@ 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.
+checksums file, typically created by jenkins using the scripts in contrib/.
diff --git a/test/Makefile b/selftest/Makefile
index 692c971..f0c8c69 100644
--- a/test/Makefile
+++ b/selftest/Makefile
@@ -1,9 +1,12 @@
.PHONY: check update
-check:
+check: set_pythonpath
./all_tests.py
update:
./all_tests.py -u
+set_pythonpath:
+ echo "export PYTHONPATH=\"$(PWD)/../src\"" > set_pythonpath
+
# vim: noexpandtab tabstop=8 shiftwidth=8
diff --git a/test/_prep.py b/selftest/_prep.py
index bfbe7b8..e89c5a7 100644
--- a/test/_prep.py
+++ b/selftest/_prep.py
@@ -10,6 +10,7 @@ sys.path.append(src_dir)
from osmo_gsm_tester import log
log.targets = [ log.TestsTarget() ]
+log.set_all_levels(log.L_DBG)
if '-v' in sys.argv:
log.style_change(trace=True)
diff --git a/test/all_tests.py b/selftest/all_tests.py
index f09fc0e..5c1ce59 100755
--- a/test/all_tests.py
+++ b/selftest/all_tests.py
@@ -6,6 +6,7 @@ import subprocess
import time
import difflib
import argparse
+import re
parser = argparse.ArgumentParser()
parser.add_argument('testdir_or_test', nargs='*',
@@ -37,6 +38,21 @@ def udiff(expect, got, expect_path):
def verify_output(got, expect_file, update=False):
if os.path.isfile(expect_file):
+ ign_file = expect_file + '.ign'
+ if os.path.isfile(ign_file):
+ with open(ign_file, 'r') as f:
+ ign_rules = f.readlines()
+ for ign_rule in ign_rules:
+ if not ign_rule:
+ continue
+ if '\t' in ign_rule:
+ ign_rule, repl = ign_rule.split('\t')
+ repl = repl.strip()
+ else:
+ repl = '*'
+ ir = re.compile(ign_rule)
+ got = repl.join(ir.split(got))
+
if update:
with open(expect_file, 'w') as f:
f.write(got)
@@ -44,6 +60,7 @@ def verify_output(got, expect_file, update=False):
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'
@@ -93,12 +110,7 @@ for test in sorted(tests):
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))
+ sys.stderr.write('\nTest failed: %r\n\n' % os.path.basename(test))
errors.append(test)
if errors:
diff --git a/selftest/conf/paths.conf b/selftest/conf/paths.conf
new file mode 100644
index 0000000..0b2d035
--- /dev/null
+++ b/selftest/conf/paths.conf
@@ -0,0 +1,2 @@
+state_dir: ./test_work/state_dir
+suites_dir: ./suite_test
diff --git a/test/resource_test/etc/resources.conf b/selftest/conf/resources.conf
index b6de84a..84c85d0 100644
--- a/test/resource_test/etc/resources.conf
+++ b/selftest/conf/resources.conf
@@ -1,37 +1,55 @@
# all hardware and interfaces available to this osmo-gsm-tester
nitb_iface:
-- 10.42.42.1
-- 10.42.42.2
-- 10.42.42.3
+- addr: 10.42.42.1
+- addr: 10.42.42.2
+- addr: 10.42.42.3
bts:
- label: sysmoBTS 1002
type: sysmo
unit_id: 1
addr: 10.42.42.114
- trx:
- - band: GSM-1800
+ band: GSM-1800
- label: octBTS 3000
type: oct
unit_id: 5
addr: 10.42.42.115
+ band: GSM-1800
trx:
- - band: GSM-1800
- hwaddr: 00:0c:90:32:b5:8a
+ - hwaddr: 00:0c:90:32:b5:8a
- label: nanoBTS 1900
type: nanobts
unit_id: 1902
addr: 10.42.42.190
+ band: GSM-1900
trx:
- - band: GSM-1900
- hwaddr: 00:02:95:00:41:b3
+ - hwaddr: 00:02:95:00:41:b3
arfcn:
-- GSM-1800: [512, 514, 516, 518, 520]
-- GSM-1900: [540, 542, 544, 546, 548]
+ - arfcn: 512
+ band: GSM-1800
+ - arfcn: 514
+ band: GSM-1800
+ - arfcn: 516
+ band: GSM-1800
+ - arfcn: 518
+ band: GSM-1800
+ - arfcn: 520
+ band: GSM-1800
+
+ - arfcn: 540
+ band: GSM-1900
+ - arfcn: 542
+ band: GSM-1900
+ - arfcn: 544
+ band: GSM-1900
+ - arfcn: 546
+ band: GSM-1900
+ - arfcn: 548
+ band: GSM-1900
modem:
- label: m7801
diff --git a/test/config_test.err b/selftest/config_test.err
index e69de29..e69de29 100644
--- a/test/config_test.err
+++ b/selftest/config_test.err
diff --git a/selftest/config_test.ok b/selftest/config_test.ok
new file mode 100644
index 0000000..40a5dcb
--- /dev/null
+++ b/selftest/config_test.ok
@@ -0,0 +1,95 @@
+{'addr': ['0.0.0.0',
+ '255.255.255.255',
+ '10.11.12.13',
+ '10.0.99.1',
+ '192.168.0.14'],
+ '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'}],
+ 'hwaddr': ['ca:ff:ee:ba:aa:be',
+ '00:00:00:00:00:00',
+ 'CA:FF:EE:BA:AA:BE',
+ 'cA:Ff:eE:Ba:aA:Be',
+ 'ff:ff:ff:ff:ff:ff'],
+ 'imsi': ['012345', '012345678', '012345678912345'],
+ 'ki': ['000102030405060708090a0b0c0d0e0f', '000102030405060708090a0b0c0d0e0f'],
+ '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 'imsi': '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
+- invalid v4 addrs:
+--- (item='addr[]'): ERR: ValueError: Invalid IPv4 address: '1.2.3'
+Validation: Error
+--- (item='addr[]'): ERR: ValueError: Invalid IPv4 address: '1.2.3 .4'
+Validation: Error
+--- (item='addr[]'): ERR: ValueError: Invalid IPv4 address: '91.2.3'
+Validation: Error
+--- (item='addr[]'): ERR: ValueError: Invalid IPv4 address: 'go away'
+Validation: Error
+--- (item='addr[]'): ERR: ValueError: Invalid IPv4 address: ''
+Validation: Error
+--- (item='addr[]'): ERR: ValueError: Invalid IPv4 address: None
+Validation: Error
+- invalid hw addrs:
+--- (item='hwaddr[]'): ERR: ValueError: Invalid hardware address: '1.2.3'
+Validation: Error
+--- (item='hwaddr[]'): ERR: ValueError: Invalid hardware address: '0b:0c:0d:0e:0f:0g'
+Validation: Error
+--- (item='hwaddr[]'): ERR: ValueError: Invalid hardware address: '0b:0c:0d:0e : 0f:0f'
+Validation: Error
+--- (item='hwaddr[]'): ERR: ValueError: Invalid hardware address: 'go away'
+Validation: Error
+--- (item='hwaddr[]'): ERR: ValueError: Invalid hardware address: ''
+Validation: Error
+--- (item='hwaddr[]'): ERR: ValueError: Invalid hardware address: None
+Validation: Error
+- invalid imsis:
+--- (item='imsi[]'): ERR: ValueError: Invalid IMSI: '99999999x9'
+Validation: Error
+--- (item='imsi[]'): ERR: ValueError: Invalid IMSI: '123 456 789 123'
+Validation: Error
+--- (item='imsi[]'): ERR: ValueError: Invalid IMSI: 'go away'
+Validation: Error
+--- (item='imsi[]'): ERR: ValueError: Invalid IMSI: ''
+Validation: Error
+--- (item='imsi[]'): ERR: ValueError: Invalid IMSI: None
+Validation: Error
diff --git a/selftest/config_test.py b/selftest/config_test.py
new file mode 100755
index 0000000..ce4d939
--- /dev/null
+++ b/selftest/config_test.py
@@ -0,0 +1,115 @@
+#!/usr/bin/env python3
+
+import _prep
+
+import sys
+import os
+import io
+import pprint
+import copy
+
+from osmo_gsm_tester import config, log, schema
+
+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': schema.STR,
+ 'modems[].msisdn': schema.STR,
+ 'modems[].imsi': schema.IMSI,
+ 'modems[].ki': schema.STR,
+ 'bts[].name' : schema.STR,
+ 'bts[].type' : schema.STR,
+ 'bts[].addr' : schema.STR,
+ 'bts[].trx[].timeslots[]' : schema.STR,
+ 'bts[].trx[].band' : schema.BAND,
+ 'a_dict.foo' : schema.INT,
+ 'addr[]' : schema.IPV4,
+ 'hwaddr[]' : schema.HWADDR,
+ 'imsi[]' : schema.IMSI,
+ 'ki[]' : schema.KI,
+ }
+
+def val(which):
+ try:
+ schema.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)
+
+print('- invalid v4 addrs:')
+c = copy.deepcopy(cfg)
+c['addr'][3] = '1.2.3'
+val(c)
+c['addr'][3] = '1.2.3 .4'
+val(c)
+c['addr'][3] = '91.2.3'
+val(c)
+c['addr'][3] = 'go away'
+val(c)
+c['addr'][3] = ''
+val(c)
+c['addr'][3] = None
+val(c)
+
+print('- invalid hw addrs:')
+c = copy.deepcopy(cfg)
+c['hwaddr'][3] = '1.2.3'
+val(c)
+c['hwaddr'][3] = '0b:0c:0d:0e:0f:0g'
+val(c)
+c['hwaddr'][3] = '0b:0c:0d:0e : 0f:0f'
+val(c)
+c['hwaddr'][3] = 'go away'
+val(c)
+c['hwaddr'][3] = ''
+val(c)
+c['hwaddr'][3] = None
+val(c)
+
+print('- invalid imsis:')
+c = copy.deepcopy(cfg)
+c['imsi'][2] = '99999999x9'
+val(c)
+c['imsi'][2] = '123 456 789 123'
+val(c)
+c['imsi'][2] = 'go away'
+val(c)
+c['imsi'][2] = ''
+val(c)
+c['imsi'][2] = None
+val(c)
+
+# vim: expandtab tabstop=4 shiftwidth=4
diff --git a/test/config_test/test.cfg b/selftest/config_test/test.cfg
index c6d61bf..cc62182 100644
--- a/test/config_test/test.cfg
+++ b/selftest/config_test/test.cfg
@@ -37,3 +37,23 @@ BTS:
- PDCH
- PDCH
band: GSM-1900
+
+addr:
+- 0.0.0.0
+- 255.255.255.255
+- 10.11.12.13
+- 10.0.99.1
+- 192.168.0.14
+hwaddr:
+- ca:ff:ee:ba:aa:be
+- 00:00:00:00:00:00
+- CA:FF:EE:BA:AA:BE
+- cA:Ff:eE:Ba:aA:Be
+- ff:ff:ff:ff:ff:ff
+imsi:
+- '012345'
+- '012345678'
+- '012345678912345'
+ki:
+- 000102030405060708090a0b0c0d0e0f
+- 000102030405060708090a0b0c0d0e0f
diff --git a/selftest/dbus_test/dbus_server.py b/selftest/dbus_test/dbus_server.py
new file mode 100755
index 0000000..222b28b
--- /dev/null
+++ b/selftest/dbus_test/dbus_server.py
@@ -0,0 +1,44 @@
+#!/usr/bin/env python3
+
+# Based on http://stackoverflow.com/questions/22390064/use-dbus-to-just-send-a-message-in-python
+
+# Python DBUS Test Server
+# runs until the Quit() method is called via DBUS
+
+from gi.repository import GLib
+from pydbus import SessionBus
+
+loop = GLib.MainLoop()
+
+class MyDBUSService(object):
+ """
+ <node>
+ <interface name='net.lew21.pydbus.ClientServerExample'>
+ <method name='Hello'>
+ <arg type='s' name='response' direction='out'/>
+ </method>
+ <method name='EchoString'>
+ <arg type='s' name='a' direction='in'/>
+ <arg type='s' name='response' direction='out'/>
+ </method>
+ <method name='Quit'/>
+ </interface>
+ </node>
+ """
+
+ def Hello(self):
+ """returns the string 'Hello, World!'"""
+ return "Hello, World!"
+
+ def EchoString(self, s):
+ """returns whatever is passed to it"""
+ return s
+
+ def Quit(self):
+ """removes this object from the DBUS connection and exits"""
+ loop.quit()
+
+bus = SessionBus()
+bus.publish("net.lew21.pydbus.ClientServerExample", MyDBUSService())
+loop.run()
+
diff --git a/selftest/dbus_test/ofono_client.py b/selftest/dbus_test/ofono_client.py
new file mode 100755
index 0000000..6b60f98
--- /dev/null
+++ b/selftest/dbus_test/ofono_client.py
@@ -0,0 +1,57 @@
+#!/usr/bin/env python3
+
+'''
+Power on and off some modem on ofono, while running the glib main loop in a
+thread and receiving modem state changes by dbus signals.
+'''
+
+from pydbus import SystemBus, Variant
+import time
+import threading
+import pprint
+
+from gi.repository import GLib
+loop = GLib.MainLoop()
+
+def propchanged(*args, **kwargs):
+ print('-> PROP CHANGED: %r %r' % (args, kwargs))
+
+class GlibMainloop(threading.Thread):
+ def run(self):
+ loop.run()
+
+ml = GlibMainloop()
+ml.start()
+
+try:
+ bus = SystemBus()
+
+ print('\n- list modems')
+ root = bus.get("org.ofono", '/')
+ print(root.Introspect())
+ modems = sorted(root.GetModems())
+ pprint.pprint(modems)
+
+ first_modem_path = modems[0][0]
+ print('\n- first modem %r' % first_modem_path)
+ modem = bus.get("org.ofono", first_modem_path)
+ modem.PropertyChanged.connect(propchanged)
+
+ print(modem.Introspect())
+ print(modem.GetProperties())
+
+ print('\n- set Powered = True')
+ modem.SetProperty('Powered', Variant('b', True))
+ print('call returned')
+ print(modem.GetProperties())
+
+ time.sleep(1)
+
+ print('\n- set Powered = False')
+ modem.SetProperty('Powered', Variant('b', False))
+ print('call returned')
+
+ print(modem.GetProperties())
+finally:
+ loop.quit()
+ml.join()
diff --git a/selftest/dbus_test/ofono_client_one_thread.py b/selftest/dbus_test/ofono_client_one_thread.py
new file mode 100644
index 0000000..96d54bc
--- /dev/null
+++ b/selftest/dbus_test/ofono_client_one_thread.py
@@ -0,0 +1,71 @@
+#!/usr/bin/env python3
+
+'''
+Power on and off some modem on ofono, while running the glib main loop in a
+thread and receiving modem state changes by dbus signals.
+'''
+
+from pydbus import SystemBus, Variant
+import time
+import pprint
+
+from gi.repository import GLib
+glib_main_loop = GLib.MainLoop()
+glib_main_ctx = glib_main_loop.get_context()
+
+def propchanged(*args, **kwargs):
+ print('-> PROP CHANGED: %r %r' % (args, kwargs))
+
+
+def pump():
+ global glib_main_ctx
+ print('pump?')
+ while glib_main_ctx.pending():
+ print('* pump')
+ glib_main_ctx.iteration()
+
+def wait(condition):
+ pump()
+ while not condition():
+ time.sleep(.1)
+ pump()
+
+bus = SystemBus()
+
+print('\n- list modems')
+root = bus.get("org.ofono", '/')
+print(root.Introspect())
+modems = sorted(root.GetModems())
+pprint.pprint(modems)
+pump()
+
+first_modem_path = modems[0][0]
+print('\n- first modem %r' % first_modem_path)
+modem = bus.get("org.ofono", first_modem_path)
+modem.PropertyChanged.connect(propchanged)
+
+print(modem.Introspect())
+print(modem.GetProperties())
+
+print('\n- set Powered = True')
+modem.SetProperty('Powered', Variant('b', True))
+print('call returned')
+print('- pump dbus events')
+pump()
+pump()
+print('sleep 1')
+time.sleep(1)
+pump()
+
+
+print('- modem properties:')
+print(modem.GetProperties())
+
+
+print('\n- set Powered = False')
+modem.SetProperty('Powered', Variant('b', False))
+print('call returned')
+
+print(modem.GetProperties())
+
+# vim: tabstop=4 shiftwidth=4 expandtab
diff --git a/test/lock_test.err b/selftest/lock_test.err
index e69de29..e69de29 100644
--- a/test/lock_test.err
+++ b/selftest/lock_test.err
diff --git a/test/lock_test.ok b/selftest/lock_test.ok
index 2c0f31b..2c0f31b 100644
--- a/test/lock_test.ok
+++ b/selftest/lock_test.ok
diff --git a/test/lock_test.sh b/selftest/lock_test.sh
index c82d141..c82d141 100755
--- a/test/lock_test.sh
+++ b/selftest/lock_test.sh
diff --git a/test/lock_test_help.py b/selftest/lock_test_help.py
index 720e100..0549981 100644
--- a/test/lock_test_help.py
+++ b/selftest/lock_test_help.py
@@ -3,7 +3,7 @@ import time
import _prep
-from osmo_gsm_tester.utils import FileLock
+from osmo_gsm_tester.util import FileLock
fl = FileLock('/tmp/lock_test', '_'.join(sys.argv[1:]))
diff --git a/test/log_test.err b/selftest/log_test.err
index e69de29..e69de29 100644
--- a/test/log_test.err
+++ b/selftest/log_test.err
diff --git a/selftest/log_test.ok b/selftest/log_test.ok
new file mode 100644
index 0000000..b2fdd69
--- /dev/null
+++ b/selftest/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:70]
+- 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:85]
+01:02:03 tst some-name(some='detail'): DBG: add origin [log_test.py:87]
+- Testing origin_width
+01:02:03 tst shortname: origin str set to 23 chars [log_test.py:94]
+01:02:03 tst very long name(and_some=(3, 'things', 'in a tuple'), some='details'): long origin str [log_test.py:96]
+01:02:03 tst very long name(and_some=(3, 'things', 'in a tuple'), some='details'): DBG: long origin str dbg [log_test.py:97]
+01:02:03 tst very long name(and_some=(3, 'things', 'in a tuple'), some='details'): ERR: long origin str err [log_test.py:98]
+- Testing log.Origin with omitted info
+01:02:03 tst LogTest: hello log, name implicit from class name [log_test.py:103]
+01:02:03 --- explicit_name: hello log, no category set [log_test.py:107]
+01:02:03 --- LogTest: hello log, no category nor name set [log_test.py:110]
+01:02:03 --- LogTest: DBG: debug message, no category nor name set [log_test.py:113]
+- Testing logging of Exceptions, tracing origins
+Not throwing an exception in 'with:' works.
+nested print just prints
+01:02:03 tst level3: nested log() [level1↪level2↪level3] [log_test.py:145]
+01:02:03 tst level2: nested l2 log() from within l3 scope [level1↪level2] [log_test.py:146]
+01:02:03 tst level3: ERR: ValueError: bork [level1↪level2↪level3] [log_test.py:147: raise ValueError('bork')]
+- Enter the same Origin context twice
+01:02:03 tst level2: nested log [level1↪level2] [log_test.py:159]
diff --git a/test/log_test.py b/selftest/log_test.py
index 6eca6aa..2ec8635 100755
--- a/test/log_test.py
+++ b/selftest/log_test.py
@@ -29,6 +29,7 @@ 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)
+log.set_all_levels(None)
print('- Testing global log functions')
log.log('<origin>', log.C_TST, 'from log.log()')
diff --git a/selftest/misc.py b/selftest/misc.py
new file mode 100755
index 0000000..e57a48c
--- /dev/null
+++ b/selftest/misc.py
@@ -0,0 +1,7 @@
+#!/usr/bin/env python3
+
+msisdn = '0000'
+
+l = len(msisdn)
+next_msisdn = ('%%0%dd' % l) % (int(msisdn) + 1)
+print(next_msisdn)
diff --git a/test/resource_test.err b/selftest/process_test.err
index e69de29..e69de29 100644
--- a/test/resource_test.err
+++ b/selftest/process_test.err
diff --git a/selftest/process_test.ok b/selftest/process_test.ok
new file mode 100644
index 0000000..4245eeb
--- /dev/null
+++ b/selftest/process_test.ok
@@ -0,0 +1,33 @@
+run foo: DBG: cd '[TMP]'; PATH=[$PATH] foo.py arg1 arg2 [foo↪foo]
+run foo: DBG: [TMP]/stdout [foo↪foo]
+run foo: DBG: [TMP]/stderr [foo↪foo]
+run foo(pid=[PID]): Launched [foo(pid=[PID])↪foo(pid=[PID])]
+stdout:
+(launched: [DATETIME])
+foo stdout
+[[$0], 'arg1', 'arg2']
+
+stderr:
+(launched: [DATETIME])
+foo stderr
+
+run foo(pid=[PID]): Terminating (SIGINT)
+run foo(pid=[PID]): DBG: Cleanup
+run foo(pid=[PID]): Terminated {rc=1}
+result: 1
+stdout:
+(launched: [DATETIME])
+foo stdout
+[[$0], 'arg1', 'arg2']
+Exiting (stdout)
+
+stderr:
+(launched: [DATETIME])
+foo stderr
+Traceback (most recent call last):
+ File [$0], line [LINE], in <module>
+ time.sleep(1)
+KeyboardInterrupt
+Exiting (stderr)
+
+done.
diff --git a/selftest/process_test.ok.ign b/selftest/process_test.ok.ign
new file mode 100644
index 0000000..0abd7d5
--- /dev/null
+++ b/selftest/process_test.ok.ign
@@ -0,0 +1,7 @@
+PATH='[^']*' PATH=[$PATH]
+/tmp/[^/ '"]* [TMP]
+pid=[0-9]* pid=[PID]
+....-..-.._..:..:.. [DATETIME]
+'[^']*/selftest/process_test/foo.py' [$0]
+"[^"]*/selftest/process_test/foo.py" [$0]
+, line [0-9]* , line [LINE]
diff --git a/selftest/process_test.py b/selftest/process_test.py
new file mode 100755
index 0000000..9ad082b
--- /dev/null
+++ b/selftest/process_test.py
@@ -0,0 +1,51 @@
+#!/usr/bin/env python3
+
+import _prep
+import time
+import os
+
+from osmo_gsm_tester import process, util, log
+
+tmpdir = util.Dir(util.get_tempdir())
+
+dollar_path = '%s:%s' % (
+ os.path.join(os.getcwd(), 'process_test'),
+ os.getenv('PATH'))
+
+p = process.Process('foo', tmpdir, ('foo.py', 'arg1', 'arg2'),
+ env={'PATH': dollar_path})
+
+p.launch()
+time.sleep(.5)
+p.poll()
+print('stdout:')
+print(p.get_stdout())
+print('stderr:')
+print(p.get_stderr())
+
+assert not p.terminated()
+p.terminate()
+assert p.terminated()
+print('result: %r' % p.result)
+
+print('stdout:')
+print(p.get_stdout())
+print('stderr:')
+print(p.get_stderr())
+print('done.')
+
+test_ssh = True
+test_ssh = False
+if test_ssh:
+ # this part of the test requires ability to ssh to localhost
+ p = process.RemoteProcess('localhost', '/tmp', 'ssh-test', tmpdir,
+ ('ls', '-al'))
+ p.launch()
+ p.wait()
+ assert p.terminated()
+ print('stdout:')
+ print(p.get_stdout())
+ print('stderr:')
+ print(p.get_stderr())
+
+# vim: expandtab tabstop=4 shiftwidth=4
diff --git a/selftest/process_test/foo.py b/selftest/process_test/foo.py
new file mode 100755
index 0000000..4abe887
--- /dev/null
+++ b/selftest/process_test/foo.py
@@ -0,0 +1,25 @@
+#!/usr/bin/env python3
+
+import sys
+import atexit
+import time
+
+
+sys.stdout.write('foo stdout\n')
+sys.stderr.write('foo stderr\n')
+
+print(repr(sys.argv))
+sys.stdout.flush()
+sys.stderr.flush()
+
+def x():
+ sys.stdout.write('Exiting (stdout)\n')
+ sys.stdout.flush()
+ sys.stderr.write('Exiting (stderr)\n')
+ sys.stderr.flush()
+atexit.register(x)
+
+while True:
+ time.sleep(1)
+
+# vim: expandtab tabstop=4 shiftwidth=4
diff --git a/selftest/py_import_test/invocation.py b/selftest/py_import_test/invocation.py
new file mode 100755
index 0000000..ad58b80
--- /dev/null
+++ b/selftest/py_import_test/invocation.py
@@ -0,0 +1,24 @@
+#!/usr/bin/env python3
+
+import support
+import importlib.util
+
+if hasattr(importlib.util, 'module_from_spec'):
+ def run_test(path):
+ print('py 3.5+')
+ spec = importlib.util.spec_from_file_location("tests.script", path)
+ spec.loader.exec_module( importlib.util.module_from_spec(spec) )
+else:
+ def run_test(path):
+ print('py 3.4-')
+ from importlib.machinery import SourceFileLoader
+ SourceFileLoader("tests.script", path).load_module()
+
+path = './subdir/script.py'
+
+support.config = 'specifics'
+run_test(path)
+
+support.config = 'specifics2'
+run_test(path)
+
diff --git a/selftest/py_import_test/subdir/script.py b/selftest/py_import_test/subdir/script.py
new file mode 100644
index 0000000..1b57c20
--- /dev/null
+++ b/selftest/py_import_test/subdir/script.py
@@ -0,0 +1,9 @@
+from support import *
+
+print('hello')
+
+def run(what):
+ print(what)
+ print(what)
+
+run(config)
diff --git a/selftest/py_import_test/support.py b/selftest/py_import_test/support.py
new file mode 100644
index 0000000..aceedb8
--- /dev/null
+++ b/selftest/py_import_test/support.py
@@ -0,0 +1,2 @@
+
+config = None
diff --git a/selftest/real_suite/README.txt b/selftest/real_suite/README.txt
new file mode 100644
index 0000000..f18840a
--- /dev/null
+++ b/selftest/real_suite/README.txt
@@ -0,0 +1,18 @@
+This a real gsm test suite configured and ready to use.
+The only thing missing is a trial dir containing binaries.
+
+If you have your trial with binary tar archives in ~/my_trial
+you can run the suite for example like this:
+
+ . ./env # point your environment at all the right places
+ run_once.py ~/my_trial -s sms:trx
+
+This combines the suites/sms test suite with the scenarios/trx choice of
+osmo-bts-trx and runs all tests in the 'sms' suite.
+
+A ./state dir will be created to store the current osmo-gsm-tester state. If
+you prefer not to write to this dir, set up an own configuration pointing at a
+different path (see paths.conf: 'state_dir' and the env file). When there is
+no OSMO_GSM_TESTER_CONF set (from ./env), osmo-gsm-tester will instead look for
+conf files in several locations like ~/.config/osmo-gsm-tester,
+/usr/local/etc/osmo-gsm-tester, /etc/osmo-gsm-tester
diff --git a/selftest/real_suite/default.conf b/selftest/real_suite/default.conf
new file mode 100644
index 0000000..b247722
--- /dev/null
+++ b/selftest/real_suite/default.conf
@@ -0,0 +1,31 @@
+nitb:
+ net:
+ mcc: 1
+ mnc: 868
+ short_name: osmo-gsm-tester
+ long_name: osmo-gsm-tester
+ auth_policy: closed
+ encryption: a5 0
+
+nitb_bts:
+ location_area_code: 23
+ base_station_id_code: 63
+ stream_id: 255
+ trx_list:
+ - max_power_red: 22
+ arfcn: 868
+ timeslot_list:
+ - phys_chan_config: CCCH+SDCCH4
+ - phys_chan_config: SDCCH8
+ - phys_chan_config: TCH_F/TCH_H/PDCH
+ - phys_chan_config: TCH_F/TCH_H/PDCH
+ - phys_chan_config: TCH_F/TCH_H/PDCH
+ - phys_chan_config: TCH_F/TCH_H/PDCH
+ - phys_chan_config: TCH_F/TCH_H/PDCH
+ - phys_chan_config: TCH_F/TCH_H/PDCH
+
+osmo_bts_sysmo:
+ ipa_unit_id: 1123
+
+osmo_bts_trx:
+ ipa_unit_id: 1124
diff --git a/selftest/real_suite/env b/selftest/real_suite/env
new file mode 100644
index 0000000..1d9cc0a
--- /dev/null
+++ b/selftest/real_suite/env
@@ -0,0 +1,4 @@
+OSMO_GSM_TESTER_SRC="$(readlink -f ../../src)"
+export PYTHONPATH="$OSMO_GSM_TESTER_SRC"
+export PATH="$OSMO_GSM_TESTER_SRC:$PATH"
+export OSMO_GSM_TESTER_CONF="$PWD"
diff --git a/selftest/real_suite/paths.conf b/selftest/real_suite/paths.conf
new file mode 100644
index 0000000..bb7316c
--- /dev/null
+++ b/selftest/real_suite/paths.conf
@@ -0,0 +1,3 @@
+state_dir: './state'
+suites_dir: './suites'
+scenarios_dir: './scenarios'
diff --git a/selftest/real_suite/resources.conf b/selftest/real_suite/resources.conf
new file mode 100644
index 0000000..a6c396b
--- /dev/null
+++ b/selftest/real_suite/resources.conf
@@ -0,0 +1,139 @@
+# all hardware and interfaces available to this osmo-gsm-tester
+
+nitb_iface:
+- addr: 127.0.0.10
+- addr: 127.0.0.11
+- addr: 127.0.0.12
+
+bts:
+- label: sysmoBTS 1002
+ type: sysmo
+ unit_id: 1
+ addr: 10.42.42.114
+ band: GSM-1800
+
+- label: octBTS 3000
+ type: oct
+ unit_id: 5
+ addr: 10.42.42.115
+ band: GSM-1800
+ trx:
+ - hwaddr: 00:0c:90:32:b5:8a
+
+- label: Ettus B210
+ type: osmotrx
+ unit_id: 6
+ addr: 10.42.42.116
+ band: GSM-1800
+
+- label: nanoBTS 1900
+ type: nanobts
+ unit_id: 1902
+ addr: 10.42.42.190
+ band: GSM-1900
+ trx:
+ - hwaddr: 00:02:95:00:41:b3
+
+arfcn:
+ - arfcn: 512
+ band: GSM-1800
+ - arfcn: 514
+ band: GSM-1800
+ - arfcn: 516
+ band: GSM-1800
+ - arfcn: 518
+ band: GSM-1800
+ - arfcn: 520
+ band: GSM-1800
+
+ - arfcn: 540
+ band: GSM-1900
+ - arfcn: 542
+ band: GSM-1900
+ - arfcn: 544
+ band: GSM-1900
+ - arfcn: 546
+ band: GSM-1900
+ - arfcn: 548
+ band: GSM-1900
+
+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/selftest/real_suite/scenarios/trx.conf b/selftest/real_suite/scenarios/trx.conf
new file mode 100644
index 0000000..98065aa
--- /dev/null
+++ b/selftest/real_suite/scenarios/trx.conf
@@ -0,0 +1,3 @@
+resources:
+ bts:
+ - type: osmotrx
diff --git a/selftest/real_suite/suites/sms/mo_mt_sms.py b/selftest/real_suite/suites/sms/mo_mt_sms.py
new file mode 100755
index 0000000..05be48c
--- /dev/null
+++ b/selftest/real_suite/suites/sms/mo_mt_sms.py
@@ -0,0 +1,26 @@
+#!/usr/bin/env python3
+from osmo_gsm_tester.test import *
+
+print('use resources...')
+nitb = suite.nitb()
+bts = suite.bts()
+ms_mo = suite.modem()
+ms_mt = suite.modem()
+
+print('start nitb and bts...')
+nitb.add_bts(bts)
+nitb.start()
+sleep(.1)
+assert nitb.running()
+bts.start()
+
+nitb.add_subscriber(ms_mo)
+nitb.add_subscriber(ms_mt)
+
+ms_mo.connect(nitb)
+ms_mt.connect(nitb)
+wait(nitb.subscriber_attached, ms_mo, ms_mt)
+
+sms = ms_mo.sms_send(ms_mt.msisdn)
+sleep(3)
+wait(nitb.sms_received, sms)
diff --git a/selftest/real_suite/suites/sms/suite.conf b/selftest/real_suite/suites/sms/suite.conf
new file mode 100644
index 0000000..4a03379
--- /dev/null
+++ b/selftest/real_suite/suites/sms/suite.conf
@@ -0,0 +1,10 @@
+resources:
+ nitb_iface:
+ - times: 1
+ bts:
+ - times: 1
+ modem:
+ - times: 2
+
+defaults:
+ timeout: 60s
diff --git a/test/resource_test.ok b/selftest/resource_test.err
index e69de29..e69de29 100644
--- a/test/resource_test.ok
+++ b/selftest/resource_test.err
diff --git a/selftest/resource_test.ok b/selftest/resource_test.ok
new file mode 100644
index 0000000..008c447
--- /dev/null
+++ b/selftest/resource_test.ok
@@ -0,0 +1,207 @@
+- expect solutions:
+[0, 1, 2]
+[0, 1, 2]
+[1, 0, 2]
+[1, 2, 0]
+- expect failure to solve:
+The requested resource requirements are not solvable [[0, 2], [2], [0, 2]]
+- test removing a Resources list from itself
+ok, caused exception: RuntimeError('Refusing to drop a list of resources from itself. This is probably a bug where a list of Resources() should have been copied but is passed as-is. use Resources.clear() instead.',)
+- test removing a Resources list from one with the same list in it
+- test resources config and state dir:
+*** all resources:
+{'arfcn': [{'_hash': 'e620569450f8259b3f0212ec19c285dd07df063c',
+ 'arfcn': '512',
+ 'band': 'GSM-1800'},
+ {'_hash': '022621e513c5a5bf33b77430a1e9c886be676fa1',
+ 'arfcn': '514',
+ 'band': 'GSM-1800'},
+ {'_hash': '3199abf375a1dd899e554e9d63a552e06d7f38bf',
+ 'arfcn': '516',
+ 'band': 'GSM-1800'},
+ {'_hash': '57aa7bd1da62495f2857ae6b859193dd592a0a02',
+ 'arfcn': '518',
+ 'band': 'GSM-1800'},
+ {'_hash': '53dd2e2682b736f427abd2ce59a9a50ca8130678',
+ 'arfcn': '520',
+ 'band': 'GSM-1800'},
+ {'_hash': '31687a5e6d5140a4b3877606ca5f18244f11d706',
+ 'arfcn': '540',
+ 'band': 'GSM-1900'},
+ {'_hash': '1def43a5c88a83cdb21279eacab0679ea08ffaf3',
+ 'arfcn': '542',
+ 'band': 'GSM-1900'},
+ {'_hash': '1d6e3b08a3861fd4d748f111295ec5a93ecd3d23',
+ 'arfcn': '544',
+ 'band': 'GSM-1900'},
+ {'_hash': '8fb36927de15466fcdbee01f7f65704c312cb36c',
+ 'arfcn': '546',
+ 'band': 'GSM-1900'},
+ {'_hash': 'dc9ce027a257da087f31a5bc1ee6b4abd2637369',
+ 'arfcn': '548',
+ 'band': 'GSM-1900'}],
+ 'bts': [{'_hash': 'a7c6d2ebaeb139e8c2e7d45c3495d046d7439007',
+ 'addr': '10.42.42.114',
+ 'band': 'GSM-1800',
+ 'label': 'sysmoBTS 1002',
+ 'type': 'sysmo',
+ 'unit_id': '1'},
+ {'_hash': '02540ab9eb556056a0b4d28443bc9f4793f6d549',
+ 'addr': '10.42.42.115',
+ 'band': 'GSM-1800',
+ 'label': 'octBTS 3000',
+ 'trx': [{'hwaddr': '00:0c:90:32:b5:8a'}],
+ 'type': 'oct',
+ 'unit_id': '5'},
+ {'_hash': '556c954d475d12cf0dc622c0df5743cac5543fa0',
+ 'addr': '10.42.42.190',
+ 'band': 'GSM-1900',
+ 'label': 'nanoBTS 1900',
+ 'trx': [{'hwaddr': '00:02:95:00:41:b3'}],
+ 'type': 'nanobts',
+ 'unit_id': '1902'}],
+ 'modem': [{'_hash': '19c69e45aa090fb511446bd00797690aa82ff52f',
+ 'imsi': '901700000007801',
+ 'ki': 'D620F48487B1B782DA55DF6717F08FF9',
+ 'label': 'm7801',
+ 'path': '/wavecom_0'},
+ {'_hash': 'e1a46516a1fb493b2617ab14fc1693a9a45ec254',
+ 'imsi': '901700000007802',
+ 'ki': '47FDB2D55CE6A10A85ABDAD034A5B7B3',
+ 'label': 'm7802',
+ 'path': '/wavecom_1'},
+ {'_hash': '4fe91500a309782bb0fd8ac6fc827834089f8b00',
+ 'imsi': '901700000007803',
+ 'ki': 'ABBED4C91417DF710F60675B6EE2C8D2',
+ 'label': 'm7803',
+ 'path': '/wavecom_2'},
+ {'_hash': 'c895badf0c2faaa4a997cd9f2313b5ebda7486e4',
+ 'imsi': '901700000007804',
+ 'ki': '8BA541179156F2BF0918CA3CFF9351B0',
+ 'label': 'm7804',
+ 'path': '/wavecom_3'},
+ {'_hash': '60f182abed05adb530e3d06d88cc47703b65d7d8',
+ 'imsi': '901700000007805',
+ 'ki': '82BEC24B5B50C9FAA69D17DEC0883A23',
+ 'label': 'm7805',
+ 'path': '/wavecom_4'},
+ {'_hash': 'd1f0fbf089a4bf32dd566af956d23b89e3d60821',
+ 'imsi': '901700000007806',
+ 'ki': 'DAF6BD6A188F7A4F09866030BF0F723D',
+ 'label': 'm7806',
+ 'path': '/wavecom_5'},
+ {'_hash': '2445e3b5949d15f4351c0db1d3f3f593f9d73aa5',
+ 'imsi': '901700000007807',
+ 'ki': 'AEB411CFE39681A6352A1EAE4DDC9DBA',
+ 'label': 'm7807',
+ 'path': '/wavecom_6'},
+ {'_hash': '80247388b2ca382382c4aec678102355b7922965',
+ 'imsi': '901700000007808',
+ 'ki': 'F5DEF8692B305D7A65C677CA9EEE09C4',
+ 'label': 'm7808',
+ 'path': '/wavecom_7'},
+ {'_hash': '5b9e4e117a8889430542d22a9693e7b999362856',
+ 'imsi': '901700000007809',
+ 'ki': 'A644F4503E812FD75329B1C8D625DA44',
+ 'label': 'm7809',
+ 'path': '/wavecom_8'},
+ {'_hash': '219a7abb057050eef3ce4b99c487f32bbaae9a41',
+ 'imsi': '901700000007810',
+ 'ki': 'EF663BDF3477DCD18D3D2293A2BAED67',
+ 'label': 'm7810',
+ 'path': '/wavecom_9'},
+ {'_hash': '75d45c2d975b893da34c7cae827c25a2039cecd2',
+ 'imsi': '901700000007811',
+ 'ki': 'E88F37F048A86A9BC4D652539228C039',
+ 'label': 'm7811',
+ 'path': '/wavecom_10'},
+ {'_hash': '1777362f556b249a5c1d6a83110704dbd037bc20',
+ 'imsi': '901700000007812',
+ 'ki': 'E8D940DD66FCF6F1CD2C0F8F8C45633D',
+ 'label': 'm7812',
+ 'path': '/wavecom_11'},
+ {'_hash': '21d7eb4b0c782e004821a9f7f778891c93956924',
+ 'imsi': '901700000007813',
+ 'ki': 'DBF534700C10141C49F699B0419107E3',
+ 'label': 'm7813',
+ 'path': '/wavecom_12'},
+ {'_hash': 'f53e4e79bdbc63eb2845de671007d4f733f28409',
+ 'imsi': '901700000007814',
+ 'ki': 'B36021DEB90C4EA607E408A92F3B024D',
+ 'label': 'm7814',
+ 'path': '/wavecom_13'},
+ {'_hash': 'df1abec7704ebc89b2c062a69bd299cf3663ed9e',
+ 'imsi': '901700000007815',
+ 'ki': '1E209F6F839F9195778C4F96BE281A24',
+ 'label': 'm7815',
+ 'path': '/wavecom_14'},
+ {'_hash': '11df1e4c7708157e5b89020c757763f58d6e610b',
+ 'imsi': '901700000007816',
+ 'ki': 'BF827D219E739DD189F6F59E60D6455C',
+ 'label': 'm7816',
+ 'path': '/wavecom_15'}],
+ 'nitb_iface': [{'_hash': 'cde1debf28f07f94f92c761b4b7c6bf35785ced4',
+ 'addr': '10.42.42.1'},
+ {'_hash': 'fd103b22c7cf2480d609150e06f4bbd92ac78d8c',
+ 'addr': '10.42.42.2'},
+ {'_hash': '1c614d6210c551d142aadca8f25e1534ebb2a70f',
+ 'addr': '10.42.42.3'}]}
+*** end: all resources
+
+- request some resources
+--- (want='nitb_iface'): DBG: Looking for 1 x nitb_iface , candidates: 3
+--- (want='arfcn'): DBG: Looking for 2 x arfcn , candidates: 10
+--- (want='bts'): DBG: Looking for 2 x bts , candidates: 3
+--- (want='modem'): DBG: Looking for 2 x modem , candidates: 16
+~~~ currently reserved:
+arfcn:
+- _hash: e620569450f8259b3f0212ec19c285dd07df063c
+ _reserved_by: testowner-123-1490837279
+ arfcn: '512'
+ band: GSM-1800
+- _hash: 022621e513c5a5bf33b77430a1e9c886be676fa1
+ _reserved_by: testowner-123-1490837279
+ arfcn: '514'
+ band: GSM-1800
+bts:
+- _hash: a7c6d2ebaeb139e8c2e7d45c3495d046d7439007
+ _reserved_by: testowner-123-1490837279
+ addr: 10.42.42.114
+ band: GSM-1800
+ label: sysmoBTS 1002
+ type: sysmo
+ unit_id: '1'
+- _hash: 02540ab9eb556056a0b4d28443bc9f4793f6d549
+ _reserved_by: testowner-123-1490837279
+ addr: 10.42.42.115
+ band: GSM-1800
+ label: octBTS 3000
+ trx:
+ - hwaddr: 00:0c:90:32:b5:8a
+ type: oct
+ unit_id: '5'
+modem:
+- _hash: 19c69e45aa090fb511446bd00797690aa82ff52f
+ _reserved_by: testowner-123-1490837279
+ imsi: '901700000007801'
+ ki: D620F48487B1B782DA55DF6717F08FF9
+ label: m7801
+ path: /wavecom_0
+- _hash: e1a46516a1fb493b2617ab14fc1693a9a45ec254
+ _reserved_by: testowner-123-1490837279
+ imsi: '901700000007802'
+ ki: 47FDB2D55CE6A10A85ABDAD034A5B7B3
+ label: m7802
+ path: /wavecom_1
+nitb_iface:
+- _hash: cde1debf28f07f94f92c761b4b7c6bf35785ced4
+ _reserved_by: testowner-123-1490837279
+ addr: 10.42.42.1
+
+~~~ end: currently reserved
+
+~~~ currently reserved:
+{}
+
+~~~ end: currently reserved
+
diff --git a/selftest/resource_test.py b/selftest/resource_test.py
new file mode 100755
index 0000000..2d0f880
--- /dev/null
+++ b/selftest/resource_test.py
@@ -0,0 +1,97 @@
+#!/usr/bin/env python3
+
+import tempfile
+import os
+import pprint
+import shutil
+import atexit
+import _prep
+from osmo_gsm_tester import config, log, resource, util
+
+workdir = util.get_tempdir()
+
+# override config locations to make sure we use only the test conf
+config.ENV_CONF = './conf'
+
+log.get_process_id = lambda: '123-1490837279'
+
+print('- expect solutions:')
+pprint.pprint(
+ resource.solve([ [0, 1, 2],
+ [0, 1, 2],
+ [0, 1, 2] ]) )
+pprint.pprint(
+ resource.solve([ [0, 1, 2],
+ [0, 1],
+ [0, 2] ]) ) # == [0, 1, 2]
+pprint.pprint(
+ resource.solve([ [0, 1, 2],
+ [0],
+ [0, 2] ]) ) # == [1, 0, 2]
+pprint.pprint(
+ resource.solve([ [0, 1, 2],
+ [2],
+ [0, 2] ]) ) # == [1, 2, 0]
+
+print('- expect failure to solve:')
+try:
+ resource.solve([ [0, 2],
+ [2],
+ [0, 2] ])
+ assert False
+except resource.NoResourceExn as e:
+ print(e)
+
+print('- test removing a Resources list from itself')
+try:
+ r = resource.Resources({ 'k': [ {'a': 1, 'b': 2}, {'a': 3, 'b': 4}, ],
+ 'i': [ {'c': 1, 'd': 2}, {'c': 3, 'd': 4}, ] })
+ r.drop(r)
+ assert False
+except RuntimeError as e:
+ print('ok, caused exception: %r' % e)
+
+print('- test removing a Resources list from one with the same list in it')
+r = resource.Resources({ 'k': [ {'a': 1, 'b': 2}, {'a': 3, 'b': 4}, ],
+ 'i': [ {'c': 1, 'd': 2}, {'c': 3, 'd': 4}, ] })
+r.drop({ 'k': r.get('k'), 'i': r.get('i') })
+assert not r
+
+print('- test resources config and state dir:')
+resources_conf = os.path.join(_prep.script_dir, 'resource_test', 'etc',
+ 'resources.conf')
+
+state_dir = config.get_state_dir()
+rrfile = state_dir.child(resource.RESERVED_RESOURCES_FILE)
+
+pool = resource.ResourcesPool()
+
+print('*** all resources:')
+pprint.pprint(pool.all_resources)
+print('*** end: all resources\n')
+
+print('- request some resources')
+want = {
+ 'nitb_iface': [ { 'times': 1 } ],
+ 'bts': [ { 'type': 'sysmo', 'times': 1 }, { 'type': 'oct', 'times': 1 } ],
+ 'arfcn': [ { 'band': 'GSM-1800', 'times': 2 } ],
+ 'modem': [ { 'times': 2 } ],
+ }
+
+origin = log.Origin('testowner')
+
+resources = pool.reserve(origin, want)
+
+print('~~~ currently reserved:')
+with open(rrfile, 'r') as f:
+ print(f.read())
+print('~~~ end: currently reserved\n')
+
+resources.free()
+
+print('~~~ currently reserved:')
+with open(rrfile, 'r') as f:
+ print(f.read())
+print('~~~ end: currently reserved\n')
+
+# vim: expandtab tabstop=4 shiftwidth=4
diff --git a/test/suite_test.err b/selftest/suite_test.err
index e69de29..e69de29 100644
--- a/test/suite_test.err
+++ b/selftest/suite_test.err
diff --git a/selftest/suite_test.ok b/selftest/suite_test.ok
new file mode 100644
index 0000000..c0232dd
--- /dev/null
+++ b/selftest/suite_test.ok
@@ -0,0 +1,40 @@
+- non-existing suite dir
+--- -: ERR: RuntimeError: Suite not found: 'does_not_exist' in ./suite_test
+- no suite.conf
+cnf empty_dir: DBG: reading suite.conf [empty_dir↪empty_dir]
+--- ./suite_test/empty_dir/suite.conf: ERR: FileNotFoundError: [Errno 2] No such file or directory: './suite_test/empty_dir/suite.conf' [empty_dir↪./suite_test/empty_dir/suite.conf]
+- valid suite dir
+cnf test_suite: DBG: reading suite.conf [test_suite↪test_suite]
+defaults:
+ timeout: 60s
+resources:
+ bts:
+ - times: '1'
+ modem:
+ - times: '2'
+ nitb_iface:
+ - times: '1'
+
+- run hello world test
+tst test_suite: reserving resources...
+--- (want='nitb_iface'): DBG: Looking for 1 x nitb_iface , candidates: 3
+--- (want='modem'): DBG: Looking for 2 x modem , candidates: 16
+--- (want='bts'): DBG: Looking for 1 x bts , candidates: 3
+tst hello_world.py: START [test_suite↪hello_world.py]
+tst hello_world.py:3: hello world [test_suite↪hello_world.py:3]
+tst hello_world.py:4: I am 'test_suite' / 'hello_world.py:4' [test_suite↪hello_world.py:4]
+tst hello_world.py:5: one [test_suite↪hello_world.py:5]
+tst hello_world.py:5: two [test_suite↪hello_world.py:5]
+tst hello_world.py:5: three [test_suite↪hello_world.py:5]
+tst hello_world.py: PASS [test_suite↪hello_world.py]
+pass: all 1 tests passed.
+
+- a test with an error
+tst test_error.py: START [test_suite↪test_error.py] [suite.py:96]
+tst test_error.py:3: I am 'test_suite' / 'test_error.py:3' [test_suite↪test_error.py:3] [test_error.py:3]
+tst test_error.py:5: FAIL [test_suite↪test_error.py:5] [suite.py:108]
+tst test_error.py:5: ERR: AssertionError: [test_suite↪test_error.py:5] [test_error.py:5: assert False]
+FAIL: 1 of 1 tests failed:
+ test_error.py
+
+- graceful exit.
diff --git a/selftest/suite_test.py b/selftest/suite_test.py
new file mode 100755
index 0000000..8c0e6e8
--- /dev/null
+++ b/selftest/suite_test.py
@@ -0,0 +1,34 @@
+#!/usr/bin/env python3
+import os
+import _prep
+from osmo_gsm_tester import log, suite, config
+
+config.ENV_CONF = os.path.join(os.getcwd(), 'conf')
+
+#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, 'empty_dir') == None)
+
+print('- valid suite dir')
+example_suite_dir = os.path.join('test_suite')
+s_def = suite.load(example_suite_dir)
+assert(isinstance(s_def, suite.SuiteDefinition))
+print(config.tostr(s_def.conf))
+
+print('- run hello world test')
+s = suite.SuiteRun(None, s_def)
+results = s.run_tests('hello_world.py')
+print(str(results))
+
+log.style_change(src=True)
+#log.style_change(trace=True)
+print('\n- a test with an error')
+results = s.run_tests('test_error.py')
+print(str(results))
+
+print('\n- graceful exit.')
+# vim: expandtab tabstop=4 shiftwidth=4
diff --git a/test/suite_test/empty_dir/.unrelated_file b/selftest/suite_test/empty_dir/.unrelated_file
index e69de29..e69de29 100644
--- a/test/suite_test/empty_dir/.unrelated_file
+++ b/selftest/suite_test/empty_dir/.unrelated_file
diff --git a/selftest/suite_test/test_suite/hello_world.py b/selftest/suite_test/test_suite/hello_world.py
new file mode 100644
index 0000000..9f3bf4a
--- /dev/null
+++ b/selftest/suite_test/test_suite/hello_world.py
@@ -0,0 +1,5 @@
+from osmo_gsm_tester.test import *
+
+print('hello world')
+print('I am %r / %r' % (suite.name(), test.name()))
+print('one\ntwo\nthree')
diff --git a/test/suite_test/test_suite/mo_mt_sms.py b/selftest/suite_test/test_suite/mo_mt_sms.py
index cf44357..cf44357 100644
--- a/test/suite_test/test_suite/mo_mt_sms.py
+++ b/selftest/suite_test/test_suite/mo_mt_sms.py
diff --git a/test/suite_test/test_suite/mo_sms.py b/selftest/suite_test/test_suite/mo_sms.py
index d9517dd..d9517dd 100644
--- a/test/suite_test/test_suite/mo_sms.py
+++ b/selftest/suite_test/test_suite/mo_sms.py
diff --git a/selftest/suite_test/test_suite/suite.conf b/selftest/suite_test/test_suite/suite.conf
new file mode 100644
index 0000000..4a03379
--- /dev/null
+++ b/selftest/suite_test/test_suite/suite.conf
@@ -0,0 +1,10 @@
+resources:
+ nitb_iface:
+ - times: 1
+ bts:
+ - times: 1
+ modem:
+ - times: 2
+
+defaults:
+ timeout: 60s
diff --git a/selftest/suite_test/test_suite/test_error.py b/selftest/suite_test/test_suite/test_error.py
new file mode 100755
index 0000000..17b05f4
--- /dev/null
+++ b/selftest/suite_test/test_suite/test_error.py
@@ -0,0 +1,5 @@
+from osmo_gsm_tester.test import *
+
+print('I am %r / %r' % (suite.name(), test.name()))
+
+assert False
diff --git a/test/template_test.err b/selftest/template_test.err
index e69de29..e69de29 100644
--- a/test/template_test.err
+++ b/selftest/template_test.err
diff --git a/test/template_test.ok b/selftest/template_test.ok
index 0ccc23a..879907d 100644
--- a/test/template_test.ok
+++ b/selftest/template_test.ok
@@ -1,8 +1,6 @@
- Testing: fill a config file with values
-cnf Templates DBG: rendering osmo-nitb.cfg.tmpl
-!
-! OpenBSC configuration saved from vty
-!
+cnf Templates: DBG: rendering osmo-nitb.cfg.tmpl [osmo-nitb.cfg.tmpl↪Templates]
+! Configuration rendered by osmo-gsm-tester
password foo
!
log stderr
@@ -14,19 +12,19 @@ log stderr
!
line vty
no login
- bind val_vty_bind_ip
+ bind val_nitb_iface_addr
!
e1_input
e1_line 0 driver ipa
- ipa bind val_abis_bind_ip
+ ipa bind val_nitb_iface_addr
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
+ short name val_short_name
+ long name val_long_name
+ auth policy val_auth_policy
location updating reject cause 13
- encryption a5 val_encryption
+ encryption val_encryption
neci 1
rrlp mode none
mm info 1
@@ -48,15 +46,6 @@ network
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
@@ -145,6 +134,15 @@ ctrl
phys_chan_config val_phys_chan_config_2
timeslot 3
phys_chan_config val_phys_chan_config_3
+smpp
+ local-tcp-ip val_nitb_iface_addr 2775
+ system-id test
+ policy closed
+ esme test
+ password test
+ default-route
+ctrl
+ bind val_nitb_iface_addr
- Testing: expect to fail on invalid templates dir
sucess: setting non-existing templates dir raised RuntimeError
diff --git a/test/template_test.py b/selftest/template_test.py
index 38495bf..2b44ae5 100755
--- a/test/template_test.py
+++ b/selftest/template_test.py
@@ -23,7 +23,7 @@ mock_bts = {
'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',
+ 'ipa_unit_id': 'val_bts.unit_id',
'stream_id': 'val_bts.stream_id',
'trx_list': (
dict(arfcn='val_trx_arfcn_trx0',
@@ -47,19 +47,19 @@ def clone_mod(d, val_ext):
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)
- )
+vals = dict(nitb=dict(
+ net=dict(
+ mcc='val_mcc',
+ mnc='val_mnc',
+ short_name='val_short_name',
+ long_name='val_long_name',
+ auth_policy='val_auth_policy',
+ encryption='val_encryption',
+ bts_list=(mock_bts0, mock_bts1)
+ ),
+ ),
+ nitb_iface=dict(addr='val_nitb_iface_addr'),
+ )
print(template.render('osmo-nitb.cfg', vals))
diff --git a/test/template_test/osmo-nitb.cfg.tmpl b/selftest/template_test/osmo-nitb.cfg.tmpl
index 3404b7f..3404b7f 100644
--- a/test/template_test/osmo-nitb.cfg.tmpl
+++ b/selftest/template_test/osmo-nitb.cfg.tmpl
diff --git a/selftest/trial_test.err b/selftest/trial_test.err
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/selftest/trial_test.err
diff --git a/selftest/trial_test.ok b/selftest/trial_test.ok
new file mode 100644
index 0000000..0b3e31a
--- /dev/null
+++ b/selftest/trial_test.ok
@@ -0,0 +1,16 @@
+- make a few trials dirs
+[TMP]/first
+[TMP]/second
+[TMP]/third
+- fetch trial dirs in order
+[TMP]/first
+['taken']
+[TMP]/second
+[TMP]/third
+- no more trial dirs left
+None
+- test checksum verification
+- detect wrong checksum
+ok, got RuntimeError("Checksum mismatch for 'trial_test/invalid_checksum/file2' vs. 'trial_test/invalid_checksum/checksums.md5' line 2",)
+- detect missing file
+ok, got RuntimeError("File listed in checksums file but missing in trials dir: 'trial_test/missing_file/file2' vs. 'trial_test/missing_file/checksums.md5' line 2",)
diff --git a/selftest/trial_test.ok.ign b/selftest/trial_test.ok.ign
new file mode 100644
index 0000000..38a82ce
--- /dev/null
+++ b/selftest/trial_test.ok.ign
@@ -0,0 +1,2 @@
+/tmp/[^/]* [TMP]
+....-..-.._..-..-.. [TIMESTAMP]
diff --git a/selftest/trial_test.py b/selftest/trial_test.py
new file mode 100755
index 0000000..ba3f01b
--- /dev/null
+++ b/selftest/trial_test.py
@@ -0,0 +1,49 @@
+#!/usr/bin/env python3
+
+import time
+import _prep
+import os
+from osmo_gsm_tester import util
+from osmo_gsm_tester.trial import Trial
+
+workdir = util.get_tempdir()
+
+trials_dir = util.Dir(workdir)
+
+print('- make a few trials dirs')
+print(trials_dir.mkdir('first'))
+time.sleep(1)
+print(trials_dir.mkdir('second'))
+time.sleep(1)
+print(trials_dir.mkdir('third'))
+
+print('- fetch trial dirs in order')
+t = Trial.next(trials_dir)
+print(t)
+print(repr(sorted(t.dir.children())))
+print(Trial.next(trials_dir))
+print(Trial.next(trials_dir))
+
+print('- no more trial dirs left')
+print(repr(Trial.next(trials_dir)))
+
+print('- test checksum verification')
+d = util.Dir('trial_test')
+t = Trial(d.child('valid_checksums'))
+t.verify()
+
+print('- detect wrong checksum')
+t = Trial(d.child('invalid_checksum'))
+try:
+ t.verify()
+except RuntimeError as e:
+ print('ok, got %r' % e)
+
+print('- detect missing file')
+t = Trial(d.child('missing_file'))
+try:
+ t.verify()
+except RuntimeError as e:
+ print('ok, got %r' % e)
+
+# vim: expandtab tabstop=4 shiftwidth=4
diff --git a/selftest/trial_test/invalid_checksum/checksums.md5 b/selftest/trial_test/invalid_checksum/checksums.md5
new file mode 100644
index 0000000..90d3547
--- /dev/null
+++ b/selftest/trial_test/invalid_checksum/checksums.md5
@@ -0,0 +1,3 @@
+5149d403009a139c7e085405ef762e1a file1
+3d709e89c8ce201e3c928eb917989aef file2
+60b91f1875424d3b4322b0fdd0529d5d file3
diff --git a/selftest/trial_test/invalid_checksum/file1 b/selftest/trial_test/invalid_checksum/file1
new file mode 100644
index 0000000..e212970
--- /dev/null
+++ b/selftest/trial_test/invalid_checksum/file1
@@ -0,0 +1 @@
+file1
diff --git a/selftest/trial_test/invalid_checksum/file2 b/selftest/trial_test/invalid_checksum/file2
new file mode 100644
index 0000000..34ccdac
--- /dev/null
+++ b/selftest/trial_test/invalid_checksum/file2
@@ -0,0 +1 @@
+no no no
diff --git a/selftest/trial_test/invalid_checksum/file3 b/selftest/trial_test/invalid_checksum/file3
new file mode 100644
index 0000000..7c8ac2f
--- /dev/null
+++ b/selftest/trial_test/invalid_checksum/file3
@@ -0,0 +1 @@
+file3
diff --git a/selftest/trial_test/missing_file/checksums.md5 b/selftest/trial_test/missing_file/checksums.md5
new file mode 100644
index 0000000..90d3547
--- /dev/null
+++ b/selftest/trial_test/missing_file/checksums.md5
@@ -0,0 +1,3 @@
+5149d403009a139c7e085405ef762e1a file1
+3d709e89c8ce201e3c928eb917989aef file2
+60b91f1875424d3b4322b0fdd0529d5d file3
diff --git a/selftest/trial_test/missing_file/file1 b/selftest/trial_test/missing_file/file1
new file mode 100644
index 0000000..e212970
--- /dev/null
+++ b/selftest/trial_test/missing_file/file1
@@ -0,0 +1 @@
+file1
diff --git a/selftest/trial_test/missing_file/file3 b/selftest/trial_test/missing_file/file3
new file mode 100644
index 0000000..7c8ac2f
--- /dev/null
+++ b/selftest/trial_test/missing_file/file3
@@ -0,0 +1 @@
+file3
diff --git a/selftest/trial_test/valid_checksums/checksums.md5 b/selftest/trial_test/valid_checksums/checksums.md5
new file mode 100644
index 0000000..90d3547
--- /dev/null
+++ b/selftest/trial_test/valid_checksums/checksums.md5
@@ -0,0 +1,3 @@
+5149d403009a139c7e085405ef762e1a file1
+3d709e89c8ce201e3c928eb917989aef file2
+60b91f1875424d3b4322b0fdd0529d5d file3
diff --git a/selftest/trial_test/valid_checksums/file1 b/selftest/trial_test/valid_checksums/file1
new file mode 100644
index 0000000..e212970
--- /dev/null
+++ b/selftest/trial_test/valid_checksums/file1
@@ -0,0 +1 @@
+file1
diff --git a/selftest/trial_test/valid_checksums/file2 b/selftest/trial_test/valid_checksums/file2
new file mode 100644
index 0000000..6c493ff
--- /dev/null
+++ b/selftest/trial_test/valid_checksums/file2
@@ -0,0 +1 @@
+file2
diff --git a/selftest/trial_test/valid_checksums/file3 b/selftest/trial_test/valid_checksums/file3
new file mode 100644
index 0000000..7c8ac2f
--- /dev/null
+++ b/selftest/trial_test/valid_checksums/file3
@@ -0,0 +1 @@
+file3
diff --git a/selftest/util_test.err b/selftest/util_test.err
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/selftest/util_test.err
diff --git a/selftest/util_test.ok b/selftest/util_test.ok
new file mode 100644
index 0000000..c2c5f87
--- /dev/null
+++ b/selftest/util_test.ok
@@ -0,0 +1,5 @@
+- expect the same hashes on every test run
+a9993e364706816aba3e25717850c26c9cd0d89d
+356a192b7913b04c54574d18c28d46e6395428ab
+40bd001563085fc35165329ea1ff5c5ecbdbbeef
+c129b324aee662b04eccf68babba85851346dff9
diff --git a/selftest/util_test.py b/selftest/util_test.py
new file mode 100755
index 0000000..c517655
--- /dev/null
+++ b/selftest/util_test.py
@@ -0,0 +1,12 @@
+#!/usr/bin/env python3
+import _prep
+
+from osmo_gsm_tester.util import hash_obj
+
+print('- expect the same hashes on every test run')
+print(hash_obj('abc'))
+print(hash_obj(1))
+print(hash_obj([1, 2, 3]))
+print(hash_obj({ 'k': [ {'a': 1, 'b': 2}, {'a': 3, 'b': 4}, ],
+ 'i': [ {'c': 1, 'd': 2}, {'c': 3, 'd': 4}, ] }))
+
diff --git a/src/osmo_gsm_tester/bts_model.py b/src/osmo_gsm_tester/bts_model.py
new file mode 100644
index 0000000..e5f9682
--- /dev/null
+++ b/src/osmo_gsm_tester/bts_model.py
@@ -0,0 +1,29 @@
+# osmo_gsm_tester: bts model specifics
+#
+# 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/>.
+
+from . import log, schema, util
+
+class TestContext(log.Origin):
+ '''
+ API to allow testing various BTS models.
+ '''
+
+ def __init__(self,
+
+# vim: expandtab tabstop=4 shiftwidth=4
diff --git a/src/osmo_gsm_tester/bts_osmotrx.py b/src/osmo_gsm_tester/bts_osmotrx.py
new file mode 100644
index 0000000..5880870
--- /dev/null
+++ b/src/osmo_gsm_tester/bts_osmotrx.py
@@ -0,0 +1,104 @@
+# osmo_gsm_tester: specifics for running a sysmoBTS
+#
+# 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, config, util, template, process
+
+class OsmoBtsTrx(log.Origin):
+ suite_run = None
+ nitb = None
+ run_dir = None
+ processes = None
+ inst = None
+ env = None
+
+ BIN_TRX = 'osmo-trx'
+ BIN_BTS_TRX = 'osmo-bts-trx'
+ BIN_PCU = 'osmo-pcu'
+
+ def __init__(self, suite_run, conf):
+ self.suite_run = suite_run
+ self.conf = conf
+ self.set_name('osmo-bts-trx')
+ self.set_log_category(log.C_RUN)
+ self.processes = {}
+ self.inst = None
+ self.env = {}
+
+ def start(self):
+ if self.nitb is None:
+ raise RuntimeError('BTS needs to be added to a NITB before it can be started')
+ self.suite_run.poll()
+
+ self.log('Starting to connect to', self.nitb)
+ self.run_dir = util.Dir(self.suite_run.trial.get_run_dir().new_dir(self.name()))
+ self.configure()
+
+ self.inst = util.Dir(os.path.abspath(self.suite_run.trial.get_inst('osmo-bts-trx')))
+ self.env = { 'LD_LIBRARY_PATH': str(self.inst) }
+
+ self.launch_process(OsmoBtsTrx.BIN_TRX)
+ self.launch_process(OsmoBtsTrx.BIN_BTS_TRX, '-r', '1', '-c', os.path.abspath(self.config_file))
+ #self.launch_process(OsmoBtsTrx.BIN_PCU, '-r', '1')
+ self.suite_run.poll()
+
+ def launch_process(self, binary_name, *args):
+ if self.processes.get(binary_name) is not None:
+ raise RuntimeError('Attempt to launch twice: %r' % binary_name)
+
+ binary = os.path.abspath(self.inst.child('bin', binary_name))
+ run_dir = self.run_dir.new_dir(binary_name)
+ if not os.path.isfile(binary):
+ raise RuntimeError('Binary missing: %r' % binary)
+ proc = process.Process(binary_name, run_dir,
+ (binary,) + args,
+ env=self.env)
+ self.processes[binary_name] = proc
+ self.suite_run.remember_to_stop(proc)
+ proc.launch()
+
+ def configure(self):
+ if self.nitb is None:
+ raise RuntimeError('BTS needs to be added to a NITB before it can be configured')
+ self.config_file = self.run_dir.new_file('osmo-bts-trx.cfg')
+ self.dbg(config_file=self.config_file)
+
+ values = dict(osmo_bts_trx=config.get_defaults('osmo_bts_trx'))
+ config.overlay(values, self.suite_run.config())
+ config.overlay(values, dict(osmo_bts_trx=dict(oml_remote_ip=self.nitb.addr())))
+ config.overlay(values, dict(osmo_bts_trx=self.conf))
+ self.dbg(conf=values)
+
+ with open(self.config_file, 'w') as f:
+ r = template.render('osmo-bts-trx.cfg', values)
+ self.dbg(r)
+ f.write(r)
+
+ def conf_for_nitb(self):
+ values = config.get_defaults('nitb_bts')
+ config.overlay(values, config.get_defaults('osmo_bts_sysmo'))
+ config.overlay(values, self.conf)
+ config.overlay(values, { 'type': 'sysmobts' })
+ self.dbg(conf=values)
+ return values
+
+ def set_nitb(self, nitb):
+ self.nitb = nitb
+
+# vim: expandtab tabstop=4 shiftwidth=4
diff --git a/src/osmo_gsm_tester/bts_sysmo.py b/src/osmo_gsm_tester/bts_sysmo.py
new file mode 100644
index 0000000..de79f65
--- /dev/null
+++ b/src/osmo_gsm_tester/bts_sysmo.py
@@ -0,0 +1,69 @@
+# osmo_gsm_tester: specifics for running a sysmoBTS
+#
+# 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/>.
+
+from . import log, config, util, template
+
+class SysmoBts(log.Origin):
+ suite_run = None
+ nitb = None
+ run_dir = None
+
+ def __init__(self, suite_run, conf):
+ self.suite_run = suite_run
+ self.conf = conf
+ self.set_name('osmo-bts-sysmo')
+ self.set_log_category(log.C_RUN)
+
+ def start(self):
+ if self.nitb is None:
+ raise RuntimeError('BTS needs to be added to a NITB before it can be started')
+ self.log('Starting sysmoBTS to connect to', self.nitb)
+ self.run_dir = util.Dir(self.suite_run.trial.get_run_dir().new_dir(self.name()))
+ self.configure()
+ self.err('SysmoBts is not yet implemented')
+
+ def configure(self):
+ if self.nitb is None:
+ raise RuntimeError('BTS needs to be added to a NITB before it can be configured')
+ self.config_file = self.run_dir.new_file('osmo-bts-sysmo.cfg')
+ self.dbg(config_file=self.config_file)
+
+ values = { 'osmo_bts_sysmo': config.get_defaults('osmo_bts_sysmo') }
+ config.overlay(values, self.suite_run.config())
+ config.overlay(values, { 'osmo_bts_sysmo': { 'oml_remote_ip': self.nitb.addr() } })
+ config.overlay(values, { 'osmo_bts_sysmo': self.conf })
+ self.dbg(conf=values)
+
+ with open(self.config_file, 'w') as f:
+ r = template.render('osmo-bts-sysmo.cfg', values)
+ self.dbg(r)
+ f.write(r)
+
+ def conf_for_nitb(self):
+ values = config.get_defaults('nitb_bts')
+ config.overlay(values, config.get_defaults('osmo_bts_sysmo'))
+ config.overlay(values, self.conf)
+ config.overlay(values, { 'type': 'sysmobts' })
+ self.dbg(conf=values)
+ return values
+
+ def set_nitb(self, nitb):
+ self.nitb = nitb
+
+# vim: expandtab tabstop=4 shiftwidth=4
diff --git a/src/osmo_gsm_tester/config.py b/src/osmo_gsm_tester/config.py
index 18b209e..0c820c3 100644
--- a/src/osmo_gsm_tester/config.py
+++ b/src/osmo_gsm_tester/config.py
@@ -1,4 +1,4 @@
-# osmo_gsm_tester: read and validate config files
+# osmo_gsm_tester: read and manage config files and global config
#
# Copyright (C) 2016-2017 by sysmocom - s.f.m.c. GmbH
#
@@ -28,35 +28,124 @@
#
# 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.
+# YAML formatting is lean, but:
+# - too powerful. The normal load() allows arbitrary code execution. There is
+# safe_load().
+# - allows several alternative ways of formatting, better to have just one
+# authoritative style.
+# - tries to detect types. It would be better to receive every setting as
+# simple string rather than e.g. an IMSI as an integer.
+# - e.g. an IMSI starting with a zero is interpreted as octal value, resulting
+# in super confusing error messages if the user merely forgets to quote it.
+# - does not tell me which line a config item came from, so no detailed error
+# message is possible.
#
-# The Python ConfigParserShootout page has numerous contestants, but it we want
-# to use widely used, standardized parsing code without re-inventing the wheel.
+# The Python ConfigParserShootout page has numerous contestants, but many of
+# those seem to be not widely used / standardized or even tested.
# 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.
+# There is no solution for octal interpretations nor config file source lines
+# unless, apparently, we implement our own config parser.
import yaml
-import re
import os
-from . import log
+from . import log, schema, util
+from .util import is_dict, is_list, Dir, get_tempdir
+
+ENV_PREFIX = 'OSMO_GSM_TESTER_'
+ENV_CONF = os.getenv(ENV_PREFIX + 'CONF')
+
+DEFAULT_CONFIG_LOCATIONS = [
+ '.',
+ os.path.join(os.getenv('HOME'), '.config', 'osmo_gsm_tester'),
+ '/usr/local/etc/osmo_gsm_tester',
+ '/etc/osmo_gsm_tester'
+ ]
+
+PATHS_CONF = 'paths.conf'
+PATH_STATE_DIR = 'state_dir'
+PATH_SUITES_DIR = 'suites_dir'
+PATH_SCENARIOS_DIR = 'scenarios_dir'
+PATHS_SCHEMA = {
+ PATH_STATE_DIR: schema.STR,
+ PATH_SUITES_DIR: schema.STR,
+ PATH_SCENARIOS_DIR: schema.STR,
+ }
-def read(path, schema=None):
+PATHS_TEMPDIR_STR = '$TEMPDIR'
+
+PATHS = None
+
+def get_config_file(basename, fail_if_missing=True):
+ if ENV_CONF:
+ locations = [ ENV_CONF ]
+ else:
+ locations = DEFAULT_CONFIG_LOCATIONS
+
+ for l in locations:
+ p = os.path.join(l, basename)
+ if os.path.isfile(p):
+ return p
+ if not fail_if_missing:
+ return None
+ raise RuntimeError('configuration file not found: %r in %r' % (basename,
+ [os.path.abspath(p) for p in locations]))
+
+def read_config_file(basename, validation_schema=None, if_missing_return=False):
+ fail_if_missing = True
+ if if_missing_return is not False:
+ fail_if_missing = False
+ path = get_config_file(basename, fail_if_missing=fail_if_missing)
+ return read(path, validation_schema=validation_schema, if_missing_return=if_missing_return)
+
+def get_configured_path(label, allow_unset=False):
+ global PATHS
+
+ env_name = ENV_PREFIX + label.upper()
+ env_path = os.getenv(env_name)
+ if env_path:
+ return env_path
+
+ if PATHS is None:
+ paths_file = get_config_file(PATHS_CONF)
+ PATHS = read(paths_file, PATHS_SCHEMA)
+ p = PATHS.get(label)
+ if p is None and not allow_unset:
+ raise RuntimeError('missing configuration in %s: %r' % (PATHS_CONF, label))
+
+ if p.startswith(PATHS_TEMPDIR_STR):
+ p = os.path.join(get_tempdir(), p[len(PATHS_TEMPDIR_STR):])
+ return p
+
+def get_state_dir():
+ return Dir(get_configured_path(PATH_STATE_DIR))
+
+def get_suites_dir():
+ return Dir(get_configured_path(PATH_SUITES_DIR))
+
+def get_scenarios_dir():
+ return Dir(get_configured_path(PATH_SCENARIOS_DIR))
+
+def read(path, validation_schema=None, if_missing_return=False):
with log.Origin(path):
+ if not os.path.isfile(path) and if_missing_return is not False:
+ return if_missing_return
with open(path, 'r') as f:
config = yaml.safe_load(f)
config = _standardize(config)
- if schema:
- validate(config, schema)
+ if validation_schema:
+ schema.validate(config, validation_schema)
return config
+def write(path, config):
+ with log.Origin(path):
+ with open(path, 'w') as f:
+ f.write(tostr(config))
+
def tostr(config):
return _tostr(_standardize(config))
@@ -74,88 +163,84 @@ 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'):
+def get_defaults(for_kind):
+ defaults = read_config_file('default.conf', if_missing_return={})
+ return defaults.get(for_kind, {})
+
+def get_scenario(name, validation_schema=None):
+ scenarios_dir = get_scenarios_dir()
+ if not name.endswith('.conf'):
+ name = name + '.conf'
+ path = scenarios_dir.child(name)
+ if not os.path.isfile(path):
+ raise RuntimeError('No such scenario file: %r' % path)
+ return read(path, validation_schema=validation_schema)
+
+def add(dest, src):
+ if is_dict(dest):
+ if not is_dict(src):
+ raise ValueError('cannot add to dict a value of type: %r' % type(src))
+
+ for key, val in src.items():
+ dest_val = dest.get(key)
+ if dest_val is None:
+ dest[key] = val
+ else:
+ with log.Origin(key=key):
+ add(dest_val, val)
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)
+ if is_list(dest):
+ if not is_list(src):
+ raise ValueError('cannot add to list a value of type: %r' % type(src))
+ dest.extend(src)
+ return
+ if dest == src:
+ return
+ raise ValueError('cannot add dicts, conflicting items (values %r and %r)'
+ % (dest, src))
+
+def combine(dest, src):
+ if is_dict(dest):
+ if not is_dict(src):
+ raise ValueError('cannot combine dict with a value of type: %r' % type(src))
+
+ for key, val in src.items():
+ dest_val = dest.get(key)
+ if dest_val is None:
+ dest[key] = val
+ else:
+ with log.Origin(key=key):
+ combine(dest_val, val)
+ return
+ if is_list(dest):
+ if not is_list(src):
+ raise ValueError('cannot combine list with a value of type: %r' % type(src))
+ for i in range(len(src)):
+ with log.Origin(idx=i):
+ combine(dest[i], src[i])
+ return
+ if dest == src:
+ return
+ raise ValueError('cannot combine dicts, conflicting items (values %r and %r)'
+ % (dest, src))
+
+def overlay(dest, src):
+ if is_dict(dest):
+ if not is_dict(src):
+ raise ValueError('cannot combine dict with a value of type: %r' % type(src))
+
+ for key, val in src.items():
+ dest_val = dest.get(key)
+ with log.Origin(key=key):
+ dest[key] = overlay(dest_val, val)
+ return dest
+ if is_list(dest):
+ if not is_list(src):
+ raise ValueError('cannot combine list with a value of type: %r' % type(src))
+ for i in range(len(src)):
+ with log.Origin(idx=i):
+ dest[i] = overlay(dest[i], src[i])
+ return dest
+ return src
# vim: expandtab tabstop=4 shiftwidth=4
diff --git a/src/osmo_gsm_tester/log.py b/src/osmo_gsm_tester/log.py
index 27194a9..2ad82aa 100644
--- a/src/osmo_gsm_tester/log.py
+++ b/src/osmo_gsm_tester/log.py
@@ -29,15 +29,25 @@ L_LOG = 20
L_DBG = 10
L_TRACEBACK = 'TRACEBACK'
+LEVEL_STRS = {
+ 'err': L_ERR,
+ 'log': L_LOG,
+ 'dbg': L_DBG,
+ }
+
C_NET = 'net'
C_RUN = 'run'
C_TST = 'tst'
C_CNF = 'cnf'
+C_BUS = 'bus'
C_DEFAULT = '---'
LONG_DATEFMT = '%Y-%m-%d_%H:%M:%S'
DATEFMT = '%H:%M:%S'
+# may be overridden by regression tests
+get_process_id = lambda: '%d-%d' % (os.getpid(), time.time())
+
class LogTarget:
do_log_time = None
do_log_category = None
@@ -47,6 +57,7 @@ class LogTarget:
do_log_src = None
origin_width = None
origin_fmt = None
+ all_levels = None
# redirected by logging test
get_time_str = lambda self: time.strftime(self.log_time_fmt)
@@ -101,10 +112,16 @@ class LogTarget:
'set global logging log.L_* level for a given log.C_* category'
self.category_levels[category] = level
+ def set_all_levels(self, level):
+ self.all_levels = level
+
def is_enabled(self, category, level):
if level == L_TRACEBACK:
return self.do_log_traceback
- is_level = self.category_levels.get(category)
+ if self.all_levels is not None:
+ is_level = self.all_levels
+ else:
+ is_level = self.category_levels.get(category)
if is_level is None:
is_level = L_LOG
if level < is_level:
@@ -128,19 +145,26 @@ class LogTarget:
if self.do_log_category:
log_pre.append(category)
+ deeper_origins = ''
if self.do_log_origin:
if origin is None:
name = '-'
+ elif isinstance(origin, Origins):
+ name = origin[-1]
+ if len(origin) > 1:
+ deeper_origins = str(origin)
elif isinstance(origin, str):
name = origin or None
- elif hasattr(origin, '_name'):
- name = origin._name
+ 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)) )
+ loglevel = '%s: ' % (level_str(level) or ('loglevel=' + str(level)))
+ else:
+ loglevel = ''
log_line = [str(m) for m in messages]
@@ -150,11 +174,15 @@ class LogTarget:
(', '.join(['%s=%r' % (k,v)
for k,v in sorted(named_items.items())])))
+ if deeper_origins:
+ log_line.append(' [%s]' % deeper_origins)
+
if self.do_log_src and src:
log_line.append(' [%s]' % str(src))
- log_str = '%s%s%s' % (' '.join(log_pre),
+ log_str = '%s%s%s%s' % (' '.join(log_pre),
': ' if log_pre else '',
+ loglevel,
' '.join(log_line))
self.log_sink(log_str.strip() + '\n')
@@ -173,6 +201,9 @@ def level_str(level):
def _log_all_targets(origin, category, level, src, messages, named_items=None):
global targets
+
+ if origin is None:
+ origin = Origin._global_current_origin
if isinstance(src, int):
src = get_src_from_caller(src + 1)
for target in targets:
@@ -188,6 +219,20 @@ def get_src_from_tb(tb, levels_up=1):
f = os.path.basename(f)
return '%s:%s: %s' % (f, l, c)
+def get_line_for_src(src_path):
+ etype, exception, tb = sys.exc_info()
+ if tb:
+ ftb = traceback.extract_tb(tb)
+ for f,l,m,c in ftb:
+ if f.endswith(src_path):
+ return l
+
+ for frame in stack():
+ caller = getframeinfo(frame[0])
+ if caller.filename.endswith(src_path):
+ return caller.lineno
+ return None
+
class Origin:
'''
@@ -198,13 +243,14 @@ class Origin:
This will log 'my name' as an origin for the Problem.
'''
+ _global_current_origin = None
+ _global_id = None
+
_log_category = None
_src = None
_name = None
- _log_line_buf = None
- _prev_stdout = None
+ _origin_id = None
- _global_current_origin = None
_parent_origin = None
def __init__(self, *name_items, category=None, **detail_items):
@@ -226,7 +272,17 @@ class Origin:
self._name = name + details
def name(self):
- return self._name
+ return self._name or self.__class__.__name__
+
+ __str__ = name
+ __repr__ = name
+
+ def origin_id(self):
+ if not self._origin_id:
+ if not Origin._global_id:
+ Origin._global_id = get_process_id()
+ self._origin_id = '%s-%s' % (self.name(), Origin._global_id)
+ return self._origin_id
def set_log_category(self, category):
self._log_category = category
@@ -249,11 +305,9 @@ class Origin:
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:
+ if not self.set_child_of(Origin._global_current_origin):
return
- self._parent_origin, Origin._global_current_origin = Origin._global_current_origin, self
+ Origin._global_current_origin = self
def __exit__(self, *exc_info):
rc = None
@@ -263,10 +317,54 @@ class Origin:
return rc
def redirect_stdout(self):
- return contextlib.redirect_stdout(self)
+ return contextlib.redirect_stdout(SafeRedirectStdout(self))
+
+ def gather_origins(self):
+ origins = Origins()
+ origins.add(self)
+ origin = self._parent_origin
+ if origin is None and Origin._global_current_origin is not None:
+ origin = Origin._global_current_origin
+ while origin is not None:
+ origins.add(origin)
+ origin = origin._parent_origin
+ return origins
+
+ def set_child_of(self, parent_origin):
+ # avoid loops
+ if self._parent_origin is not None:
+ return False
+ if parent_origin == self:
+ return False
+ self._parent_origin = parent_origin
+ return True
+
+class LineInfo(Origin):
+ def __init__(self, src_file, *name_items, **detail_items):
+ self.src_file = src_file
+ self.set_name(*name_items, **detail_items)
+
+ def name(self):
+ l = get_line_for_src(self.src_file)
+ if l is not None:
+ return '%s:%s' % (self._name, l)
+ return super().name()
+
+class SafeRedirectStdout:
+ '''
+ To be able to use 'print' in test scripts, this is used to redirect stdout
+ to a test class' log() function. However, it turns out doing that breaks
+ python debugger sessions -- it uses extended features of stdout, and will
+ fail dismally if it finds this wrapper in sys.stdout. Luckily, overriding
+ __getattr__() to return the original sys.__stdout__ attributes for anything
+ else than write() makes the debugger session work nicely again!
+ '''
+ _log_line_buf = None
+
+ def __init__(self, origin):
+ self._origin = origin
def write(self, message):
- 'to redirect stdout to the log'
lines = message.splitlines()
if not lines:
return
@@ -276,21 +374,12 @@ class Origin:
if not message.endswith('\n'):
self._log_line_buf = lines[-1]
lines = lines[:-1]
- origins = self.gather_origins()
+ origins = self._origin.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)
+ self._origin._log(L_LOG, (line,), origins=origins)
+ def __getattr__(self, name):
+ return sys.__stdout__.__getattribute__(name)
def dbg(origin, category, *messages, **named_items):
@@ -337,7 +426,7 @@ def log_exn(origin=None, category=None, exc_info=None):
# if there are origins recorded with the Exception, prefer that
if hasattr(exception, 'origins'):
- origin = str(exception.origins)
+ origin = exception.origins
# if there is a category recorded with the Exception, prefer that
if hasattr(exception, 'category'):
@@ -363,15 +452,22 @@ class Origins(list):
if origin is not None:
self.add(origin)
def add(self, origin):
- if hasattr(origin, '_name'):
- origin_str = origin._name
+ if hasattr(origin, 'name'):
+ origin_str = origin.name()
else:
- origin_str = str(origin)
+ origin_str = repr(origin)
+ if origin_str is None:
+ raise RuntimeError('origin_str is None for %r' % origin)
self.insert(0, origin_str)
def __str__(self):
- return '->'.join(self)
+ return '↪'.join(self)
+
+def set_all_levels(level):
+ global targets
+ for target in targets:
+ target.set_all_levels(level)
def set_level(category, level):
global targets
diff --git a/src/osmo_gsm_tester/ofono_client.py b/src/osmo_gsm_tester/ofono_client.py
new file mode 100644
index 0000000..622a18f
--- /dev/null
+++ b/src/osmo_gsm_tester/ofono_client.py
@@ -0,0 +1,117 @@
+# osmo_gsm_tester: DBUS client to talk to ofono
+#
+# 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/>.
+
+from . import log
+
+from pydbus import SystemBus, Variant
+import time
+import pprint
+
+from gi.repository import GLib
+glib_main_loop = GLib.MainLoop()
+glib_main_ctx = glib_main_loop.get_context()
+bus = SystemBus()
+
+def poll():
+ global glib_main_ctx
+ while glib_main_ctx.pending():
+ glib_main_ctx.iteration()
+
+def get(path):
+ global bus
+ return bus.get('org.ofono', path)
+
+def list_modems():
+ root = get('/')
+ return sorted(root.GetModems())
+
+
+class Modem(log.Origin):
+ 'convenience for ofono Modem interaction'
+ msisdn = None
+
+ def __init__(self, conf):
+ self.conf = conf
+ self.path = conf.get('path')
+ self.set_name(self.path)
+ self.set_log_category(log.C_BUS)
+ self._dbus_obj = None
+ self._interfaces_was = set()
+ poll()
+
+ def set_msisdn(self, msisdn):
+ self.msisdn = msisdn
+
+ def imsi(self):
+ return self.conf.get('imsi')
+
+ def ki(self):
+ return self.conf.get('ki')
+
+ def set_powered(self, on=True):
+ self.dbus_obj.SetProperty('Powered', Variant('b', on))
+
+ def dbus_obj(self):
+ if self._dbus_obj is not None:
+ return self._dbus_obj
+ self._dbus_obj = get(self.path)
+ self._dbus_obj.PropertyChanged.connect(self._on_property_change)
+ self._on_interfaces_change(self.properties().get('Interfaces'))
+
+ def properties(self):
+ return self.dbus_obj().GetProperties()
+
+ def _on_property_change(self, name, value):
+ if name == 'Interfaces':
+ self._on_interfaces_change(value)
+
+ def _on_interfaces_change(self, interfaces_now):
+ now = set(interfaces_now)
+ additions = now - self._interfaces_was
+ removals = self._interfaces_was - now
+ self._interfaces_was = now
+ for iface in removals:
+ with log.Origin('modem.disable(%s)' % iface):
+ try:
+ self._on_interface_disabled(iface)
+ except:
+ self.log_exn()
+ for iface in additions:
+ with log.Origin('modem.enable(%s)' % iface):
+ try:
+ self._on_interface_enabled(iface)
+ except:
+ self.log_exn()
+
+ def _on_interface_enabled(self, interface_name):
+ self.dbg('Interface enabled:', interface_name)
+ # todo: when the messages service comes up, connect a message reception signal
+
+ def _on_interface_disabled(self, interface_name):
+ self.dbg('Interface disabled:', interface_name)
+
+ def connect(self, nitb):
+ 'set the modem up to connect to MCC+MNC from NITB config'
+ self.log('connect to', nitb)
+
+ def sms_send(self, msisdn):
+ self.log('send sms to MSISDN', msisdn)
+ return 'todo'
+
+# vim: expandtab tabstop=4 shiftwidth=4
diff --git a/src/osmo_gsm_tester/osmo_ctrl.py b/src/osmo_gsm_tester/osmo_ctrl.py
new file mode 100644
index 0000000..c3a09db
--- /dev/null
+++ b/src/osmo_gsm_tester/osmo_ctrl.py
@@ -0,0 +1,88 @@
+
+# osmo_gsm_tester: specifics for running a sysmoBTS
+#
+# 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 socket
+import struct
+
+from . import log
+
+class CtrlInterfaceExn(Exception):
+ pass
+
+class OsmoCtrl(log.Origin):
+
+ def __init__(self, host, port):
+ self.set_name('Ctrl', host=host, port=port)
+ self.set_log_category(log.C_BUS)
+ self.host = host
+ self.port = port
+ self.sck = None
+
+ def prefix_ipa_ctrl_header(self, data):
+ if isinstance(data, str):
+ data = data.encode('utf-8')
+ s = struct.pack(">HBB", len(data)+1, 0xee, 0)
+ return s + data
+
+ def remove_ipa_ctrl_header(self, data):
+ if (len(data) < 4):
+ raise CtrlInterfaceExn("Answer too short!")
+ (plen, ipa_proto, osmo_proto) = struct.unpack(">HBB", data[:4])
+ if (plen + 3 > len(data)):
+ self.err('Warning: Wrong payload length', expected=plen, got=len(data)-3)
+ if (ipa_proto != 0xee or osmo_proto != 0):
+ raise CtrlInterfaceExn("Wrong protocol in answer!")
+ return data[4:plen+3], data[plen+3:]
+
+ def connect(self):
+ self.dbg('Connecting')
+ self.sck = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ self.sck.connect((self.host, self.port))
+ self.sck.setblocking(1)
+
+ def disconnect(self):
+ self.dbg('Disconnecting')
+ if self.sck is not None:
+ self.sck.close()
+
+ def _send(self, data):
+ self.dbg('Sending', data=data)
+ data = self.prefix_ipa_ctrl_header(data)
+ self.sck.send(data)
+
+ def receive(self, length = 1024):
+ return self.sck.recv(length)
+
+ def do_set(self, var, value, id=0):
+ setmsg = "SET %s %s %s" %(id, var, value)
+ self._send(setmsg)
+
+ def do_get(self, var, id=0):
+ getmsg = "GET %s %s" %(id, var)
+ self._send(getmsg)
+
+ def __enter__(self):
+ self.connect()
+ return self
+
+ def __exit__(self, *exc_info):
+ self.disconnect()
+
+# vim: expandtab tabstop=4 shiftwidth=4
diff --git a/src/osmo_gsm_tester/osmo_nitb.py b/src/osmo_gsm_tester/osmo_nitb.py
new file mode 100644
index 0000000..3d5fc6a
--- /dev/null
+++ b/src/osmo_gsm_tester/osmo_nitb.py
@@ -0,0 +1,155 @@
+# osmo_gsm_tester: specifics for running an osmo-nitb
+#
+# 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 random
+import re
+import socket
+
+from . import log, util, config, template, process, osmo_ctrl
+
+class OsmoNitb(log.Origin):
+ suite_run = None
+ nitb_iface = None
+ run_dir = None
+ config_file = None
+ process = None
+ bts = None
+
+ def __init__(self, suite_run, nitb_iface):
+ self.suite_run = suite_run
+ self.nitb_iface = nitb_iface
+ self.set_log_category(log.C_RUN)
+ self.set_name('osmo-nitb_%s' % nitb_iface.get('addr'))
+ self.bts = []
+
+ def start(self):
+ self.log('Starting osmo-nitb')
+ self.run_dir = util.Dir(self.suite_run.trial.get_run_dir().new_dir(self.name()))
+ self.configure()
+ inst = util.Dir(self.suite_run.trial.get_inst('openbsc'))
+ binary = os.path.abspath(inst.child('bin', 'osmo-nitb'))
+ if not os.path.isfile(binary):
+ raise RuntimeError('Binary missing: %r' % binary)
+ env = { 'LD_LIBRARY_PATH': os.path.abspath(str(inst)) }
+ self.dbg(run_dir=self.run_dir, binary=binary, env=env)
+ self.process = process.Process(self.name(), self.run_dir,
+ (binary, '-c',
+ os.path.abspath(self.config_file)),
+ env=env)
+ self.suite_run.remember_to_stop(self.process)
+ self.process.launch()
+
+ def configure(self):
+ self.config_file = self.run_dir.new_file('osmo-nitb.cfg')
+ self.dbg(config_file=self.config_file)
+
+ values = dict(nitb=config.get_defaults('nitb'))
+ config.overlay(values, self.suite_run.config())
+ config.overlay(values, dict(nitb_iface=self.nitb_iface))
+
+ bts_list = []
+ for bts in self.bts:
+ bts_list.append(bts.conf_for_nitb())
+ config.overlay(values, dict(nitb=dict(net=dict(bts_list=bts_list))))
+
+ self.dbg(conf=values)
+
+ with open(self.config_file, 'w') as f:
+ r = template.render('osmo-nitb.cfg', values)
+ self.dbg(r)
+ f.write(r)
+
+ def addr(self):
+ return self.nitb_iface.get('addr')
+
+ def add_bts(self, bts):
+ self.bts.append(bts)
+ bts.set_nitb(self)
+
+ def add_subscriber(self, modem, msisdn=None):
+ if msisdn is None:
+ msisdn = self.suite_run.resources_pool.next_msisdn(modem)
+ modem.set_msisdn(msisdn)
+ self.log('Add subscriber', msisdn=msisdn, imsi=modem.imsi())
+ with self:
+ OsmoNitbCtrl(self).add_subscriber(modem.imsi(), msisdn, modem.ki())
+
+ def subscriber_attached(self, *modems):
+ return all([self.imsi_attached(m.imsi()) for m in modems])
+
+ def imsi_attached(self, imsi):
+ return random.choice((True, False))
+
+ def sms_received(self, sms):
+ return random.choice((True, False))
+
+ def running(self):
+ return not self.process.terminated()
+
+
+class OsmoNitbCtrl(log.Origin):
+ PORT = 4249
+ SUBSCR_MODIFY_VAR = 'subscriber-modify-v1'
+ SUBSCR_MODIFY_REPLY_RE = re.compile("SET_REPLY (\d+) %s OK" % SUBSCR_MODIFY_VAR)
+ SUBSCR_LIST_ACTIVE_VAR = 'subscriber-list-active-v1'
+
+ def __init__(self, nitb):
+ self.nitb = nitb
+ self.set_name('CTRL(%s:%d)' % (self.nitb.addr(), OsmoNitbCtrl.PORT))
+ self.set_child_of(nitb)
+
+ def ctrl(self):
+ return osmo_ctrl.OsmoCtrl(self.nitb.addr(), OsmoNitbCtrl.PORT)
+
+ def add_subscriber(self, imsi, msisdn, ki=None, algo=None):
+ created = False
+ if ki and not algo:
+ algo = 'comp128v1'
+
+ if algo:
+ value = '%s,%s,%s,%s' % (imsi,msisdn,algo,ki)
+ else:
+ value = '%s,%s' % (imsi, msisdn)
+
+ with osmo_ctrl.OsmoCtrl(self.nitb.addr(), OsmoNitbCtrl.PORT) as ctrl:
+ ctrl.do_set(OsmoNitbCtrl.SUBSCR_MODIFY_VAR, value)
+ data = ctrl.receive()
+ (answer, data) = ctrl.remove_ipa_ctrl_header(data)
+ answer_str = answer.decode('utf-8')
+ res = OsmoNitbCtrl.SUBSCR_MODIFY_REPLY_RE.match(answer_str)
+ if not res:
+ raise RuntimeError('Cannot create subscriber %r (answer=%r)' % (imsi, answer_str))
+ self.dbg('Created subscriber', imsi=imsi, msisdn=msisdn)
+ return True
+
+ def subscriber_list_active(self):
+ var = 'subscriber-list-active-v1'
+ aslist_str = ""
+ with osmo_ctrl.OsmoCtrl(self.nitb.addr(), OsmoNitbCtrl.PORT) as ctrl:
+ self.ctrl.do_get(OsmoNitbCtrl.SUBSCR_LIST_ACTIVE_VAR)
+ # this looks like it doesn't work for long data. It's legacy code from the old osmo-gsm-tester.
+ data = self.ctrl.receive()
+ while (len(data) > 0):
+ (answer, data) = self.ctrl.remove_ipa_ctrl_header(data)
+ answer = answer.replace('\n', ' ')
+ aslist_str = answer
+ return aslist_str
+
+# vim: expandtab tabstop=4 shiftwidth=4
diff --git a/src/osmo_gsm_tester/process.py b/src/osmo_gsm_tester/process.py
index 2e0ff52..4cf1b8d 100644
--- a/src/osmo_gsm_tester/process.py
+++ b/src/osmo_gsm_tester/process.py
@@ -17,7 +17,190 @@
# 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 time
+import subprocess
+import signal
+from . import log
+from .util import Dir
+class Process(log.Origin):
+
+ process_obj = None
+ outputs = None
+ result = None
+ killed = None
+
+ def __init__(self, name, run_dir, popen_args, **popen_kwargs):
+ self.name_str = name
+ self.set_name(name)
+ self.set_log_category(log.C_RUN)
+ self.run_dir = run_dir
+ self.popen_args = popen_args
+ self.popen_kwargs = popen_kwargs
+ self.outputs = {}
+ if not isinstance(self.run_dir, Dir):
+ self.run_dir = Dir(os.path.abspath(str(self.run_dir)))
+
+ def set_env(self, key, value):
+ env = self.popen_kwargs.get('env') or {}
+ env[key] = value
+ self.popen_kwargs['env'] = env
+
+ def make_output_log(self, name):
+ '''
+ create a non-existing log output file in run_dir to pipe stdout and
+ stderr from this process to.
+ '''
+ path = self.run_dir.new_child(name)
+ f = open(path, 'w')
+ self.dbg(path)
+ f.write('(launched: %s)\n' % time.strftime(log.LONG_DATEFMT))
+ f.flush()
+ self.outputs[name] = (path, f)
+ return f
+
+ def launch(self):
+ with self:
+
+ self.dbg('cd %r; %s %s' % (
+ os.path.abspath(str(self.run_dir)),
+ ' '.join(['%s=%r'%(k,v) for k,v in self.popen_kwargs.get('env', {}).items()]),
+ ' '.join(self.popen_args)))
+
+ self.process_obj = subprocess.Popen(
+ self.popen_args,
+ stdout=self.make_output_log('stdout'),
+ stderr=self.make_output_log('stderr'),
+ shell=False,
+ cwd=self.run_dir.path,
+ **self.popen_kwargs)
+ self.set_name(self.name_str, pid=self.process_obj.pid)
+ self.log('Launched')
+
+ def _poll_termination(self, time_to_wait_for_term=5):
+ wait_step = 0.001
+ waited_time = 0
+ while True:
+ # poll returns None if proc is still running
+ self.result = self.process_obj.poll()
+ if self.result is not None:
+ return True
+ waited_time += wait_step
+ # make wait_step approach 1.0
+ wait_step = (1. + 5. * wait_step) / 6.
+ if waited_time >= time_to_wait_for_term:
+ break
+ time.sleep(wait_step)
+ return False
+
+ def terminate(self):
+ if self.process_obj is None:
+ return
+ if self.result is not None:
+ return
+
+ while True:
+ # first try SIGINT to allow stdout+stderr flushing
+ self.log('Terminating (SIGINT)')
+ os.kill(self.process_obj.pid, signal.SIGINT)
+ self.killed = signal.SIGINT
+ if self._poll_termination():
+ break
+
+ # SIGTERM maybe?
+ self.log('Terminating (SIGTERM)')
+ self.process_obj.terminate()
+ self.killed = signal.SIGTERM
+ if self._poll_termination():
+ break
+
+ # out of patience
+ self.log('Terminating (SIGKILL)')
+ self.process_obj.kill()
+ self.killed = signal.SIGKILL
+ break;
+
+ self.process_obj.wait()
+ self.cleanup()
+
+ def cleanup(self):
+ self.close_output_logs()
+ if self.result == 0:
+ self.log('Terminated: ok', rc=self.result)
+ elif self.killed:
+ self.log('Terminated', rc=self.result)
+ else:
+ self.err('Terminated: ERROR', rc=self.result)
+ #self.err('stdout:\n', self.get_stdout_tail(prefix='| '), '\n')
+ self.err('stderr:\n', self.get_stderr_tail(prefix='| '), '\n')
+
+ def close_output_logs(self):
+ self.dbg('Cleanup')
+ for k, v in self.outputs.items():
+ path, f = v
+ if f:
+ f.flush()
+ f.close()
+ self.outputs[k] = (path, None)
+
+ def poll(self):
+ if self.process_obj is None:
+ return
+ if self.result is not None:
+ return
+ self.result = self.process_obj.poll()
+ if self.result is not None:
+ self.cleanup()
+
+ def get_output(self, which):
+ v = self.outputs.get(which)
+ if not v:
+ return None
+ path, f = v
+ with open(path, 'r') as f2:
+ return f2.read()
+
+ def get_output_tail(self, which, tail=10, prefix=''):
+ out = self.get_output(which).splitlines()
+ tail = min(len(out), tail)
+ return ('\n' + prefix).join(out[-tail:])
+
+ def get_stdout(self):
+ return self.get_output('stdout')
+
+ def get_stderr(self):
+ return self.get_output('stderr')
+
+ def get_stdout_tail(self, tail=10, prefix=''):
+ return self.get_output_tail('stdout', tail, prefix)
+
+ def get_stderr_tail(self, tail=10, prefix=''):
+ return self.get_output_tail('stderr', tail, prefix)
+
+ def terminated(self):
+ self.poll()
+ return self.result is not None
+
+ def wait(self):
+ self.process_obj.wait()
+ self.poll()
+
+
+class RemoteProcess(Process):
+
+ def __init__(self, remote_host, remote_cwd, *process_args, **process_kwargs):
+ super().__init__(*process_args, **process_kwargs)
+ self.remote_host = remote_host
+ self.remote_cwd = remote_cwd
+
+ # hacky: instead of just prepending ssh, i.e. piping stdout and stderr
+ # over the ssh link, we should probably run on the remote side,
+ # monitoring the process remotely.
+ self.popen_args = ['ssh', '-t', self.remote_host,
+ 'cd "%s"; %s' % (self.remote_cwd,
+ ' '.join(['"%s"' % arg for arg in self.popen_args]))]
+ self.dbg(self.popen_args, dir=self.run_dir, conf=self.popen_kwargs)
# vim: expandtab tabstop=4 shiftwidth=4
diff --git a/src/osmo_gsm_tester/resource.py b/src/osmo_gsm_tester/resource.py
index bebc82d..dc8435e 100644
--- a/src/osmo_gsm_tester/resource.py
+++ b/src/osmo_gsm_tester/resource.py
@@ -18,34 +18,443 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import os
+import time
+import copy
+import atexit
+import pprint
from . import log
from . import config
-from .utils import listdict, FileLock
+from . import util
+from . import schema
+from . import ofono_client
+from . import osmo_nitb
+from . import bts_sysmo, bts_osmotrx
-class Resources(log.Origin):
+from .util import is_dict, is_list
- 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)
+HASH_KEY = '_hash'
+RESERVED_KEY = '_reserved_by'
+USED_KEY = '_used'
- def ensure_lock_dir_exists(self):
- if not os.path.isdir(self.lock_dir):
- os.makedirs(self.lock_dir)
+RESOURCES_CONF = 'resources.conf'
+LAST_USED_MSISDN_FILE = 'last_used_msisdn.state'
+RESERVED_RESOURCES_FILE = 'reserved_resources.state'
+R_NITB_IFACE = 'nitb_iface'
+R_BTS = 'bts'
+R_ARFCN = 'arfcn'
+R_MODEM = 'modem'
+R_ALL = (R_NITB_IFACE, R_BTS, R_ARFCN, R_MODEM)
-global_resources = listdict()
+RESOURCES_SCHEMA = {
+ 'nitb_iface[].addr': schema.IPV4,
+ 'bts[].label': schema.STR,
+ 'bts[].type': schema.STR,
+ 'bts[].unit_id': schema.INT,
+ 'bts[].addr': schema.IPV4,
+ 'bts[].band': schema.BAND,
+ 'bts[].trx[].hwaddr': schema.HWADDR,
+ 'arfcn[].arfcn': schema.INT,
+ 'arfcn[].band': schema.BAND,
+ 'modem[].label': schema.STR,
+ 'modem[].path': schema.STR,
+ 'modem[].imsi': schema.IMSI,
+ 'modem[].ki': schema.KI,
+ }
-def register(kind, instance):
- global global_resources
- global_resources.add(kind, instance)
+WANT_SCHEMA = util.dict_add(
+ dict([('%s[].times' % r, schema.INT) for r in R_ALL]),
+ RESOURCES_SCHEMA)
-def reserve(user, config):
- asdf
+KNOWN_BTS_TYPES = {
+ 'sysmo': bts_sysmo.SysmoBts,
+ 'osmotrx': bts_osmotrx.OsmoBtsTrx,
+ }
+
+def register_bts_type(name, clazz):
+ KNOWN_BTS_TYPES[name] = clazz
+
+class ResourcesPool(log.Origin):
+ _remember_to_free = None
+ _registered_exit_handler = False
+
+ def __init__(self):
+ self.config_path = config.get_config_file(RESOURCES_CONF)
+ self.state_dir = config.get_state_dir()
+ self.set_name(conf=self.config_path, state=self.state_dir.path)
+ self.read_conf()
+
+ def read_conf(self):
+ self.all_resources = Resources(config.read(self.config_path, RESOURCES_SCHEMA))
+ self.all_resources.set_hashes()
+
+ def reserve(self, origin, want):
+ '''
+ attempt to reserve the resources specified in the dict 'want' for
+ 'origin'. Obtain a lock on the resources lock dir, verify that all
+ wanted resources are available, and if yes mark them as reserved.
+
+ On success, return a reservation object which can be used to release
+ the reservation. The reservation will be freed automatically on program
+ exit, if not yet done manually.
+
+ 'origin' should be an Origin() instance.
+
+ 'want' is a dict matching WANT_SCHEMA, which is the same as
+ the RESOURCES_SCHEMA, except each entity that can be reserved has a 'times'
+ field added, to indicate how many of those should be reserved.
+
+ If an entry has only a 'times' set, any of the resources may be
+ reserved without further limitations.
+
+ ResourcesPool may also be selected with narrowed down constraints.
+ This would reserve one NITB IP address, two modems, one BTS of type
+ sysmo and one of type oct, plus 2 ARFCNs in the 1800 band:
+
+ {
+ 'nitb_iface': [ { 'times': 1 } ],
+ 'bts': [ { 'type': 'sysmo', 'times': 1 }, { 'type': 'oct', 'times': 1 } ],
+ 'arfcn': [ { 'band': 'GSM-1800', 'times': 2 } ],
+ 'modem': [ { 'times': 2 } ],
+ }
+
+ A times=1 value is implicit, so the above is equivalent to:
+
+ {
+ 'nitb_iface': [ {} ],
+ 'bts': [ { 'type': 'sysmo' }, { 'type': 'oct' } ],
+ 'arfcn': [ { 'band': 'GSM-1800', 'times': 2 } ],
+ 'modem': [ { 'times': 2 } ],
+ }
+ '''
+ schema.validate(want, WANT_SCHEMA)
+
+ # replicate items that have a 'times' > 1
+ want = copy.deepcopy(want)
+ for key, item_list in want.items():
+ more_items = []
+ for item in item_list:
+ times = int(item.pop('times'))
+ if times and times > 1:
+ for i in range(times - 1):
+ more_items.append(copy.deepcopy(item))
+ item_list.extend(more_items)
+
+ origin_id = origin.origin_id()
+
+ with self.state_dir.lock(origin_id):
+ rrfile_path = self.state_dir.mk_parentdir(RESERVED_RESOURCES_FILE)
+ reserved = Resources(config.read(rrfile_path, if_missing_return={}))
+ to_be_reserved = self.all_resources.without(reserved).find(want)
+
+ to_be_reserved.mark_reserved_by(origin_id)
+
+ reserved.add(to_be_reserved)
+ config.write(rrfile_path, reserved)
+
+ self.remember_to_free(to_be_reserved)
+ return ReservedResources(self, origin, to_be_reserved)
+
+ def free(self, origin, to_be_freed):
+ with self.state_dir.lock(origin.origin_id()):
+ rrfile_path = self.state_dir.mk_parentdir(RESERVED_RESOURCES_FILE)
+ reserved = Resources(config.read(rrfile_path, if_missing_return={}))
+ reserved.drop(to_be_freed)
+ config.write(rrfile_path, reserved)
+ self.forget_freed(to_be_freed)
+
+ def register_exit_handler(self):
+ if self._registered_exit_handler:
+ return
+ atexit.register(self.clean_up_registered_resources)
+ self._registered_exit_handler = True
+
+ def unregister_exit_handler(self):
+ if not self._registered_exit_handler:
+ return
+ atexit.unregister(self.clean_up_registered_resources)
+ self._registered_exit_handler = False
+
+ def clean_up_registered_resources(self):
+ if not self._remember_to_free:
+ return
+ self.free(log.Origin('atexit.clean_up_registered_resources()'),
+ self._remember_to_free)
+
+ def remember_to_free(self, to_be_reserved):
+ self.register_exit_handler()
+ if not self._remember_to_free:
+ self._remember_to_free = Resources()
+ self._remember_to_free.add(to_be_reserved)
+
+ def forget_freed(self, freed):
+ if freed is self._remember_to_free:
+ self._remember_to_free.clear()
+ else:
+ self._remember_to_free.drop(freed)
+ if not self._remember_to_free:
+ self.unregister_exit_handler()
+
+ def next_msisdn(self, origin):
+ origin_id = origin.origin_id()
+
+ with self.state_dir.lock(origin_id):
+ msisdn_path = self.state_dir.child(LAST_USED_MSISDN_FILE)
+ with log.Origin(msisdn_path):
+ last_msisdn = '1'
+ if os.path.exists(msisdn_path):
+ if not os.path.isfile(msisdn_path):
+ raise RuntimeError('path should be a file but is not: %r' % msisdn_path)
+ with open(msisdn_path, 'r') as f:
+ last_msisdn = f.read().strip()
+ schema.msisdn(last_msisdn)
+
+ next_msisdn = util.msisdn_inc(last_msisdn)
+ with open(msisdn_path, 'w') as f:
+ f.write(next_msisdn)
+ return next_msisdn
+
+
+class NoResourceExn(Exception):
+ pass
+
+class Resources(dict):
+
+ def __init__(self, all_resources={}, do_copy=True):
+ if do_copy:
+ all_resources = copy.deepcopy(all_resources)
+ self.update(all_resources)
+
+ def drop(self, reserved, fail_if_not_found=True):
+ # protect from modifying reserved because we're the same object
+ if reserved is self:
+ raise RuntimeError('Refusing to drop a list of resources from itself.'
+ ' This is probably a bug where a list of Resources()'
+ ' should have been copied but is passed as-is.'
+ ' use Resources.clear() instead.')
+
+ for key, reserved_list in reserved.items():
+ my_list = self.get(key) or []
+
+ if my_list is reserved_list:
+ self.pop(key)
+ continue
+
+ for reserved_item in reserved_list:
+ found = False
+ reserved_hash = reserved_item.get(HASH_KEY)
+ if not reserved_hash:
+ raise RuntimeError('Resources.drop() only works with hashed items')
+
+ for i in range(len(my_list)):
+ my_item = my_list[i]
+ my_hash = my_item.get(HASH_KEY)
+ if not my_hash:
+ raise RuntimeError('Resources.drop() only works with hashed items')
+ if my_hash == reserved_hash:
+ found = True
+ my_list.pop(i)
+ break
+
+ if fail_if_not_found and not found:
+ raise RuntimeError('Asked to drop resource from a pool, but the'
+ ' resource was not found: %s = %r' % (key, reserved_item))
+
+ if not my_list:
+ self.pop(key)
+ return self
+
+ def without(self, reserved):
+ return Resources(self).drop(reserved)
+
+ def find(self, want, skip_if_marked=None, do_copy=True):
+ matches = {}
+ for key, want_list in want.items():
+ with log.Origin(want=key):
+ my_list = self.get(key)
+
+ log.dbg(None, None, 'Looking for', len(want_list), 'x', key, ', candidates:', len(my_list))
+
+ # Try to avoid a less constrained item snatching away a resource
+ # from a more detailed constrained requirement.
+
+ # first record all matches
+ all_matches = []
+ for want_item in want_list:
+ item_match_list = []
+ for i in range(len(my_list)):
+ my_item = my_list[i]
+ if skip_if_marked and my_item.get(skip_if_marked):
+ continue
+ if item_matches(my_item, want_item, ignore_keys=('times',)):
+ item_match_list.append(i)
+ if not item_match_list:
+ raise NoResourceExn('No matching resource available for %s = %r'
+ % (key, want_item))
+ all_matches.append( item_match_list )
+
+ if not all_matches:
+ raise NoResourceExn('No matching resource available for %s = %r'
+ % (key, want_list))
+
+ # figure out who gets what
+ solution = solve(all_matches)
+ picked = [ my_list[i] for i in solution if i is not None ]
+ log.dbg(None, None, 'Picked', pprint.pformat(picked))
+ matches[key] = picked
+
+ return Resources(matches, do_copy=do_copy)
+
+ def set_hashes(self):
+ for key, item_list in self.items():
+ for item in item_list:
+ item[HASH_KEY] = util.hash_obj(item, HASH_KEY, RESERVED_KEY, USED_KEY)
+
+ def add(self, more):
+ if more is self:
+ raise RuntimeError('adding a list of resources to itself?')
+ config.add(self, copy.deepcopy(more))
+
+ def combine(self, more_rules):
+ if more_rules is self:
+ raise RuntimeError('combining a list of resource rules with itself?')
+ config.combine(self, copy.deepcopy(more))
+
+ def mark_reserved_by(self, origin_id):
+ for key, item_list in self.items():
+ for item in item_list:
+ item[RESERVED_KEY] = origin_id
+
+
+def solve(all_matches):
+ '''
+ all_matches shall be a list of index-lists.
+ all_matches[i] is the list of indexes that item i can use.
+ Return a solution so that each i gets a different index.
+ solve([ [0, 1, 2],
+ [0],
+ [0, 2] ]) == [1, 0, 2]
+ '''
+
+ def all_differ(l):
+ return len(set(l)) == len(l)
+
+ def search_in_permutations(fixed=[]):
+ idx = len(fixed)
+ for i in range(len(all_matches[idx])):
+ val = all_matches[idx][i]
+ # don't add a val that's already in the list
+ if val in fixed:
+ continue
+ l = list(fixed)
+ l.append(val)
+ if len(l) == len(all_matches):
+ # found a solution
+ return l
+ # not at the end yet, add next digit
+ r = search_in_permutations(l)
+ if r:
+ # nested search_in_permutations() call found a solution
+ return r
+ # this entire branch yielded no solution
+ return None
+
+ if not all_matches:
+ raise RuntimeError('Cannot solve: no candidates')
+
+ solution = search_in_permutations()
+ if not solution:
+ raise NoResourceExn('The requested resource requirements are not solvable %r'
+ % all_matches)
+ return solution
+
+
+def contains_hash(list_of_dicts, a_hash):
+ for d in list_of_dicts:
+ if d.get(HASH_KEY) == a_hash:
+ return True
+ return False
+
+def item_matches(item, wanted_item, ignore_keys=None):
+ if is_dict(wanted_item):
+ # match up two dicts
+ if not isinstance(item, dict):
+ return False
+ for key, wanted_val in wanted_item.items():
+ if ignore_keys and key in ignore_keys:
+ continue
+ if not item_matches(item.get(key), wanted_val, ignore_keys=ignore_keys):
+ return False
+ return True
+
+ if is_list(wanted_item):
+ # multiple possible values
+ if item not in wanted_item:
+ return False
+ return True
+
+ return item == wanted_item
+
+
+class ReservedResources(log.Origin):
+ '''
+ After all resources have been figured out, this is the API that a test case
+ gets to interact with resources. From those resources that have been
+ reserved for it, it can pick some to mark them as currently in use.
+ Functions like nitb() provide a resource by automatically picking its
+ dependencies from so far unused (but reserved) resource.
+ '''
+
+ def __init__(self, resources_pool, origin, reserved):
+ self.resources_pool = resources_pool
+ self.origin = origin
+ self.reserved = reserved
+
+ def __repr__(self):
+ return 'resources(%s)=%s' % (self.origin.name(), pprint.pformat(self.reserved))
+
+ def get(self, kind, specifics=None):
+ if specifics is None:
+ specifics = {}
+ self.dbg('requesting use of', kind, specifics=specifics)
+ want = { kind: [specifics] }
+ available_dict = self.reserved.find(want, skip_if_marked=USED_KEY, do_copy=False)
+ available = available_dict.get(kind)
+ self.dbg(available=len(available))
+ if not available:
+ raise NoResourceExn('No unused resource found: %r%s' %
+ (kind,
+ (' matching %r' % specifics) if specifics else '')
+ )
+ pick = available[0]
+ self.dbg(using=pick)
+ assert not pick.get(USED_KEY)
+ pick[USED_KEY] = True
+ return copy.deepcopy(pick)
+
+ def put(self, item):
+ if not item.get(USED_KEY):
+ raise RuntimeError('Can only put() a resource that is used: %r' % item)
+ hash_to_put = item.get(HASH_KEY)
+ if not hash_to_put:
+ raise RuntimeError('Can only put() a resource that has a hash marker: %r' % item)
+ for key, item_list in self.reserved.items():
+ my_list = self.get(key)
+ for my_item in my_list:
+ if hash_to_put == my_item.get(HASH_KEY):
+ my_item.pop(USED_KEY)
+
+ def put_all(self):
+ for key, item_list in self.reserved.items():
+ my_list = self.get(key)
+ for my_item in my_list:
+ if my_item.get(USED_KEY):
+ my_item.pop(USED_KEY)
+
+ def free(self):
+ self.resources_pool.free(self.origin, self.reserved)
+ self.reserved = None
-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/schema.py b/src/osmo_gsm_tester/schema.py
new file mode 100644
index 0000000..a10ddd1
--- /dev/null
+++ b/src/osmo_gsm_tester/schema.py
@@ -0,0 +1,144 @@
+# osmo_gsm_tester: validate dict structures
+#
+# 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 re
+
+from . import log
+from .util import is_dict, is_list
+
+KEY_RE = re.compile('[a-zA-Z][a-zA-Z0-9_]*')
+IPV4_RE = re.compile('([0-9]{1,3}.){3}[0-9]{1,3}')
+HWADDR_RE = re.compile('([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}')
+IMSI_RE = re.compile('[0-9]{6,15}')
+KI_RE = re.compile('[0-9a-fA-F]{32}')
+MSISDN_RE = re.compile('[0-9]{1,15}')
+
+def match_re(name, regex, val):
+ while True:
+ if not isinstance(val, str):
+ break;
+ if not regex.fullmatch(val):
+ break;
+ return
+ raise ValueError('Invalid %s: %r' % (name, val))
+
+def band(val):
+ if val in ('GSM-1800', 'GSM-1900'):
+ return
+ raise ValueError('Unknown GSM band: %r' % val)
+
+def ipv4(val):
+ match_re('IPv4 address', IPV4_RE, val)
+ els = [int(el) for el in val.split('.')]
+ if not all([el >= 0 and el <= 255 for el in els]):
+ raise ValueError('Invalid IPv4 address: %r' % val)
+
+def hwaddr(val):
+ match_re('hardware address', HWADDR_RE, val)
+
+def imsi(val):
+ match_re('IMSI', IMSI_RE, val)
+
+def ki(val):
+ match_re('KI', KI_RE, val)
+
+def msisdn(val):
+ match_re('MSISDN', MSISDN_RE, val)
+
+INT = 'int'
+STR = 'str'
+BAND = 'band'
+IPV4 = 'ipv4'
+HWADDR = 'hwaddr'
+IMSI = 'imsi'
+KI = 'ki'
+MSISDN = 'msisdn'
+SCHEMA_TYPES = {
+ INT: int,
+ STR: str,
+ BAND: band,
+ IPV4: ipv4,
+ HWADDR: hwaddr,
+ IMSI: imsi,
+ KI: ki,
+ MSISDN: msisdn,
+ }
+
+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/suite.py b/src/osmo_gsm_tester/suite.py
index fb7c34d..0b8927f 100644
--- a/src/osmo_gsm_tester/suite.py
+++ b/src/osmo_gsm_tester/suite.py
@@ -18,9 +18,12 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import os
-from . import config, log, template, utils
+import sys
+import time
+from . import config, log, template, util, resource, schema, ofono_client, osmo_nitb
+from . import test
-class Suite(log.Origin):
+class SuiteDefinition(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
@@ -29,30 +32,13 @@ class Suite(log.Origin):
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,
- }
+ CONF_SCHEMA = util.dict_add(
+ {
+ 'defaults.timeout': schema.STR,
+ },
+ dict([('resources.%s' % k, t) for k,t in resource.WANT_SCHEMA.items()])
+ )
- 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)
@@ -62,17 +48,19 @@ class Suite(log.Origin):
def read_conf(self):
with self:
+ self.dbg('reading %s' % SuiteDefinition.CONF_FILENAME)
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)
+ SuiteDefinition.CONF_FILENAME),
+ SuiteDefinition.CONF_SCHEMA)
self.load_tests()
+
def load_tests(self):
with self:
self.tests = []
- for basename in os.listdir(self.suite_dir):
+ for basename in sorted(os.listdir(self.suite_dir)):
if not basename.endswith('.py'):
continue
self.tests.append(Test(self, basename))
@@ -87,64 +75,252 @@ class Suite(log.Origin):
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
+
+class Test(log.Origin):
+
+ def __init__(self, suite, test_basename):
+ self.suite = suite
+ self.basename = test_basename
+ self.path = os.path.join(self.suite.suite_dir, self.basename)
+ super().__init__(self.path)
+ self.set_name(self.basename)
+ self.set_log_category(log.C_TST)
+
+ def run(self, suite_run):
+ assert self.suite is suite_run.definition
+ with self:
+ test.setup(suite_run, self, ofono_client)
+ success = False
+ try:
+ self.log('START')
+ with self.redirect_stdout():
+ util.run_python_file('%s.%s' % (self.suite.name(), self.name()),
+ self.path)
+ success = True
+ except resource.NoResourceExn:
+ self.err('Current resource state:\n', repr(reserved_resources))
+ raise
+ finally:
+ if success:
+ self.log('PASS')
+ else:
+ self.log('FAIL')
+
+ def name(self):
+ l = log.get_line_for_src(self.path)
+ if l is not None:
+ return '%s:%s' % (self._name, l)
+ return super().name()
+
+class SuiteRun(log.Origin):
+
+ trial = None
+ resources_pool = None
+ reserved_resources = None
+ _resource_requirements = None
+ _config = None
+ _processes = None
+
+ def __init__(self, current_trial, suite_definition, scenarios=[]):
+ self.trial = current_trial
+ self.definition = suite_definition
+ self.scenarios = scenarios
+ self.set_name(suite_definition.name())
+ self.set_log_category(log.C_TST)
+ self.resources_pool = resource.ResourcesPool()
+
+ def combined(self, conf_name):
+ combination = self.definition.conf.get(conf_name) or {}
+ for scenario in self.scenarios:
+ c = scenario.get(conf_name)
+ if c is None:
+ continue
+ config.combine(combination, c)
+ return combination
+
+ def resource_requirements(self):
+ if self._resource_requirements is None:
+ self._resource_requirements = self.combined('resources')
+ return self._resource_requirements
+
+ def config(self):
+ if self._config is None:
+ self._config = self.combined('config')
+ return self._config
+
+ 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 __str__(self):
+ if self.failed:
+ return 'FAIL: %d of %d tests failed:\n %s' % (
+ len(self.failed),
+ len(self.failed) + len(self.passed),
+ '\n '.join([t.name() for t in self.failed]))
+ if not self.passed:
+ return 'no tests were run.'
+ return 'pass: all %d tests passed.' % len(self.passed)
+
+ def reserve_resources(self):
+ if self.reserved_resources:
+ raise RuntimeError('Attempt to reserve resources twice for a SuiteRun')
+ self.log('reserving resources...')
+ with self:
+ self.reserved_resources = self.resources_pool.reserve(self, self.resource_requirements())
+
+ def run_tests(self, names=None):
+ if not self.reserved_resources:
+ self.reserve_resources()
+ results = SuiteRun.Results()
+ for test in self.definition.tests:
+ if names and not test.name() in names:
+ continue
+ self._run_test(test, results)
+ self.stop_processes()
return results.conclude()
def _run_test(self, test, results):
try:
with self:
- test.run()
+ test.run(self)
results.add_pass(test)
except:
results.add_fail(test)
self.log_exn()
-class Test(log.Origin):
+ def remember_to_stop(self, process):
+ if self._processes is None:
+ self._processes = []
+ self._processes.append(process)
- 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 stop_processes(self):
+ if not self._processes:
+ return
+ for process in self._processes:
+ process.terminate()
- 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)
+ def nitb_iface(self):
+ return self.reserved_resources.get(resource.R_NITB_IFACE)
+
+ def nitb(self, nitb_iface=None):
+ if nitb_iface is None:
+ nitb_iface = self.nitb_iface()
+ return osmo_nitb.OsmoNitb(self, nitb_iface)
+
+ def bts(self):
+ return bts_obj(self, self.reserved_resources.get(resource.R_BTS))
+
+ def modem(self):
+ return modem_obj(self.reserved_resources.get(resource.R_MODEM))
+
+ def msisdn(self):
+ msisdn = self.resources_pool.next_msisdn(self.origin)
+ self.log('using MSISDN', msisdn)
+ return msisdn
+
+ def wait(self, condition, *condition_args, timeout=300, **condition_kwargs):
+ if not timeout or timeout < 0:
+ raise RuntimeError('wait() *must* time out at some point. timeout=%r' % timeout)
+
+ started = time.time()
+ while True:
+ self.poll()
+ if condition(*condition_args, **condition_kwargs):
+ return True
+ waited = time.time() - started
+ if waited > timeout:
+ return False
+ time.sleep(.1)
+
+ def sleep(self, seconds):
+ self.wait(lambda: False, timeout=seconds)
+
+ def poll(self):
+ if self._processes:
+ for process in self._processes:
+ process.poll()
+ ofono_client.poll()
+
+ def prompt(self, *msgs, **msg_details):
+ 'ask for user interaction. Do not use in tests that should run automatically!'
+ if msg_details:
+ msgs = list(msgs)
+ msgs.append('{%s}' %
+ (', '.join(['%s=%r' % (k,v)
+ for k,v in sorted(msg_details.items())])))
+ msg = ' '.join(msgs) or 'Hit Enter to continue'
+ self.log('prompt:', msg)
+ sys.__stdout__.write(msg)
+ sys.__stdout__.write('\n> ')
+ sys.__stdout__.flush()
+ entered = util.input_polling(self.poll)
+ self.log('prompt entered:', entered)
+ return entered
+
+
+loaded_suite_definitions = {}
+
+def load(suite_name):
+ global loaded_suite_definitions
+
+ suite = loaded_suite_definitions.get(suite_name)
+ if suite is not None:
+ return suite
+
+ suites_dir = config.get_suites_dir()
+ suite_dir = suites_dir.child(suite_name)
+ if not suites_dir.exists(suite_name):
+ raise RuntimeError('Suite not found: %r in %r' % (suite_name, suites_dir))
+ if not suites_dir.isdir(suite_name):
+ raise RuntimeError('Suite name found, but not a directory: %r' % (suite_dir))
+
+ suite_def = SuiteDefinition(suite_dir)
+ loaded_suite_definitions[suite_name] = suite_def
+ return suite_def
+
+def parse_suite_scenario_str(suite_scenario_str):
+ tokens = suite_scenario_str.split(':')
+ if len(tokens) > 2:
+ raise RuntimeError('invalid combination string: %r' % suite_scenario_str)
+
+ suite_name = tokens[0]
+ if len(tokens) <= 1:
+ scenario_names = []
+ else:
+ scenario_names = tokens[1].split('+')
+
+ return suite_name, scenario_names
+
+def load_suite_scenario_str(suite_scenario_str):
+ suite_name, scenario_names = parse_suite_scenario_str(suite_scenario_str)
+ suite = load(suite_name)
+ scenarios = [config.get_scenario(scenario_name) for scenario_name in scenario_names]
+ return (suite, scenarios)
+
+def bts_obj(suite_run, conf):
+ bts_type = conf.get('type')
+ log.dbg(None, None, 'create BTS object', type=bts_type)
+ bts_class = resource.KNOWN_BTS_TYPES.get(bts_type)
+ if bts_class is None:
+ raise RuntimeError('No such BTS type is defined: %r' % bts_type)
+ return bts_class(suite_run, conf)
+
+def modem_obj(conf):
+ log.dbg(None, None, 'create Modem object', conf=conf)
+ return ofono_client.Modem(conf)
# vim: expandtab tabstop=4 shiftwidth=4
diff --git a/src/osmo_gsm_tester/template.py b/src/osmo_gsm_tester/template.py
index 434ab62..c00bdc8 100644
--- a/src/osmo_gsm_tester/template.py
+++ b/src/osmo_gsm_tester/template.py
@@ -23,7 +23,7 @@ from mako.template import Template
from mako.lookup import TemplateLookup
from . import log
-from .utils import dict2obj
+from .util import dict2obj
_lookup = None
_logger = log.Origin('no templates dir set')
@@ -47,10 +47,12 @@ def render(name, values):
global _lookup
if _lookup is None:
set_templates_dir()
- with _logger:
- tmpl_name = name + '.tmpl'
+ tmpl_name = name + '.tmpl'
+ with log.Origin(tmpl_name):
template = _lookup.get_template(tmpl_name)
_logger.dbg('rendering', tmpl_name)
+
+ line_info_name = tmpl_name.replace('-', '_').replace('.', '_')
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-sysmo.cfg.tmpl
index 20fa57f..724bba8 100644
--- a/src/osmo_gsm_tester/templates/osmo-bts.cfg.tmpl
+++ b/src/osmo_gsm_tester/templates/osmo-bts-sysmo.cfg.tmpl
@@ -1,7 +1,4 @@
-!
-! OsmoBTS () configuration saved from vty
-!!
-!
+! Configuration rendered by osmo-gsm-tester
log stderr
logging color 1
logging timestamp 1
@@ -14,8 +11,8 @@ log stderr
phy 0
instance 0
bts 0
- band {band}
- ipa unit-id {ipa_unit_id} 0
- oml remote-ip {oml_remote_ip}
+ band ${osmo_bts_sysmo.band}
+ ipa unit-id ${osmo_bts_sysmo.ipa_unit_id} 0
+ oml remote-ip ${osmo_bts_sysmo.oml_remote_ip}
trx 0
phy 0 instance 0
diff --git a/src/osmo_gsm_tester/templates/osmo-bts-trx.cfg.tmpl b/src/osmo_gsm_tester/templates/osmo-bts-trx.cfg.tmpl
new file mode 100644
index 0000000..d416361
--- /dev/null
+++ b/src/osmo_gsm_tester/templates/osmo-bts-trx.cfg.tmpl
@@ -0,0 +1,22 @@
+! Configuration rendered by osmo-gsm-tester
+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
+ osmotrx rx-gain 25
+bts 0
+ band ${osmo_bts_trx.band}
+ ipa unit-id ${osmo_bts_trx.ipa_unit_id} 0
+ oml remote-ip ${osmo_bts_trx.oml_remote_ip}
+ settsc
+ gsmtap-sapi ccch
+ gsmtap-sapi pdtch
+ 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
index 3404b7f..e7dc119 100644
--- a/src/osmo_gsm_tester/templates/osmo-nitb.cfg.tmpl
+++ b/src/osmo_gsm_tester/templates/osmo-nitb.cfg.tmpl
@@ -1,6 +1,4 @@
-!
-! OpenBSC configuration saved from vty
-!
+! Configuration rendered by osmo-gsm-tester
password foo
!
log stderr
@@ -12,19 +10,19 @@ log stderr
!
line vty
no login
- bind ${vty_bind_ip}
+ bind ${nitb_iface.addr}
!
e1_input
e1_line 0 driver ipa
- ipa bind ${abis_bind_ip}
+ ipa bind ${nitb_iface.addr}
network
- network country code ${mcc}
- mobile network code ${mnc}
- short name ${net_name_short}
- long name ${net_name_long}
- auth policy ${net_auth_policy}
+ network country code ${nitb.net.mcc}
+ mobile network code ${nitb.net.mnc}
+ short name ${nitb.net.short_name}
+ long name ${nitb.net.long_name}
+ auth policy ${nitb.net.auth_policy}
location updating reject cause 13
- encryption a5 ${encryption}
+ encryption ${nitb.net.encryption}
neci 1
rrlp mode none
mm info 1
@@ -46,16 +44,7 @@ network
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:
+%for bts in nitb.net.bts_list:
bts ${loop.index}
type ${bts.type}
band ${bts.band}
@@ -69,7 +58,7 @@ ctrl
channel allocator ascending
rach tx integer 9
rach max transmission 7
- ip.access unit_id ${bts.unit_id} 0
+ ip.access unit_id ${bts.ipa_unit_id} 0
oml ip.access stream_id ${bts.stream_id} line 0
gprs mode none
% for trx in bts.trx_list:
@@ -85,3 +74,12 @@ ctrl
% endfor
% endfor
%endfor
+smpp
+ local-tcp-ip ${nitb_iface.addr} 2775
+ system-id test
+ policy closed
+ esme test
+ password test
+ default-route
+ctrl
+ bind ${nitb_iface.addr}
diff --git a/src/osmo_gsm_tester/test.py b/src/osmo_gsm_tester/test.py
index fd5a640..e52b545 100644
--- a/src/osmo_gsm_tester/test.py
+++ b/src/osmo_gsm_tester/test.py
@@ -1,4 +1,4 @@
-# osmo_gsm_tester: prepare a test run and provide test API
+# osmo_gsm_tester: context for individual test runs
#
# Copyright (C) 2016-2017 by sysmocom - s.f.m.c. GmbH
#
@@ -17,27 +17,33 @@
# 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
+# These will be initialized before each test run.
+# A test script can thus establish its context by doing:
+# from osmo_gsm_tester.test import *
+trial = None
+suite = None
+test = None
+resources = None
+log = None
+dbg = None
+err = None
+wait = None
+sleep = None
+poll = None
+prompt = None
-from . import suite as _suite
-from . import log
-from . import resource
+def setup(suite_run, _test, ofono_client):
+ global trial, suite, test, resources, log, dbg, err, wait, sleep, poll, prompt
+ trial = suite_run.trial
+ suite = suite_run
+ test = _test
+ resources = suite_run.reserved_resources
+ log = test.log
+ dbg = test.dbg
+ err = test.err
+ wait = suite_run.wait
+ sleep = suite_run.sleep
+ poll = suite_run.poll
+ prompt = suite_run.prompt
-# 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/trial.py b/src/osmo_gsm_tester/trial.py
new file mode 100644
index 0000000..a938971
--- /dev/null
+++ b/src/osmo_gsm_tester/trial.py
@@ -0,0 +1,160 @@
+# osmo_gsm_tester: trial: directory of binaries to be tested
+#
+# 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 time
+import shutil
+import tarfile
+
+from . import log, util
+
+FILE_MARK_TAKEN = 'taken'
+FILE_CHECKSUMS = 'checksums.md5'
+TIMESTAMP_FMT = '%Y-%m-%d_%H-%M-%S'
+FILE_LAST_RUN = 'last_run'
+
+class Trial(log.Origin):
+ path = None
+ dir = None
+ _run_dir = None
+ bin_tars = None
+
+ @staticmethod
+ def next(trials_dir):
+
+ with trials_dir.lock('Trial.next'):
+ trials = [e for e in trials_dir.children()
+ if trials_dir.isdir(e) and not trials_dir.exists(e, FILE_MARK_TAKEN)]
+ if not trials:
+ return None
+ # sort by time to get the one that waited longest
+ trials.sort(key=lambda e: os.path.getmtime(trials_dir.child(e)))
+ next_trial = trials[0]
+ return Trial(trials_dir.child(next_trial)).take()
+
+ def __init__(self, trial_dir):
+ self.path = trial_dir
+ self.set_name(self.path)
+ self.set_log_category(log.C_TST)
+ self.dir = util.Dir(self.path)
+ self.inst_dir = util.Dir(self.dir.child('inst'))
+ self.bin_tars = []
+
+ def __repr__(self):
+ return self.name()
+
+ def __enter__(self):
+ self.log('Trial start')
+ super().__enter__()
+
+ def __exit__(self, *exc_info):
+ super().__exit__(*exc_info)
+ self.log('Trial end')
+
+ def take(self):
+ self.dir.touch(FILE_MARK_TAKEN)
+ return self
+
+ def get_run_dir(self):
+ if self._run_dir is not None:
+ return self._run_dir
+ self._run_dir = util.Dir(self.dir.new_child('run.%s' % time.strftime(TIMESTAMP_FMT)))
+ self._run_dir.mkdir()
+
+ last_run = self.dir.child(FILE_LAST_RUN)
+ if os.path.islink(last_run):
+ os.remove(last_run)
+ if not os.path.exists(last_run):
+ os.symlink(self.dir.rel_path(self._run_dir.path), last_run)
+ return self._run_dir
+
+ def verify(self):
+ "verify checksums"
+
+ if not self.dir.exists():
+ raise RuntimeError('Trial dir does not exist: %r' % self.dir)
+ if not self.dir.isdir():
+ raise RuntimeError('Trial dir is not a dir: %r' % self.dir)
+
+ checksums = self.dir.child(FILE_CHECKSUMS)
+ if not self.dir.isfile(FILE_CHECKSUMS):
+ raise RuntimeError('No checksums file in trial dir: %r', checksums)
+
+ with open(checksums, 'r') as f:
+ line_nr = 0
+ for line in [l.strip() for l in f.readlines()]:
+ line_nr += 1
+ if not line:
+ continue
+ md5, filename = line.split(' ')
+ file_path = self.dir.child(filename)
+
+ if not self.dir.isfile(filename):
+ raise RuntimeError('File listed in checksums file but missing in trials dir:'
+ ' %r vs. %r line %d' % (file_path, checksums, line_nr))
+
+ if md5 != util.md5_of_file(file_path):
+ raise RuntimeError('Checksum mismatch for %r vs. %r line %d'
+ % (file_path, checksums, line_nr))
+
+ if filename.endswith('.tgz'):
+ self.bin_tars.append(filename)
+
+ def has_bin_tar(self, bin_name):
+ bin_tar_start = '%s.' % bin_name
+ matches = [t for t in self.bin_tars if t.startswith(bin_tar_start)]
+ self.dbg(bin_name=bin_name, matches=matches)
+ if not matches:
+ return None
+ if len(matches) > 1:
+ raise RuntimeError('More than one match for bin name %r: %r' % (bin_name, matches))
+ bin_tar = matches[0]
+ bin_tar_path = self.dir.child(bin_tar)
+ if not os.path.isfile(bin_tar_path):
+ raise RuntimeError('Not a file or missing: %r' % bin_tar_path)
+ return bin_tar_path
+
+ def get_inst(self, bin_name):
+ bin_tar = self.has_bin_tar(bin_name)
+ if not bin_tar:
+ return None
+ inst_dir = self.inst_dir.child(bin_name)
+
+ if os.path.isdir(inst_dir):
+ # already unpacked
+ return inst_dir
+
+ t = None
+ try:
+ os.makedirs(inst_dir)
+ t = tarfile.open(bin_tar)
+ t.extractall(inst_dir)
+ return inst_dir
+
+ except:
+ shutil.rmtree(inst_dir)
+ raise
+ finally:
+ if t:
+ try:
+ t.close()
+ except:
+ pass
+
+# vim: expandtab tabstop=4 shiftwidth=4
diff --git a/src/osmo_gsm_tester/util.py b/src/osmo_gsm_tester/util.py
new file mode 100644
index 0000000..61d0f6e
--- /dev/null
+++ b/src/osmo_gsm_tester/util.py
@@ -0,0 +1,332 @@
+# 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 sys
+import time
+import fcntl
+import hashlib
+import tempfile
+import shutil
+import atexit
+import threading
+import importlib.util
+import fcntl
+import tty
+import termios
+
+
+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__()
+
+
+class Dir():
+ LOCK_FILE = 'lock'
+
+ def __init__(self, path):
+ self.path = path
+ self.lock_path = os.path.join(self.path, Dir.LOCK_FILE)
+
+ def lock(self, origin_id):
+ '''
+ return lock context, usage:
+
+ with my_dir.lock(origin):
+ read_from(my_dir.child('foo.txt'))
+ write_to(my_dir.child('bar.txt'))
+ '''
+ self.mkdir()
+ return FileLock(self.lock_path, origin_id)
+
+ @staticmethod
+ def ensure_abs_dir_exists(*path_elements):
+ l = len(path_elements)
+ if l < 1:
+ raise RuntimeError('Cannot create empty path')
+ if l == 1:
+ path = path_elements[0]
+ else:
+ path = os.path.join(*path_elements)
+ if not os.path.isdir(path):
+ os.makedirs(path)
+
+ def child(self, *rel_path):
+ if not rel_path:
+ return self.path
+ return os.path.join(self.path, *rel_path)
+
+ def mk_parentdir(self, *rel_path):
+ child = self.child(*rel_path)
+ child_parent = os.path.dirname(child)
+ Dir.ensure_abs_dir_exists(child_parent)
+ return child
+
+ def mkdir(self, *rel_path):
+ child = self.child(*rel_path)
+ Dir.ensure_abs_dir_exists(child)
+ return child
+
+ def children(self):
+ return os.listdir(self.path)
+
+ def exists(self, *rel_path):
+ return os.path.exists(self.child(*rel_path))
+
+ def isdir(self, *rel_path):
+ return os.path.isdir(self.child(*rel_path))
+
+ def isfile(self, *rel_path):
+ return os.path.isfile(self.child(*rel_path))
+
+ def new_child(self, *rel_path):
+ attempt = 1
+ prefix, suffix = os.path.splitext(self.child(*rel_path))
+ rel_path_fmt = '%s%%s%s' % (prefix, suffix)
+ while True:
+ path = rel_path_fmt % (('_%d'%attempt) if attempt > 1 else '')
+ if not os.path.exists(path):
+ break
+ attempt += 1
+ continue
+ Dir.ensure_abs_dir_exists(os.path.dirname(path))
+ return path
+
+ def rel_path(self, path):
+ return os.path.relpath(path, self.path)
+
+ def touch(self, *rel_path):
+ touch_file(self.child(*rel_path))
+
+ def new_file(self, *rel_path):
+ path = self.new_child(*rel_path)
+ touch_file(path)
+ return path
+
+ def new_dir(self, *rel_path):
+ path = self.new_child(*rel_path)
+ Dir.ensure_abs_dir_exists(path)
+ return path
+
+ def __str__(self):
+ return self.path
+ def __repr__(self):
+ return self.path
+
+def touch_file(path):
+ with open(path, 'a') as f:
+ f.close()
+
+def is_dict(l):
+ return isinstance(l, dict)
+
+def is_list(l):
+ return isinstance(l, (list, tuple))
+
+
+def dict_add(a, *b, **c):
+ for bb in b:
+ a.update(bb)
+ a.update(c)
+ return a
+
+def _hash_recurse(acc, obj, ignore_keys):
+ if is_dict(obj):
+ for key, val in sorted(obj.items()):
+ if key in ignore_keys:
+ continue
+ _hash_recurse(acc, val, ignore_keys)
+ return
+
+ if is_list(obj):
+ for item in obj:
+ _hash_recurse(acc, item, ignore_keys)
+ return
+
+ acc.update(str(obj).encode('utf-8'))
+
+def hash_obj(obj, *ignore_keys):
+ acc = hashlib.sha1()
+ _hash_recurse(acc, obj, ignore_keys)
+ return acc.hexdigest()
+
+
+def md5(of_content):
+ if isinstance(of_content, str):
+ of_content = of_content.encode('utf-8')
+ return hashlib.md5(of_content).hexdigest()
+
+def md5_of_file(path):
+ with open(path, 'rb') as f:
+ return md5(f.read())
+
+_tempdir = None
+
+def get_tempdir(remove_on_exit=True):
+ global _tempdir
+ if _tempdir is not None:
+ return _tempdir
+ _tempdir = tempfile.mkdtemp()
+ if remove_on_exit:
+ atexit.register(lambda: shutil.rmtree(_tempdir))
+ return _tempdir
+
+
+if hasattr(importlib.util, 'module_from_spec'):
+ def run_python_file(module_name, path):
+ spec = importlib.util.spec_from_file_location(module_name, path)
+ spec.loader.exec_module( importlib.util.module_from_spec(spec) )
+else:
+ from importlib.machinery import SourceFileLoader
+ def run_python_file(module_name, path):
+ SourceFileLoader(module_name, path).load_module()
+
+def msisdn_inc(msisdn_str):
+ 'add 1 and preserve leading zeros'
+ return ('%%0%dd' % len(msisdn_str)) % (int(msisdn_str) + 1)
+
+class polling_stdin:
+ def __init__(self, stream):
+ self.stream = stream
+ self.fd = self.stream.fileno()
+ def __enter__(self):
+ self.original_stty = termios.tcgetattr(self.stream)
+ tty.setcbreak(self.stream)
+ self.orig_fl = fcntl.fcntl(self.fd, fcntl.F_GETFL)
+ fcntl.fcntl(self.fd, fcntl.F_SETFL, self.orig_fl | os.O_NONBLOCK)
+ def __exit__(self, *args):
+ fcntl.fcntl(self.fd, fcntl.F_SETFL, self.orig_fl)
+ termios.tcsetattr(self.stream, termios.TCSANOW, self.original_stty)
+
+def input_polling(poll_func, stream=None):
+ if stream is None:
+ stream = sys.stdin
+ unbuffered_stdin = os.fdopen(stream.fileno(), 'rb', buffering=0)
+ try:
+ with polling_stdin(unbuffered_stdin):
+ acc = []
+ while True:
+ poll_func()
+ got = unbuffered_stdin.read(1)
+ if got and len(got):
+ try:
+ # this is hacky: can't deal with multibyte sequences
+ got_str = got.decode('utf-8')
+ except:
+ got_str = '?'
+ acc.append(got_str)
+ sys.__stdout__.write(got_str)
+ sys.__stdout__.flush()
+ if '\n' in got_str:
+ return ''.join(acc)
+ time.sleep(.1)
+ finally:
+ unbuffered_stdin.close()
+
+# vim: expandtab tabstop=4 shiftwidth=4
diff --git a/src/osmo_gsm_tester/utils.py b/src/osmo_gsm_tester/utils.py
deleted file mode 100644
index 9992d44..0000000
--- a/src/osmo_gsm_tester/utils.py
+++ /dev/null
@@ -1,118 +0,0 @@
-# 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
index ff15204..88eed28 100755
--- a/src/run_once.py
+++ b/src/run_once.py
@@ -21,28 +21,124 @@
'''osmo_gsm_tester: invoke a single test run.
-./run_once.py ~/path/to/test_package/
+Examples:
-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.
+./run_once.py ~/my_trial_package/ -s osmo_trx
+./run_once.py ~/my_trial_package/ -c sms_tests:dyn_ts+eu_band+bts_sysmo
+./run_once.py ~/my_trial_package/ -c sms_tests/mo_mt_sms:bts_trx
+
+(The names for test suite, scenario and series names used in these examples
+must be defined by the osmo-gsm-tester configuration.)
+
+A trial package contains binaries (usually built by a jenkins job) of GSM
+software, including the core network programs as well as binaries for the
+various BTS models.
+
+A test suite defines specific actions to be taken and verifies their outcome.
+Such a test suite may leave certain aspects of a setup undefined, e.g. it may
+be BTS model agnostic or does not care which voice codecs are chosen.
+
+A test scenario completes the picture in that it defines which specific choices
+shall be made to run a test suite. Any one test suite may thus run on any
+number of different scenarios, e.g. to test various voice codecs.
+
+Test scenarios may be combined. For example, one scenario may define a timeslot
+configuration to use, while another scenario may define the voice codec
+configuration.
+
+There may still be aspects that are neither required by a test suite nor
+strictly defined by a scenario, which will be resolved automatically, e.g. by
+choosing the first available item that matches the other constraints.
+
+A test run thus needs to define: a trial package containing built binaries, a
+combination of scenarios to run a suite in, and a test suite to launch in the
+given scenario with the given binaries.
+
+The osmo-gsm-tester configuration may define one or more series as a number of
+suite:scenario combinations. So instead of a specific suite:scenario
+combination, the name of such a series can be passed.
+
+If neither a combination or series is specified, the default series will be run
+as defined in the osmo-gsm-tester configuration.
+
+The scenarios and suites run for a given trial will be recorded in a trial
+package's directory: Upon launch, a 'test_package/run.<date>' directory will be
+created, which will collect logs and reports.
'''
-import osmo_gsm_tester
+from osmo_gsm_tester import trial, suite, log, __version__
if __name__ == '__main__':
import argparse
- parser = argparse.ArgumentParser()
+ parser = argparse.ArgumentParser(epilog=__doc__, formatter_class=argparse.RawTextHelpFormatter)
parser.add_argument('-V', '--version', action='store_true',
help='Show version')
- parser.add_argument('test_package', nargs='*',
+ parser.add_argument('trial_package', nargs='+',
help='Directory containing binaries to test')
+ parser.add_argument('-s', '--suite-scenario', dest='suite_scenario', action='append',
+ help='A suite-scenarios combination like suite:scenario+scenario')
+ parser.add_argument('-S', '--series', dest='series', action='append',
+ help='A series of suite-scenarios combinations as defined in the'
+ ' osmo-gsm-tester configuration')
+ parser.add_argument('-t', '--test', dest='test', action='append',
+ help='Run only tests matching this name')
+ parser.add_argument('-l', '--log-level', dest='log_level', choices=log.LEVEL_STRS.keys(),
+ default=None,
+ help='Set logging level for all categories (on stdout)')
+ parser.add_argument('-T', '--traceback', dest='trace', action='store_true',
+ help='Enable logging of tracebacks')
args = parser.parse_args()
if args.version:
- print(osmo_gsm_tester.__version__)
+ print(__version__)
exit(0)
+ print('combinations:', repr(args.suite_scenario))
+ print('series:', repr(args.series))
+ print('trials:', repr(args.trial_package))
+ print('tests:', repr(args.test))
+
+ if args.log_level:
+ log.set_all_levels(log.LEVEL_STRS.get(args.log_level))
+ log.style_change(origin_width=32)
+ if args.trace:
+ log.style_change(trace=True)
+
+ combination_strs = list(args.suite_scenario or [])
+ # for series in args.series:
+ # combination_strs.extend(config.get_series(series))
+
+ if not combination_strs:
+ raise RuntimeError('Need at least one suite:scenario or series to run')
+
+ suite_scenarios = []
+ for combination_str in combination_strs:
+ suite_scenarios.append(suite.load_suite_scenario_str(combination_str))
+
+ test_names = []
+ for test_name in (args.test or []):
+ found = False
+ for suite_run in suite_runs:
+ for test in suite_run.definition.tests:
+ if test_name in test.name():
+ found = True
+ test_names.append(test.name())
+ if not found:
+ raise RuntimeError('No test found for %r' % test_name)
+ if test_names:
+ print(repr(test_names))
+
+ trials = []
+ for trial_package in args.trial_package:
+ t = trial.Trial(trial_package)
+ t.verify()
+ trials.append(t)
+
+ for current_trial in trials:
+ with current_trial:
+ for suite_def, scenarios in suite_scenarios:
+ suite_run = suite.SuiteRun(current_trial, suite_def, scenarios)
+ suite_run.run_tests(test_names)
# vim: expandtab tabstop=4 shiftwidth=4
diff --git a/test/config_test.ok b/test/config_test.ok
deleted file mode 100644
index dc88ae2..0000000
--- a/test/config_test.ok
+++ /dev/null
@@ -1,46 +0,0 @@
-{'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
deleted file mode 100755
index de4ffb9..0000000
--- a/test/config_test.py
+++ /dev/null
@@ -1,70 +0,0 @@
-#!/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/log_test.ok b/test/log_test.ok
deleted file mode 100644
index 70257d5..0000000
--- a/test/log_test.ok
+++ /dev/null
@@ -1,41 +0,0 @@
-- 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/resource_test.py b/test/resource_test.py
deleted file mode 100755
index 87e0473..0000000
--- a/test/resource_test.py
+++ /dev/null
@@ -1,20 +0,0 @@
-#!/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/suite_test.ok b/test/suite_test.ok
deleted file mode 100644
index 173fee9..0000000
--- a/test/suite_test.ok
+++ /dev/null
@@ -1,24 +0,0 @@
-- 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
deleted file mode 100755
index 5e6c312..0000000
--- a/test/suite_test.py
+++ /dev/null
@@ -1,29 +0,0 @@
-#!/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/test_suite/hello_world.py b/test/suite_test/test_suite/hello_world.py
deleted file mode 100644
index c992139..0000000
--- a/test/suite_test/test_suite/hello_world.py
+++ /dev/null
@@ -1,3 +0,0 @@
-print('hello world')
-print('I am %r / %r' % (this.suite, this.test))
-print('one\ntwo\nthree')
diff --git a/test/suite_test/test_suite/suite.conf b/test/suite_test/test_suite/suite.conf
deleted file mode 100644
index 7596ca0..0000000
--- a/test/suite_test/test_suite/suite.conf
+++ /dev/null
@@ -1,9 +0,0 @@
-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
deleted file mode 100644
index a45f7a6..0000000
--- a/test/suite_test/test_suite/test_error.py
+++ /dev/null
@@ -1,2 +0,0 @@
-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
deleted file mode 100755
index 7e04588..0000000
--- a/test/suite_test/test_suite/test_error2.py
+++ /dev/null
@@ -1,8 +0,0 @@
-#!/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)