ansible-taler-exchange

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

commit 36e60f229af05a2a5787c7e705f19c29c5d12740
parent 66e4c7754568e545591102129322e9b75ff051d4
Author: Florian Dold <dold@taler.net>
Date:   Fri, 11 Sep 2026 21:29:11 +0200

deployment: reapply service configuration after interrupted runs

Keep an incomplete-deployment marker until handlers and sanity checks
succeed. On retries, requeue nginx and monitoring handlers so unchanged
files from a failed run are applied to the running services.

Validate Fluent Bit before restarting and require its enabled latency
endpoint to respond before monitoring setup succeeds.

Diffstat:
MREADME | 17+++++++++++++++++
Acontrib/tests/test_deployment_recovery.py | 233+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mplaybooks/setup.yml | 9+++++++++
Aroles/deployment_state/defaults/main.yml | 2++
Aroles/deployment_state/tasks/complete.yml | 7+++++++
Aroles/deployment_state/tasks/main.yml | 32++++++++++++++++++++++++++++++++
Mroles/monitoring/handlers/main.yml | 9++++++++-
Mroles/monitoring/tasks/main.yml | 17+++++++++++++++++
Aroles/monitoring/tasks/recover.yml | 13+++++++++++++
Mroles/webserver/tasks/main.yml | 4+++-
Mtest | 3+++
11 files changed, 344 insertions(+), 2 deletions(-)

