ansible-taler-exchange

Ansible playbook to deploy a production Taler Exchange
Log | Files | Refs | README | LICENSE

commit 323ef8f4a478bab0da5bc08f29d62338ab4ee621
parent 4f133f3a273daa863c3f638a99ad2851e4734bde
Author: Florian Dold <dold@taler.net>
Date:   Sat,  5 Sep 2026 20:13:00 +0200

Regression checks: exercise deployment failures and recovery in CI

Use the shared disposable-container runner in CI and cover application
shutdown, package-start suppression, secret access, devtesting
revocation, configuration preservation and recovery. Run fact helpers
inside the container and retain fixtures only when explicitly requested.

Diffstat:
M.gitignore | 3+++
MREADME | 10++++++++++
Mcontrib/ci/Containerfile | 4+---
Mcontrib/ci/jobs/001-build/build.sh | 45++++-----------------------------------------
Mcontrib/test-fact-helpers.sh | 2+-
Acontrib/tests/test_deployment.py | 247+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtest.sh | 24+++++++++++++++++-------
7 files changed, 283 insertions(+), 52 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -14,3 +14,6 @@ vault_pass.txt # Text editor files *~ .vscode + +# Python regression test bytecode +__pycache__/ diff --git a/README b/README @@ -376,3 +376,13 @@ encounters another backup, it exits nonzero (script status 75) without touching that run's snapshot. Retry after the existing backup finishes; a reboot playbook aborts before rebooting on this failure. Pruning and compaction are skipped after an unsuccessful or warning-producing archive creation. + +## Regression checks + +Run python3 contrib/tests/test_backup.py for isolated concurrency, failure, +cleanup and archive round-trip checks (requires Borg and Python Jinja2). +The extended ./test.sh also exercises real systemd shutdown, package-start +suppression, a pre-2.20 Ansible dependency bootstrap, secret permissions, +devtesting revocation, preservation of unrelated configuration, failed deployment +and recovery. All fixtures run in the disposable container, never an inventory +production host. diff --git a/contrib/ci/Containerfile b/contrib/ci/Containerfile @@ -2,6 +2,4 @@ FROM quay.io/podman/stable:v5.2.3 RUN dnf update -yq && \ - dnf install -yq \ - ansible #\ - #systemd + dnf install -yq ansible borgbackup ShellCheck diff --git a/contrib/ci/jobs/001-build/build.sh b/contrib/ci/jobs/001-build/build.sh @@ -1,44 +1,7 @@ #!/bin/bash -set -exuo pipefail +set -euo pipefail -#### WARNING: THIS SCRIPT IS INTENED TO BE RUN INSIDE OF A CONTAINER - - -# Print some debug info -id ; cat /proc/self/uid_map ; mount | grep cgroup || true - -# Check the fact helpers before spending time on the container -contrib/test-fact-helpers.sh - -# Hack to make podman adapt to being nested +# This job runs inside the disposable CI container. The shared runner provisions +# its nested container over the published SSH port and executes all regressions. rm -f /etc/containers/storage.conf - -# Build our image -podman build -f Containerfile -t ansible-taler-test - -# Run in background (-d) with systemd init -podman run \ - --privileged \ - --tmpfs /sys \ - --rm \ - --name ansible-taler-test \ - -d localhost/ansible-taler-test sh -c "id ; cat /proc/self/uid_map ; mount | grep cgroup; exec /usr/sbin/init --show-status" - -# Print to log that container is running -podman ps - -# TOFU SSH host keys (so we don't get user prompt) -echo "StrictHostKeyChecking=accept-new" > ~/.ssh/config - -# Run our playbook(s) -# NOTE: Trailing comma is correct (and required) in agument for -i flag -ansible-playbook --verbose -i 127.0.0.1:22, --user root playbooks/setup.yml - -echo -e ' - ############################# - ############################# - ############################# - ###### Setup finished. ###### - ############################# - ############################# - #############################' +exec ./test.sh diff --git a/contrib/test-fact-helpers.sh b/contrib/test-fact-helpers.sh @@ -7,7 +7,7 @@ set -u -helpers="$(dirname "$0")/../roles/common_packages/files" +helpers="${TALER_FACT_HELPERS_DIR:-$(dirname "$0")/../roles/common_packages/files}" failures=0 check() { diff --git a/contrib/tests/test_deployment.py b/contrib/tests/test_deployment.py @@ -0,0 +1,247 @@ +#!/usr/bin/env python3 +"""Integration regressions for the disposable container created by test.sh.""" +import argparse +import json +import os +import re +from pathlib import Path +import subprocess +import tempfile + +from test_upgrade_policy import verify_upgrade_policy_restoration + +REPO = Path(__file__).resolve().parents[2] +UNITS = ['taler-exchange.target', 'taler-exchange-httpd.service', + 'libeufin-nexus-httpd.service', 'taler-auditor.target', + 'taler-auditor-httpd.service', 'sms-challenger-httpd.service', + 'email-challenger-httpd.service', 'postal-challenger-httpd.service'] + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('container') + parser.add_argument('private_key') + parser.add_argument('setup_log') + args = parser.parse_args() + with tempfile.TemporaryDirectory(prefix='taler-deployment-regressions-') as directory: + work = Path(directory) + def container(*cmd, check=True, input=None): + return subprocess.run(['podman', 'exec', '-i', args.container, *cmd], + capture_output=True, text=True, check=check, input=input) + + subprocess.run(['podman', 'cp', str(REPO / 'contrib/test-fact-helpers.sh'), + f'{args.container}:/tmp/test-fact-helpers.sh'], check=True) + helper_result = container('env', 'TALER_FACT_HELPERS_DIR=/bin', 'bash', '/tmp/test-fact-helpers.sh') + print(helper_result.stdout, end='', flush=True) + + facts = json.loads(container('python3', '-c', '''import glob,json +print(json.dumps([json.load(open(p)) for p in glob.glob('/etc/ansible/facts.d/*secret.fact') + glob.glob('/etc/ansible/facts.d/*access-token.fact')])) +''').stdout) + # These values come exclusively from the disposable testing inventory. + auditor_auth = container('cat', '/etc/nginx/auditor-auth.conf.inc').stdout + auditor_token = re.search(r'Bearer ([^"]+)', auditor_auth)[1] + secret_values = facts + ['SECRET2', auditor_token] + def assert_no_secrets(output): + for value in secret_values: + assert value not in output, 'A secret appeared in deployment output' + + assert_no_secrets(Path(args.setup_log).read_text()) + ansible = ['ansible-playbook', '-i', str(REPO / 'inventories/default'), + '-l', 'podman-localhost', '--user', 'root', + '--private-key', args.private_key] + env = dict(os.environ, ANSIBLE_CONFIG=str(REPO / 'test-ansible.cfg'), ANSIBLE_NOCOWS='1') + def play(tasks, variables=None, check_mode=False, expect_failure=False): + p = work / 'regression.json' + p.write_text(json.dumps([{'hosts': 'all', 'gather_facts': False, + 'pre_tasks': [{'ansible.builtin.setup': {}, 'no_log': True}], + 'vars': variables or {}, 'tasks': tasks}])) + return invoke(p, check_mode, expect_failure) + + def invoke(p, check_mode=False, expect_failure=False): + cmd = ansible + ['-vvv', '--diff'] + if check_mode: + cmd += ['--check'] + result = subprocess.run(cmd + [str(p)], capture_output=True, text=True, env=env) + output = result.stdout + result.stderr + assert_no_secrets(output) + if bool(result.returncode) != expect_failure: + print(output) + raise AssertionError(f'Unexpected Ansible exit status: {result.returncode}') + return output + + def role(name, **variables): + return {'ansible.builtin.include_role': {'name': name}, 'vars': variables} + + def states(): + return [container('systemctl', 'is-active', unit, check=False).stdout.strip() for unit in UNITS] + + def assert_active(): + assert states() == ['active'] * len(UNITS), states() + + assert_active() + # Check mode must not restart active application processes. + before = container('systemctl', 'show', '--property=MainPID', *UNITS).stdout + invoke(REPO / 'playbooks/setup.yml', check_mode=True) + after = container('systemctl', 'show', '--property=MainPID', *UNITS).stdout + assert before == after, 'Check mode restarted application processes' + print('PASS: check mode preserves running applications', flush=True) + + # All three KYC consumers can read the unchanged key; unrelated users cannot. + tasks = [] + for user in ['taler-exchange-httpd', 'taler-exchange-aggregator', 'taler-exchange-sanctionscheck']: + tasks += [{'ansible.builtin.command': {'argv': ['taler-exchange-config', '-c', + '/etc/taler-exchange/taler-exchange.conf', '-s', 'exchange', '-o', 'ATTRIBUTE_ENCRYPTION_KEY']}, + 'become': True, 'become_user': user, 'register': 'key_read', 'no_log': True, 'changed_when': False}, + {'ansible.builtin.assert': {'that': 'key_read.stdout == exchange_attribute_encryption_key'}, 'no_log': True}] + for path in ['/etc/taler-exchange/secrets/exchange-attributes.secret.conf', '/etc/nginx/auditor-auth.conf.inc']: + tasks += [{'ansible.builtin.command': {'argv': ['test', '-r', path]}, + 'become': True, 'become_user': 'nobody', 'register': 'read_test', + 'changed_when': False, 'failed_when': 'read_test.rc != 1'}] + tasks += [{'ansible.builtin.command': {'argv': ['taler-exchange-config', '-c', + '/etc/taler-exchange/taler-exchange.conf', '-s', 'exchange', '-o', 'CURRENCY']}, + 'become': True, 'become_user': 'nobody', 'changed_when': False}] + play(tasks) + auditor_site = container('cat', '/etc/nginx/sites-available/auditor-nginx.conf').stdout + assert auditor_token not in auditor_site + assert container('grep', '-q', 'ATTRIBUTE_ENCRYPTION_KEY =', + '/etc/taler-exchange/conf.d/exchange-business.conf', check=False).returncode == 1 + auditor_host = re.search(r'server_name ([^;]+);', auditor_site)[1] + def http_status(path, token=None): + cmd = ['curl', '--noproxy', '*', '--silent', '--show-error', '--insecure', + '--resolve', f'{auditor_host}:443:127.0.0.1', '-o', '/dev/null', '-w', '%{http_code}'] + if token: + cmd += ['-H', 'Authorization: Bearer ' + token] + return container(*cmd, 'https://' + auditor_host + path).stdout + assert http_status('/config') == '200' + assert http_status('/regression-protected') == '401' + assert http_status('/regression-protected', auditor_token) != '401' + print('PASS: secrets are restricted and public configuration remains readable', flush=True) + + # Actual services/targets and timers must stop, with enablement preserved. + container('systemd-run', '--unit=taler-exchange-regression', '--on-active=1h', '/bin/true') + enabled_before = container('systemctl', 'is-enabled', *UNITS, check=False).stdout + output = play([role('stop_services', stop_services_include_merchant=False), + {'ansible.builtin.assert': {'that': [ + "'taler-exchange.target' in stop_services_active_units", + "'taler-auditor.target' in stop_services_active_units", + "'sms-challenger-httpd.service' in stop_services_active_units", + "'taler-exchange-regression.timer' in stop_services_active_units"]}}]) + assert 'active' not in states(), states() + assert enabled_before == container('systemctl', 'is-enabled', *UNITS, check=False).stdout + print('PASS: shutdown includes targets, services, and timers without disabling them', flush=True) + + # Run the real dependency bootstrap on the image's pre-2.20 controller. + version = container('ansible-playbook', '--version').stdout.splitlines()[0] + assert 'core 2.19.' in version or 'core 2.18.' in version, version + container('mkdir', '-p', '/tmp/taler-compat/roles') + subprocess.run(['podman', 'cp', str(REPO / 'roles/common_packages'), + f'{args.container}:/tmp/taler-compat/roles/common_packages'], check=True) + compat = [{'hosts': 'localhost', 'connection': 'local', 'vars': { + 'taler_repo_suites': 'trixie', 'use_pregenerated_dhparam': True}, 'roles': ['common_packages']}] + container('python3', '-c', 'import sys; open("/tmp/taler-compat/play.json","w").write(sys.stdin.read())', + input=json.dumps(compat)) + container('dpkg', '--remove', '--force-depends', 'python3-debian') + compat_result = container('ansible-playbook', '-i', 'localhost,', '/tmp/taler-compat/play.json', check=False) + assert compat_result.returncode == 0, compat_result.stdout + compat_result.stderr + print('PASS: dependency bootstrap on ' + version, flush=True) + + verify_upgrade_policy_restoration(container, play) + + # Exercise package scripts attempting to start a service and restoration + # of a pre-existing policy-rc.d. Everything here is disposable test data. + container('sh', '-eu', '-c', ''' +mkdir -p /tmp/taler-policy-probe/DEBIAN /tmp/taler-policy-probe/etc/init.d +cat > /tmp/taler-policy-probe/DEBIAN/control <<'EOF' +Package: taler-policy-probe +Version: 1.0 +Architecture: all +Maintainer: Test <test@example.invalid> +Description: Disposable service-start policy probe +EOF +cat > /tmp/taler-policy-probe/DEBIAN/postinst <<'EOF' +#!/bin/sh +set -e +invoke-rc.d taler-policy-probe start +EOF +cat > /tmp/taler-policy-probe/etc/init.d/taler-policy-probe <<'EOF' +#!/bin/sh +touch /tmp/taler-policy-started +EOF +chmod 755 /tmp/taler-policy-probe/DEBIAN/postinst /tmp/taler-policy-probe/etc/init.d/taler-policy-probe +printf '#!/bin/sh\\nexit 0\\n' > /usr/sbin/policy-rc.d +chmod 755 /usr/sbin/policy-rc.d +dpkg-deb --build /tmp/taler-policy-probe /tmp/taler-policy-probe.deb +''') + original_policy = container('cat', '/usr/sbin/policy-rc.d').stdout + play([{'ansible.builtin.apt': {'deb': '/tmp/taler-policy-probe.deb', 'policy_rc_d': 101}}]) + assert container('test', '-e', '/tmp/taler-policy-started', check=False).returncode == 1 + assert container('cat', '/usr/sbin/policy-rc.d').stdout == original_policy + container('rm', '/usr/sbin/policy-rc.d') + container('dpkg', '--remove', 'taler-policy-probe') + assert 'active' not in states(), states() + print('PASS: package starts are suppressed and existing policy is restored', flush=True) + + # Restore skipped optional services from a captured list without changing flags. + play([role('start_services')], {'deployment_previously_active_units': UNITS, + 'deploy_auditor': False, 'deploy_challenger': False}) + assert_active() + print('PASS: previously active optional services resume', flush=True) + + # Revocation must affect existing sessions and retain the account's data. + dev_vars = {'dangerously_enable_devtesting': True, 'devtesting_ssh_keys': []} + play([role('devtesting')], dev_vars) + container('touch', '/home/devtesting/keep-data') + container('systemd-run', '--unit=devtesting-regression', '--uid=devtesting', '/bin/sleep', '600') + play([role('devtesting')], {'dangerously_enable_devtesting': False}) + assert container('pgrep', '-u', 'devtesting', check=False).returncode == 1 + assert container('test', '-f', '/home/devtesting/keep-data', check=False).returncode == 0 + assert container('test', '-e', '/etc/sudoers.d/devtesting', check=False).returncode == 1 + assert '/usr/sbin/nologin' in container('getent', 'passwd', 'devtesting').stdout + result = container('su', '-s', '/bin/sh', 'devtesting', '-c', + 'sudo -n -u libeufin-nexus /usr/bin/libeufin-nexus testing --help', check=False) + assert result.returncode != 0, 'Revoked user retained sudo access' + play([role('devtesting')], dev_vars) + assert '/bin/bash' in container('getent', 'passwd', 'devtesting').stdout + assert container('test', '-f', '/etc/sudoers.d/devtesting', check=False).returncode == 0 + play([role('devtesting')], {'dangerously_enable_devtesting': False}) + print('PASS: devtesting revocation and re-enablement preserve home data', flush=True) + + # Backup provisioning must not overwrite another application's SSH settings. + container('sh', '-eu', '-c', ''' +printf 'Host unrelated\\n HostName example.invalid\\n' > /root/.ssh/config +ssh-keygen -q -t ed25519 -N '' -f /root/.ssh/borg +ssh-keyscan localhost > /root/.ssh/known_hosts +''') + ssh_before = container('cat', '/root/.ssh/config').stdout + play([role('borg-start')], {'borg_host': 'localhost', 'borg_repo': '/tmp/test-backups', + 'borg_passphrase': 'synthetic-backup-passphrase'}) + assert container('cat', '/root/.ssh/config').stdout == ssh_before + assert 'IdentityFile ~/.ssh/borg' in container('ssh', '-G', '-F', '/root/.ssh/borg-config', 'localhost').stdout or \ + 'identityfile ~/.ssh/borg' in container('ssh', '-G', '-F', '/root/.ssh/borg-config', 'localhost').stdout + container('rm', '-f', '/var/spool/cron/crontabs/root') + for override in [None, '/tmp/independent-repo']: + variables = {} if override is None else {'borg_repo': override} + play([{'ansible.builtin.include_vars': str(REPO / 'roles/database_restore/defaults/main.yml')}, + {'ansible.builtin.assert': {'that': 'database_restore_borg_repository == borg_repo'}}], variables) + print('PASS: SSH configuration survives and backup/restore repository settings agree', flush=True) + + # An invalid global config must fail a real redeploy, preserve unrelated + # site links, and leave apps stopped. A subsequent corrected redeploy recovers. + container('sh', '-eu', '-c', ''' +printf 'server { listen 127.0.0.1:8099; return 200 "unrelated"; }\\n' > /etc/nginx/sites-available/unrelated +ln -s /etc/nginx/sites-available/unrelated /etc/nginx/sites-enabled/unrelated +printf 'invalid_regression_directive;\\n' > /etc/nginx/conf.d/regression-invalid.conf +''') + invoke(REPO / 'playbooks/setup.yml', expect_failure=True) + assert 'active' not in states(), states() + assert container('test', '-L', '/etc/nginx/sites-enabled/unrelated', check=False).returncode == 0 + container('rm', '/etc/nginx/conf.d/regression-invalid.conf') + invoke(REPO / 'playbooks/setup.yml') + assert_active() + assert container('test', '-L', '/etc/nginx/sites-enabled/unrelated', check=False).returncode == 0 + assert container('cat', '/root/.ssh/config').stdout == ssh_before + print('PASS: failed deployment preserves sites and stops applications; redeploy recovers', flush=True) + + +if __name__ == '__main__': + main() diff --git a/test.sh b/test.sh @@ -1,23 +1,32 @@ #!/bin/bash -set -exuo pipefail +set -euo pipefail +export ANSIBLE_NOCOWS=1 repo_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) cd "$repo_dir" -test_container=ansible-taler-test +test_image=ansible-taler-test +test_container=ansible-taler-test-$$ test_state_dir=$(mktemp -d) cleanup() { + if [[ ${TALER_TEST_KEEP_CONTAINER:-0} == 1 ]]; then + echo "Kept test container $test_container and test files $test_state_dir" + return + fi podman rm --force "$test_container" >/dev/null 2>&1 || true rm -rf -- "${test_state_dir:?}" } trap cleanup EXIT ssh-keygen -q -t ed25519 -N "" -f "$test_state_dir/id_ed25519" -podman rm --force "$test_container" >/dev/null 2>&1 || true + +# Fast isolated regressions run before the complete deployment. +python3 contrib/tests/test_backup.py +python3 contrib/tests/test_upgrade_policy.py # Build our image -podman build -f Containerfile -t "$test_container" +podman build -f Containerfile -t "$test_image" # Run in background (-d) with systemd init. Taler's hardened systemd units # require capabilities that Podman otherwise removes from the container. @@ -27,7 +36,7 @@ podman run \ -p 127.0.0.1:8022:22 \ --systemd=always \ --privileged \ - -d "localhost/$test_container" sh -c "exec /usr/sbin/init --show-status" + -d "localhost/$test_image" sh -c "exec /usr/sbin/init --show-status" # Use a disposable key because the deployment correctly disables SSH password # authentication, and check mode runs in a separate SSH session afterwards. @@ -66,8 +75,9 @@ ansible_args=( ) # Provision, then prove that a separate check-mode run can inspect the result. -ansible-playbook --verbose "${ansible_args[@]}" -ansible-playbook --check --diff "${ansible_args[@]}" +ansible-playbook --verbose --diff "${ansible_args[@]}" | tee "$test_state_dir/setup.log" +python3 contrib/tests/test_deployment.py \ + "$test_container" "$test_state_dir/id_ed25519" "$test_state_dir/setup.log" # Basic smoke checks independent of Ansible's post-deployment checks. podman exec "$test_container" systemctl is-active --quiet \