ansible-taler-exchange

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

commit 35e55f0716866aefb0e72de35f5db9bc38f9dcad
parent b1acb948c36a5263b5a07baf6ef91c6c2c2caae9
Author: Florian Dold <dold@taler.net>
Date:   Fri,  4 Sep 2026 20:44:41 +0200

podman test: exercise setup check mode on Trixie

Run the Trixie container deployment with disposable SSH credentials and
local test certificates, then repeat setup in check mode and exercise
the deployed services and HTTP endpoints.

Keep read-only nginx, certificate, and post-deployment probes active in
check mode so their registered results remain meaningful.

Diffstat:
MContainerfile | 7++++++-
MREADME | 13+++++++++++++
Acontrib/test-certbot | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minventories/host_vars/podman-localhost/test-public.yml | 2++
Mroles/cert/tasks/main.yml | 22++++++++++++++++++++--
Mroles/post_deployment_checks/defaults/main.yml | 9+++++++++
Mroles/post_deployment_checks/tasks/main.yml | 2++
Mroles/webserver/tasks/main.yml | 1+
Atest-ansible.cfg | 6++++++
Mtest.sh | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
10 files changed, 225 insertions(+), 18 deletions(-)

diff --git a/Containerfile b/Containerfile @@ -1,4 +1,4 @@ -FROM docker.io/library/debian:bookworm +FROM docker.io/library/debian:trixie ENV DEBIAN_FRONTEND=noninteractive @@ -16,6 +16,11 @@ RUN apt-get update -yqq && \ RUN mkdir -p /etc/ansible/facts.d +# Public ACME validation cannot reach this disposable local container. Keep +# the production certificate role in the test path while issuing local, +# short-lived certificates instead. +COPY --chmod=0755 contrib/test-certbot /usr/local/bin/certbot + ##################################################################### ## WARNING: THIS ALLOWS FOR COMPLETELY UNAUTHENTICATED SSH SESSIONS # ####### FOR TESTING ENVIRONMENT ONLY! ############################### diff --git a/README b/README @@ -22,6 +22,19 @@ $ gpg -d vault_pass.txt.gpg > vault_pass.txt The canonical playbooks are run via shell scripts in the top-level directory. +### Local Podman deployment test + +Run `./test.sh` to build a Debian Trixie container, provision the complete test +deployment, repeat `setup.yml` with `--check --diff`, and exercise the main +systemd units and HTTP APIs. The script creates a disposable SSH key and +removes the container when it exits. + +The container deliberately runs privileged because the hardened Taler systemd +units need capabilities which Podman otherwise removes. It also uses the +pregenerated DH parameters and a container-only Certbot substitute which +creates short-lived self-signed certificates: public ACME validation cannot +reach the local test domains. Neither shortcut is used by real deployments. + ### Main setup (restore.sh, deploy.sh) Database restore is deliberately separate from normal deployment. It is a diff --git a/contrib/test-certbot b/contrib/test-certbot @@ -0,0 +1,94 @@ +#!/bin/bash +set -euo pipefail + +# Minimal Certbot substitute for the disposable Podman test container. It +# supports only the two invocations made by roles/cert and must never be used +# on a real deployment. + +certbot_command="" +cert_name="" +domains=() + +while (( $# > 0 )); do + case "$1" in + certificates | certonly) + certbot_command="$1" + shift + ;; + --cert-name) + cert_name="${2:?missing certificate name}" + shift 2 + ;; + -d | --domains) + domains+=("${2:?missing domain name}") + shift 2 + ;; + --email) + shift 2 + ;; + *) + shift + ;; + esac +done + +if [[ -z "$certbot_command" || -z "$cert_name" ]]; then + echo "test-certbot: unsupported invocation" >&2 + exit 2 +fi + +if [[ ! "$cert_name" =~ ^[A-Za-z0-9._-]+$ ]]; then + echo "test-certbot: invalid certificate name" >&2 + exit 2 +fi + +state_dir=/var/lib/test-certbot +state_file="$state_dir/$cert_name.domains" +certificate_dir="/etc/letsencrypt/live/$cert_name" + +case "$certbot_command" in + certificates) + if [[ -f "$state_file" ]]; then + printf ' Certificate Name: %s\n' "$cert_name" + printf ' Domains: %s\n' "$(<"$state_file")" + fi + ;; + certonly) + if (( ${#domains[@]} == 0 )); then + echo "test-certbot: certonly requires at least one domain" >&2 + exit 2 + fi + + requested_domains="${domains[*]}" + current_domains="" + if [[ -f "$state_file" ]]; then + current_domains="$(<"$state_file")" + fi + + if [[ "$current_domains" == "$requested_domains" && -f "$certificate_dir/fullchain.pem" ]]; then + echo "Certificate not yet due for renewal." + exit 0 + fi + + install -d -m 0755 "$state_dir" "$certificate_dir" + subject_alt_name="" + for domain in "${domains[@]}"; do + subject_alt_name+="${subject_alt_name:+,}DNS:$domain" + done + + openssl req \ + -x509 \ + -newkey rsa:2048 \ + -nodes \ + -keyout "$certificate_dir/privkey.pem" \ + -out "$certificate_dir/fullchain.pem" \ + -subj "/CN=${domains[0]}" \ + -addext "subjectAltName=$subject_alt_name" \ + -days 2 \ + >/dev/null 2>&1 + cp "$certificate_dir/fullchain.pem" "$certificate_dir/chain.pem" + chmod 0600 "$certificate_dir/privkey.pem" + printf '%s\n' "$requested_domains" > "$state_file" + echo "Successfully received test certificate." + ;; +esac diff --git a/inventories/host_vars/podman-localhost/test-public.yml b/inventories/host_vars/podman-localhost/test-public.yml @@ -4,6 +4,8 @@ # Other customizations depend on this. # Can be "gls" or "tops" (later: "magnet") deployment_kind: "tops" +# Keep disposable container provisioning fast and deterministic. +use_pregenerated_dhparam: true # Main domain name. domain_name: "topstest.fdold.eu" exchange_domain: "exchange.{{ domain_name }}" diff --git a/roles/cert/tasks/main.yml b/roles/cert/tasks/main.yml @@ -11,6 +11,7 @@ ansible.builtin.command: nginx -c /etc/nginx/nginx.conf -t register: result changed_when: false + check_mode: false ignore_errors: true - name: Fail if nginx misconfigured @@ -21,13 +22,30 @@ - name: Check if domains have changed block: - name: Register certificate domains - shell: "certbot certificates --cert-name {{ cert_name }} | grep Domains | cut -d':' -f2" + ansible.builtin.command: + argv: + - certbot + - certificates + - --cert-name + - "{{ cert_name }}" changed_when: false + check_mode: false + failed_when: false register: cert_domains_dirty - name: Cleanup domain list set_fact: - actual_cert_domains: "{{ cert_domains_dirty.stdout | trim | split(' ') | map('trim') | select('!=', '') | list | sort }}" + actual_cert_domains: >- + {{ + cert_domains_dirty.stdout + | regex_findall('(?m)^\s*Domains:\s*(.*)$') + | join(' ') + | split(' ') + | map('trim') + | select('!=', '') + | list + | sort + }} - name: Determine if domains have changed set_fact: diff --git a/roles/post_deployment_checks/defaults/main.yml b/roles/post_deployment_checks/defaults/main.yml @@ -32,6 +32,10 @@ post_deployment_systemd_checks: and 'taler-exchange-sanctionscheck.service' in ansible_facts.services }} + # Auditor + - unit: taler-auditor-httpd.service + enabled: "{{ deploy_auditor | bool }}" + # LibEuFin Nexus - unit: libeufin-nexus-httpd.service enabled: true @@ -60,6 +64,11 @@ post_deployment_http_checks: url: "http://127.0.0.1:{{ libeufin_port }}/taler-prepared-transfer/config" enabled: true + - name: Taler auditor /config + url: http://localhost/config + unix_socket: /var/run/taler-auditor/httpd/auditor-http.sock + enabled: "{{ deploy_auditor | bool }}" + - name: SMS Challenger /config url: http://localhost/config unix_socket: /run/challenger-sms/challenger-http.sock diff --git a/roles/post_deployment_checks/tasks/main.yml b/roles/post_deployment_checks/tasks/main.yml @@ -14,6 +14,7 @@ label: "{{ item.unit }}" register: post_deployment_systemd_check changed_when: false + check_mode: false failed_when: false retries: "{{ post_deployment_check_retries }}" delay: "{{ post_deployment_check_delay }}" @@ -32,6 +33,7 @@ label: "{{ item.name }}" register: post_deployment_http_check changed_when: false + check_mode: false failed_when: false retries: "{{ post_deployment_check_retries }}" delay: "{{ post_deployment_check_delay }}" diff --git a/roles/webserver/tasks/main.yml b/roles/webserver/tasks/main.yml @@ -55,6 +55,7 @@ ansible.builtin.command: nginx -c /etc/nginx/nginx.conf -t register: result changed_when: false + check_mode: false ignore_errors: true - name: Clear all sites if nginx is misconfigured diff --git a/test-ansible.cfg b/test-ansible.cfg @@ -0,0 +1,6 @@ +[defaults] +roles_path = roles +host_key_checking = False + +[ssh_connection] +pipelining = True diff --git a/test.sh b/test.sh @@ -1,28 +1,85 @@ #!/bin/bash set -exuo pipefail +repo_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +cd "$repo_dir" + +test_container=ansible-taler-test +test_state_dir=$(mktemp -d) + +cleanup() { + 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 + # Build our image -podman build -f Containerfile -t ansible-taler-test +podman build -f Containerfile -t "$test_container" -# Run in background (-d) with systemd init +# Run in background (-d) with systemd init. Taler's hardened systemd units +# require capabilities that Podman otherwise removes from the container. podman run \ --rm \ - --name ansible-taler-test \ + --name "$test_container" \ -p 127.0.0.1:8022:22 \ --systemd=always \ - -d localhost/ansible-taler-test sh -c "exec /usr/sbin/init --show-status" + --privileged \ + -d "localhost/$test_container" 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. +podman exec "$test_container" install -d -m 0700 /root/.ssh +podman cp "$test_state_dir/id_ed25519.pub" "$test_container:/root/.ssh/authorized_keys" +podman exec "$test_container" chmod 0600 /root/.ssh/authorized_keys # Print to log that container is running podman ps -# Clear out fingerprint from any past runs. The container gets fresh host -# keys every time. Tolerate the file not existing yet. -ssh-keygen -f "$HOME/.ssh/known_hosts" -R "[127.0.0.1]:8022" || true - -# Run our playbook(s) -ansible-playbook \ - --verbose \ - -i inventories/default \ - -l "podman-localhost" \ - --user root \ - playbooks/setup.yml +export ANSIBLE_CONFIG="$repo_dir/test-ansible.cfg" +export ANSIBLE_SSH_ARGS="-o ControlMaster=auto -o ControlPersist=60s -o StrictHostKeyChecking=no -o UserKnownHostsFile=$test_state_dir/known_hosts" + +for attempt in {1..30}; do + if ssh \ + -i "$test_state_dir/id_ed25519" \ + -p 8022 \ + -o StrictHostKeyChecking=no \ + -o "UserKnownHostsFile=$test_state_dir/known_hosts" \ + root@127.0.0.1 true; then + break + fi + if (( attempt == 30 )); then + echo "SSH did not become ready in the test container" >&2 + exit 1 + fi + sleep 1 +done + +ansible_args=( + -i inventories/default + -l podman-localhost + --user root + --private-key "$test_state_dir/id_ed25519" + playbooks/setup.yml +) + +# Provision, then prove that a separate check-mode run can inspect the result. +ansible-playbook --verbose "${ansible_args[@]}" +ansible-playbook --check --diff "${ansible_args[@]}" + +# Basic smoke checks independent of Ansible's post-deployment checks. +podman exec "$test_container" systemctl is-active --quiet \ + taler-exchange.target \ + taler-exchange-httpd.service \ + libeufin-nexus-httpd.service \ + taler-auditor-httpd.service +podman exec "$test_container" curl --fail --silent --show-error \ + --unix-socket /var/run/taler-exchange/httpd/exchange-http.sock \ + http://localhost/config >/dev/null +podman exec "$test_container" curl --fail --silent --show-error \ + http://127.0.0.1:8082/taler-prepared-transfer/config >/dev/null +podman exec "$test_container" curl --fail --silent --show-error \ + --unix-socket /var/run/taler-auditor/httpd/auditor-http.sock \ + http://localhost/config >/dev/null