diff --git a/README b/README @@ -84,6 +84,15 @@ If you are root@rusty.taler-ops.ch, you may be able to: $ ./deploy rusty ``` +Deployments keep `/var/lib/taler-deployment/incomplete` until configuration +handlers and post-deployment checks have succeeded. If a run fails or is +interrupted, the next `deploy` reapplies nginx and enabled monitoring service +configuration even when the files are unchanged. This recovers restart +notifications lost by the previous Ansible process. Applications still stay +stopped on deployment failure; check mode neither clears the marker nor +restarts services. Normal successful reruns keep the usual change-triggered +handler behavior. + For TOPS production, replace the "rusty" with "spec" to use the actual secrets for the deployment. For this, you first need to decrypt them: @@ -554,6 +563,14 @@ Fluent Bit binary's `--dry-run` mode and nginx's `-t` mode. ## Regression checks +`python3 contrib/tests/test_deployment_recovery.py` uses the local +`ansible-taler-test` image built by `./test` (or a Debian systemd image passed +with `--image`). It runs the real setup failure/retry flow and recovery handlers +against disposable service fixtures, checking lost notifications, unchanged +retries, check mode, validation failures and disabled monitoring. It requires +the `containers.podman` Ansible collection and runs without container networking +or package downloads. The full `./test` runner includes this regression. + `python3 contrib/tests/test_nginx_latency.py` checks the timing parser and bounded route classification (requires Python Jinja2 and Lua). The `tsys-infra/monitoring` Podman suite exercises this repository's real monitoring role, nginx GET diff --git a/contrib/tests/test_deployment_recovery.py b/contrib/tests/test_deployment_recovery.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python3 +"""Exercise setup's failure/retry flow with disposable systemd service fixtures. + +Uses the real setup play, deployment state, recovery tasks and service handlers. +Package installation and application configuration are replaced by small roles +so the regression needs no Taler deployment, certificates or package downloads. +""" +import argparse +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import time + +import yaml + +REPO = Path(__file__).resolve().parents[2] +INFRASTRUCTURE = ['nginx', 'prometheus-node-exporter', 'node-exporter-proxy', 'fluent-bit'] + + +def run(*args, **kwargs): + return subprocess.run(list(map(str, args)), capture_output=True, text=True, + check=kwargs.pop('check', True), **kwargs) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--image', default='localhost/ansible-taler-test:latest', + help='Local Debian image with systemd, Python and dbus installed') + args = parser.parse_args() + with tempfile.TemporaryDirectory(prefix='taler-deployment-recovery-') as directory: + work = Path(directory) + container_name = 'taler-deployment-recovery-' + str(os.getpid()) + + def container(*cmd, **kwargs): + return run('podman', 'exec', '-i', container_name, *cmd, **kwargs) + + def write(path, value): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(yaml.safe_dump(value, sort_keys=False)) + + def role(name, tasks): + write(work / 'roles' / name / 'tasks/main.yml', tasks) + + def production_tasks(role_name): + return yaml.safe_load((REPO / 'roles' / role_name / 'tasks/main.yml').read_text()) + + def fail(stage): + return {'name': 'Inject deployment failure at ' + stage, + 'ansible.builtin.fail': {'msg': 'Injected regression failure'}, + 'when': f"failure_stage == '{stage}'"} + + shutil.copyfile(REPO / 'playbooks/setup.yml', work / 'setup.yml') + shutil.copytree(REPO / 'roles/deployment_state', work / 'roles/deployment_state') + for name in ['common_packages', 'database', 'libeufin-nexus', 'challenger', + 'exchange', 'auditor', 'devtesting']: + role(name, []) + role('stop_services', [ + {'ansible.builtin.systemd_service': {'name': 'taler-exchange-httpd', 'state': 'stopped'}}, + {'ansible.builtin.set_fact': {'stop_services_active_units': []}}]) + role('start_services', [ + {'ansible.builtin.systemd_service': {'name': 'taler-exchange-httpd', 'state': 'started'}}]) + role('post_deployment_checks', [fail('sanity-checks')]) + nginx_tasks = production_tasks('webserver') + role('webserver', [ + {'ansible.builtin.copy': {'content': '{{ revision }}\n', + 'dest': '/etc/nginx/nginx.conf', 'mode': '0644'}, + 'notify': 'Restart nginx'}, + *nginx_tasks[-2:]]) # Real validation/recovery notification and enablement. + for name in ['webserver', 'monitoring']: + shutil.copytree(REPO / 'roles' / name / 'handlers', work / 'roles' / name / 'handlers') + monitor_tasks = production_tasks('monitoring') + apply_index = next(i for i, task in enumerate(monitor_tasks) + if task['name'] == 'Load the configured monitoring units') + role('monitoring', [ + monitor_tasks[1], # Service discovery for check mode. + {'ansible.builtin.copy': {'content': '{{ revision }}\n', + 'dest': '{{ item }}', 'mode': '0600'}, + 'loop': ['/etc/fluent-bit/fluent-bit.conf', '/etc/taler-monitoring/nginx.conf', + '/etc/default/prometheus-node-exporter'], + 'notify': ['Restart monitoring Fluent Bit', 'Restart monitoring proxy', + 'Restart monitoring node_exporter', 'Restart monitoring journal']}, + fail('before-handlers'), *monitor_tasks[apply_index:]]) + shutil.copyfile(REPO / 'roles/monitoring/tasks/recover.yml', + work / 'roles/monitoring/tasks/recover.yml') + write(work / 'roles/monitoring/tasks/preflight.yml', []) + write(work / 'inventory.yml', {'all': {'hosts': {container_name: { + 'ansible_connection': 'containers.podman.podman', 'ansible_user': 'root', + 'ansible_python_interpreter': '/usr/bin/python3'}}}}) + (work / 'ansible.cfg').write_text('[defaults]\nroles_path = ' + str(work / 'roles') + '\n') + environment = dict(os.environ, ANSIBLE_CONFIG=str(work / 'ansible.cfg'), ANSIBLE_NOCOWS='1') + + def deploy(revision=1, failure_stage='', check=False, enabled=True, expect_failure=False): + variables = dict(revision=revision, failure_stage=failure_stage, + enable_monitoring=enabled, monitoring_nginx_latency_enabled=revision >= 2, + deployment_kind='regression', exchange_attribute_encryption_key='fixture', + devtesting_mock_mfa=False, deploy_auditor=False, deploy_challenger=False, + use_ebics=False, configure_ebics=False) + write(work / 'vars.yml', variables) + command = ['ansible-playbook', '-i', work / 'inventory.yml', work / 'setup.yml', + '-e', '@' + str(work / 'vars.yml')] + if check: + command.append('--check') + result = run(*command, env=environment, check=False) + if bool(result.returncode) != expect_failure: + raise AssertionError(result.stdout + result.stderr) + return result.stdout + + def pids(): + values = container('systemctl', 'show', '--value', '-p', 'MainPID', *INFRASTRUCTURE).stdout.split() + return dict(zip(INFRASTRUCTURE, values, strict=True)) + + def marker(): + return container('test', '-f', '/var/lib/taler-deployment/incomplete', check=False).returncode == 0 + + def app_active(): + return container('systemctl', 'is-active', '--quiet', 'taler-exchange-httpd', check=False).returncode == 0 + + def listener(): + return container('python3', '-c', + 'import urllib.request; print(urllib.request.urlopen(' + '"http://127.0.0.1:2021/metrics", timeout=2).status)', check=False).returncode == 0 + + run('podman', 'run', '--detach', '--name', container_name, '--network', 'none', + '--systemd', 'always', '--tmpfs', '/run', '--tmpfs', '/tmp', + args.image, '/sbin/init') + try: + for _ in range(30): + state = container('systemctl', 'is-system-running', check=False).stdout.strip() + if state in ['running', 'degraded']: + break + time.sleep(1) + else: + raise AssertionError('Test systemd did not start') + # Each fixture reads its configuration at startup. Fluent Bit's + # second revision opens the latency listener, just as on rusty. + service = work / 'service.py' + service.write_text('''#!/usr/bin/python3 +import http.server, pathlib, sys, time +name, config = sys.argv[1:] +revision = pathlib.Path(config).read_text().strip() +if name == 'fluent-bit' and int(revision) >= 2 and not pathlib.Path('/tmp/disable-metrics').exists(): + class Metrics(http.server.BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.end_headers() + def log_message(self, *args): + pass + http.server.HTTPServer(('127.0.0.1', 2021), Metrics).serve_forever() +else: + time.sleep(3600) +''') + container('mkdir', '-p', '/etc/nginx', '/etc/taler-monitoring', '/etc/fluent-bit', + '/opt/fluent-bit/bin') + run('podman', 'cp', service, f'{container_name}:/service.py') + # Validation commands fail on demand without involving real nginx + # certificates or Fluent Bit packages; service handlers are real. + for binary in ['/usr/sbin/nginx', '/opt/fluent-bit/bin/fluent-bit']: + container('sh', '-c', 'cat > "$1"; chmod 755 "$1"', 'sh', binary, + input='#!/bin/sh\ntest ! -e /tmp/reject-configuration\n') + for name, config in [ + ('nginx', '/etc/nginx/nginx.conf'), + ('node-exporter-proxy', '/etc/taler-monitoring/nginx.conf'), + ('prometheus-node-exporter', '/etc/default/prometheus-node-exporter'), + ('fluent-bit', '/etc/fluent-bit/fluent-bit.conf'), + ('taler-exchange-httpd', '/etc/nginx/nginx.conf')]: + container('tee', config, input='1\n') + container('tee', f'/etc/systemd/system/{name}.service', input=f''' +[Unit] +Description=Disposable deployment recovery fixture +[Service] +ExecStart=/usr/bin/python3 /service.py {name} {config} +[Install] +WantedBy=multi-user.target +''') + container('systemctl', 'daemon-reload') + before = pids() + deploy(check=True) + assert not marker() and pids() == before + deploy() + assert not marker() and app_active() and not listener() + before = pids() + deploy(revision=2, failure_stage='before-handlers', expect_failure=True) + assert marker() and not app_active() and pids() == before and not listener() + config_time = container('stat', '-c', '%Y', '/etc/fluent-bit/fluent-bit.conf').stdout + deploy(revision=2, check=True) + assert marker() and not app_active() and pids() == before and not listener() + deploy(revision=2, failure_stage='before-handlers', expect_failure=True) + assert marker() and not app_active() and pids() == before + deploy(revision=2) + assert not marker() and app_active() and pids() != before and listener() + assert all(pids()[unit] != before[unit] for unit in INFRASTRUCTURE) + assert container('stat', '-c', '%Y', '/etc/fluent-bit/fluent-bit.conf').stdout == config_time + print('PASS: failed configuration and unchanged retries recover; check mode preserves state', flush=True) + + before = pids() + deploy(revision=2) + deploy(revision=2, check=True) + assert not marker() and app_active() and pids() == before and listener() + print('PASS: successful reruns and check mode leave infrastructure running', flush=True) + + container('systemctl', 'stop', 'fluent-bit') + container('touch', '/tmp/disable-metrics') + deploy(revision=2, expect_failure=True) + assert marker() and not app_active() and not listener() + container('systemctl', 'is-active', '--quiet', 'fluent-bit') + container('rm', '/tmp/disable-metrics') + deploy(revision=2) + assert not marker() and app_active() and listener() + print('PASS: a running collector without a metrics listener fails deployment and recovers on retry', flush=True) + + before = pids() + deploy(revision=2, failure_stage='sanity-checks', expect_failure=True) + assert marker() and not app_active() + container('touch', '/tmp/reject-configuration') + deploy(revision=2, expect_failure=True) + assert marker() and not app_active() and pids() == before + container('rm', '/tmp/reject-configuration') + deploy(revision=2, enabled=False) + after = pids() + assert not marker() and app_active() + assert all(after[unit] == before[unit] for unit in INFRASTRUCTURE if unit != 'nginx'), \ + 'Disabled monitoring was restarted' + assert after != before, 'Nginx recovery was skipped with monitoring disabled' + print('PASS: late failures and validation failures retain recovery state; disabled monitoring is untouched', flush=True) + finally: + run('podman', 'rm', '--force', container_name) + + +if __name__ == '__main__': + main() diff --git a/playbooks/setup.yml b/playbooks/setup.yml @@ -77,6 +77,10 @@ tasks: - name: Deploy with applications stopped until configuration is complete block: + - name: Track deployment completion before modifying services + ansible.builtin.include_role: + name: deployment_state + - name: Stop existing applications before any package upgrades ansible.builtin.include_role: name: stop_services @@ -146,6 +150,11 @@ ansible.builtin.include_role: name: post_deployment_checks + - name: Record successful deployment + ansible.builtin.include_role: + name: deployment_state + tasks_from: complete + rescue: - name: Leave applications stopped after a deployment failure ansible.builtin.include_role: diff --git a/roles/deployment_state/defaults/main.yml b/roles/deployment_state/defaults/main.yml @@ -0,0 +1,2 @@ +--- +deployment_state_directory: /var/lib/taler-deployment diff --git a/roles/deployment_state/tasks/complete.yml b/roles/deployment_state/tasks/complete.yml @@ -0,0 +1,7 @@ +--- +- name: Record successful deployment after handlers and sanity checks + ansible.builtin.file: + path: "{{ deployment_state_directory }}/incomplete" + state: absent + changed_when: false + when: not ansible_check_mode diff --git a/roles/deployment_state/tasks/main.yml b/roles/deployment_state/tasks/main.yml @@ -0,0 +1,32 @@ +--- +- name: Check for an incomplete deployment + ansible.builtin.stat: + path: "{{ deployment_state_directory }}/incomplete" + register: deployment_state_previous + +- name: Remember whether configuration handlers need to be recovered + ansible.builtin.set_fact: + deployment_recover_configuration: "{{ deployment_state_previous.stat.exists }}" + +# This bookkeeping is created and removed on every successful deployment. +# It should not count as a configuration change or modify hosts in check mode. +- name: Create the private deployment state directory + ansible.builtin.file: + path: "{{ deployment_state_directory }}" + state: directory + owner: root + group: root + mode: "0700" + changed_when: false + when: not ansible_check_mode + +- name: Record the incomplete deployment before modifying services + ansible.builtin.copy: + content: "Configuration may not have been applied by a completed deployment.\n" + dest: "{{ deployment_state_directory }}/incomplete" + force: false + owner: root + group: root + mode: "0600" + changed_when: false + when: not ansible_check_mode diff --git a/roles/monitoring/handlers/main.yml b/roles/monitoring/handlers/main.yml @@ -40,9 +40,16 @@ changed_when: true when: not ansible_check_mode -- name: Restart monitoring Fluent Bit +- name: Validate monitoring Fluent Bit before restart + ansible.builtin.command: /opt/fluent-bit/bin/fluent-bit --dry-run -c /etc/fluent-bit/fluent-bit.conf + changed_when: false + listen: Restart monitoring Fluent Bit + when: not ansible_check_mode + +- name: Restart validated monitoring Fluent Bit ansible.builtin.systemd_service: name: fluent-bit daemon_reload: true state: restarted + listen: Restart monitoring Fluent Bit when: not ansible_check_mode diff --git a/roles/monitoring/tasks/main.yml b/roles/monitoring/tasks/main.yml @@ -103,6 +103,9 @@ daemon_reload: true when: not ansible_check_mode +- name: Recover monitoring handlers lost by an interrupted deployment + ansible.builtin.import_tasks: recover.yml + # Move an existing legacy exporter off port 9100 before starting the proxy. # This also loads renewed certificate pairs before reporting success. - name: Apply monitoring configuration before enabling services @@ -118,3 +121,17 @@ - node-exporter-proxy - fluent-bit when: not ansible_check_mode or (item ~ '.service') in ansible_facts['services'] + +- name: Wait for the enabled exchange latency exporter + ansible.builtin.uri: + url: http://127.0.0.1:2021/metrics + status_code: 200 + use_proxy: false + timeout: 5 + register: monitoring_latency_exporter_check + until: monitoring_latency_exporter_check.status | default(-1) == 200 + retries: 10 + delay: 1 + when: + - monitoring_nginx_latency_enabled + - not ansible_check_mode diff --git a/roles/monitoring/tasks/recover.yml b/roles/monitoring/tasks/recover.yml @@ -0,0 +1,13 @@ +--- +# An interrupted play loses its handler notifications even though its file +# changes survive. Requeue them only after this role has configured everything. +- name: Reapply monitoring configuration after an incomplete deployment + ansible.builtin.debug: + msg: Reapplying monitoring configuration left by an incomplete deployment. + changed_when: true + when: deployment_recover_configuration | default(false) | bool + notify: + - Restart monitoring node_exporter + - Restart monitoring proxy + - Restart monitoring journal + - Restart monitoring Fluent Bit diff --git a/roles/webserver/tasks/main.yml b/roles/webserver/tasks/main.yml @@ -66,7 +66,9 @@ - name: Validate nginx configuration without removing enabled sites ansible.builtin.command: nginx -c /etc/nginx/nginx.conf -t - changed_when: false + # Files written by an interrupted deployment no longer notify on a retry. + changed_when: deployment_recover_configuration | default(false) | bool + notify: Restart nginx check_mode: false - name: Ensure Nginx service is enabled and started diff --git a/test b/test @@ -29,6 +29,9 @@ python3 contrib/tests/test_monitoring_listeners.py # Build our image podman build -f Containerfile -t "$test_image" +# Exercise interrupted deployment recovery with isolated service fixtures. +python3 contrib/tests/test_deployment_recovery.py --image "localhost/$test_image" + # Run in background (-d) with systemd init. Taler's hardened systemd units # require capabilities that Podman otherwise removes from the container. podman run \