From e1e25e6021c353e987611146ab1a84729d948239 Mon Sep 17 00:00:00 2001
From: ale <ale@incal.net>
Date: Mon, 3 May 2021 17:23:10 +0100
Subject: [PATCH] Refactor for simpler testing

Remove the prod configuration, moved to a separate project. Add a
"test.sh" script to programmatically create ephemeral test
environments.
---
 Vagrantfile => Vagrantfile.in                 |  18 ++-
 ansible.cfg.in                                |  14 ++
 ci/vmine.py                                   | 150 ++++++++++++++++++
 group_vars/all/admins.yml                     |  35 ----
 group_vars/all/secrets.yml                    |  21 ---
 group_vars/all/vars.yml                       |  24 ---
 hosts-vagrant.ini                             |  23 ---
 hosts.ini                                     |  30 ----
 hosts.ini.in                                  |  30 ++++
 roles/base/tasks/main.yml                     |   8 +-
 .../templates/apt/50unattended-upgrades.j2    |   6 +-
 roles/base/templates/apt/90proxy.j2           |   4 +
 roles/monitor/defaults/main.yml               |   2 +-
 roles/monitor/tasks/grafana.yml               |   2 +-
 roles/network-config/defaults/main.yml        |   7 -
 roles/network-config/tasks/main.yml           |   8 +
 site.yml                                      |  21 ---
 test.sh                                       | 116 ++++++++++++++
 18 files changed, 347 insertions(+), 172 deletions(-)
 rename Vagrantfile => Vagrantfile.in (54%)
 create mode 100644 ansible.cfg.in
 create mode 100755 ci/vmine.py
 delete mode 100644 group_vars/all/admins.yml
 delete mode 100644 group_vars/all/secrets.yml
 delete mode 100644 group_vars/all/vars.yml
 delete mode 100644 hosts-vagrant.ini
 delete mode 100644 hosts.ini
 create mode 100644 hosts.ini.in
 create mode 100644 roles/base/templates/apt/90proxy.j2
 delete mode 100644 roles/network-config/defaults/main.yml
 create mode 100755 test.sh

diff --git a/Vagrantfile b/Vagrantfile.in
similarity index 54%
rename from Vagrantfile
rename to Vagrantfile.in
index c17f16d..231fcd4 100644
--- a/Vagrantfile
+++ b/Vagrantfile.in
@@ -1,8 +1,10 @@
 
 NUM_HOSTS = 3
 
+RAM = 1024
+
 Vagrant.configure(2) do |config|
-  config.vm.box = "debian/buster64"
+  config.vm.box = "debian/@DIST@64"
 
   # Use the old insecure Vagrant SSH key for access.
   config.ssh.insert_key = false
@@ -10,19 +12,25 @@ Vagrant.configure(2) do |config|
   # Disable synchronization of the /vagrant folder for faster startup.
   config.vm.synced_folder ".", "/vagrant", disabled: true
 
-  # Increase RAM to 1G.
+  # Set VM memory size (provider-dependant).
   config.vm.provider :virtualbox do |vb|
-    vb.customize ["modifyvm", :id, "--memory", "1024"]
+    vb.customize ["modifyvm", :id, "--memory", RAM.to_s]
   end
   config.vm.provider :libvirt do |libvirt|
-    libvirt.memory = 1024
+    libvirt.memory = RAM
+    if !ENV['LIBVIRT_HOST'].nil? then
+      libvirt.remote_host = ENV['LIBVIRT_HOST']
+    end
+    if !ENV['LIBVIRT_USER'].nil? then
+      libvirt.remote_user = ENV['LIBVIRT_USER']
+    end
   end
 
   # Create progressively numbered hosts 'hostN', with IP 9+N.
   (1..NUM_HOSTS).each do |i|
     config.vm.define "host#{i}" do |m|
       m.vm.hostname = "host#{i}"
-      m.vm.network "private_network", ip: "10.236.82.#{9+i}", libvirt__dhcp_enabled: false
+      m.vm.network "private_network", ip: "@IP_NET@.#{9+i}", libvirt__dhcp_enabled: false
     end
   end
 end
diff --git a/ansible.cfg.in b/ansible.cfg.in
new file mode 100644
index 0000000..6c26efb
--- /dev/null
+++ b/ansible.cfg.in
@@ -0,0 +1,14 @@
+[defaults]
+roles_path = @REPO_ROOT@/roles
+strategy_plugins = @MITOGEN_PATH@/plugins/strategy
+strategy = mitogen_linear
+display_skipped_hosts = False
+nocows = 1
+force_handlers = True
+host_key_checking = False
+
+[ssh_connection]
+ssh_args = -C -o ControlMaster=auto -o ControlPersist=120s @SSH_OPTS@
+control_path_dir = ~/.ansible/cp
+control_path = %(directory)s/%%h-%%r
+pipelining = True
diff --git a/ci/vmine.py b/ci/vmine.py
new file mode 100755
index 0000000..d11b0a9
--- /dev/null
+++ b/ci/vmine.py
@@ -0,0 +1,150 @@
+#!/usr/bin/env python3
+#
+# Read a hosts.yml float inventory, and manage a VM group derived by it.
+# This tool is meant to replace "vagrant up" in a CI pipeline.
+#
+
+import argparse
+import json
+import os
+import re
+import shlex
+import subprocess
+
+
+# The Vagrant "insecure" SSH key that is used to log onto the VMs.
+INSECURE_PRIVATE_KEY = '''-----BEGIN RSA PRIVATE KEY-----
+MIIEogIBAAKCAQEA6NF8iallvQVp22WDkTkyrtvp9eWW6A8YVr+kz4TjGYe7gHzI
+w+niNltGEFHzD8+v1I2YJ6oXevct1YeS0o9HZyN1Q9qgCgzUFtdOKLv6IedplqoP
+kcmF0aYet2PkEDo3MlTBckFXPITAMzF8dJSIFo9D8HfdOV0IAdx4O7PtixWKn5y2
+hMNG0zQPyUecp4pzC6kivAIhyfHilFR61RGL+GPXQ2MWZWFYbAGjyiYJnAmCP3NO
+Td0jMZEnDkbUvxhMmBYSdETk1rRgm+R4LOzFUGaHqHDLKLX+FIPKcF96hrucXzcW
+yLbIbEgE98OHlnVYCzRdK8jlqm8tehUc9c9WhQIBIwKCAQEA4iqWPJXtzZA68mKd
+ELs4jJsdyky+ewdZeNds5tjcnHU5zUYE25K+ffJED9qUWICcLZDc81TGWjHyAqD1
+Bw7XpgUwFgeUJwUlzQurAv+/ySnxiwuaGJfhFM1CaQHzfXphgVml+fZUvnJUTvzf
+TK2Lg6EdbUE9TarUlBf/xPfuEhMSlIE5keb/Zz3/LUlRg8yDqz5w+QWVJ4utnKnK
+iqwZN0mwpwU7YSyJhlT4YV1F3n4YjLswM5wJs2oqm0jssQu/BT0tyEXNDYBLEF4A
+sClaWuSJ2kjq7KhrrYXzagqhnSei9ODYFShJu8UWVec3Ihb5ZXlzO6vdNQ1J9Xsf
+4m+2ywKBgQD6qFxx/Rv9CNN96l/4rb14HKirC2o/orApiHmHDsURs5rUKDx0f9iP
+cXN7S1uePXuJRK/5hsubaOCx3Owd2u9gD6Oq0CsMkE4CUSiJcYrMANtx54cGH7Rk
+EjFZxK8xAv1ldELEyxrFqkbE4BKd8QOt414qjvTGyAK+OLD3M2QdCQKBgQDtx8pN
+CAxR7yhHbIWT1AH66+XWN8bXq7l3RO/ukeaci98JfkbkxURZhtxV/HHuvUhnPLdX
+3TwygPBYZFNo4pzVEhzWoTtnEtrFueKxyc3+LjZpuo+mBlQ6ORtfgkr9gBVphXZG
+YEzkCD3lVdl8L4cw9BVpKrJCs1c5taGjDgdInQKBgHm/fVvv96bJxc9x1tffXAcj
+3OVdUN0UgXNCSaf/3A/phbeBQe9xS+3mpc4r6qvx+iy69mNBeNZ0xOitIjpjBo2+
+dBEjSBwLk5q5tJqHmy/jKMJL4n9ROlx93XS+njxgibTvU6Fp9w+NOFD/HvxB3Tcz
+6+jJF85D5BNAG3DBMKBjAoGBAOAxZvgsKN+JuENXsST7F89Tck2iTcQIT8g5rwWC
+P9Vt74yboe2kDT531w8+egz7nAmRBKNM751U/95P9t88EDacDI/Z2OwnuFQHCPDF
+llYOUI+SpLJ6/vURRbHSnnn8a/XG+nzedGH5JGqEJNQsz+xT2axM0/W/CRknmGaJ
+kda/AoGANWrLCz708y7VYgAtW2Uf1DPOIYMdvo6fxIB5i9ZfISgcJ/bbCUkFrhoH
++vq/5CIWxCPp0f85R4qxxQ5ihxJ0YDQT9Jpx4TMss4PSavPaBH3RXow5Ohe+bYoQ
+NE5OgEXk2wVfZczCZpigBKbKZHNYcelXtTt/nP3rsCuGcM4h53s=
+-----END RSA PRIVATE KEY-----
+'''
+
+
+def parse_inventory(spec, host_attrs):
+    hosts = []
+    for s in spec:
+        name, addr = s.split('=')
+        hosts.append({'name': name, 'ip': addr})
+    for h in hosts:
+        h.update(host_attrs)
+
+    # We know that the network is a /24.
+    net = re.sub(r'\.[0-9]+$', '.0/24', hosts[0]['ip'])
+    return {
+        'network': net,
+        'hosts': hosts,
+    }
+
+
+def do_request(url, ssh_gw, payload):
+    data = json.dumps(payload)
+    cmd = "curl -s -X POST -H 'Content-Type: application/json' -d %s %s" % (
+        shlex.quote(data), url)
+    if ssh_gw:
+        cmd = "ssh %s %s" % (ssh_gw, shlex.quote(cmd))
+
+    output = subprocess.check_output(cmd, shell=True)
+    try:
+        return json.loads(output)
+    except json.decoder.JSONDecodeError:
+        print(f'server error: {output}')
+        raise
+
+
+def install_ssh_key():
+    # Install the SSH key as Vagrant would do, for compatibility.
+    key_path = os.path.join(
+        os.getenv('HOME'), '.vagrant.d', 'insecure_private_key')
+    if os.path.exists(key_path):
+        return
+    os.makedirs(os.path.dirname(key_path), mode=0o700, exist_ok=True)
+    with open(key_path, 'w') as fd:
+        fd.write(INSECURE_PRIVATE_KEY)
+    os.chmod(key_path, 0o600)
+
+
+def main():
+    ci_job_id = os.getenv('CI_JOB_ID')
+    parser = argparse.ArgumentParser()
+    parser.add_argument(
+        '--url', metavar='URL', default='http://127.0.0.1:4949',
+        help='URL of the vmine API server')
+    parser.add_argument(
+        '--ssh', metavar='USER@HOST',
+        help='proxy the vmine API request through SSH')
+    parser.add_argument(
+        '--state-file', metavar='FILE',
+        default=f'.vmine_group-{ci_job_id}' if ci_job_id else '.vmine_group',
+        help='state file to store the vmine group ID')
+    parser.add_argument(
+        '--image', metavar='NAME',
+        help='base image to use for the VMs')
+    parser.add_argument(
+        '--ram', type=int,
+        help='memory reservation for the VMs')
+    parser.add_argument(
+        '--ttl', metavar='DURATION', default='1h',
+        help='TTL for the virtual machines')
+    parser.add_argument(
+        'cmd',
+        choices=['up', 'down'])
+    parser.add_argument(
+        'inventory', nargs='*')
+    args = parser.parse_args()
+
+    if args.cmd == 'up':
+        host_attrs = {}
+        if args.ram:
+            host_attrs['ram'] = args.ram
+        if args.image:
+            host_attrs['image'] = args.image
+        req = parse_inventory(args.inventory, host_attrs)
+        req['ttl'] = args.ttl
+
+        print('creating VM group...')
+        resp = do_request(args.url + '/api/create-group', args.ssh, req)
+        group_id = resp['group_id']
+        with open(args.state_file, 'w') as fd:
+            fd.write(group_id)
+        print(f'created VM group {group_id}')
+
+        install_ssh_key()
+
+    elif args.cmd == 'down':
+        try:
+            with open(args.state_file) as fd:
+                group_id = fd.read().strip()
+        except FileNotFoundError:
+            print('state file not found, exiting')
+            return
+        print(f'stopping VM group {group_id}...')
+        do_request(args.url + '/api/stop-group', args.ssh,
+                   {'group_id': group_id})
+        os.remove(args.state_file)
+
+
+if __name__ == '__main__':
+    main()
diff --git a/group_vars/all/admins.yml b/group_vars/all/admins.yml
deleted file mode 100644
index 00872e2..0000000
--- a/group_vars/all/admins.yml
+++ /dev/null
@@ -1,35 +0,0 @@
-$ANSIBLE_VAULT;1.1;AES256
-61313436643761313439323963326433363436383861653934663464336437313036393636636465
-3234366165373836636463343333336532333838393938390a616131663734306431653937376233
-39613665393131636462376366386165393038636633393232643638663239396165396337386466
-6630636332366231660a666233653530653435303232313136376464373961373363356637613533
-61386134326335386661343930323131343935643431346239633935306333363030393238626234
-61313634346633313661666662633636643234343336666232376239303434343032333831343638
-39623564313036376130623037646232346633326536646564633635353238663164303537643532
-61313330346263376565313631643639363734333934623538383466353335656265666337643665
-61666163386265643065393334626136623637643030313563663336316366376463313361346231
-34316365326564396237656435303930653562656539643261623664346466386365346237346637
-30633132383936313664363066346165613737343739633334336337336236636539336562376662
-32663636666533333665626463653063326636343831313435633964313735623733373035663037
-63303738313161626163626466613831333536353336623566356462646339383435303438303639
-30366465666361333335343936626637653834646363343566646432383835623230326566623465
-38383430646163386235626435376439393337306565346462336530653663623166376333353530
-62313834373462646564646537366132653533313263383136653766396665666137366432386134
-66663965393035663465353031326232616432346263333562333738383661356464376435646539
-62663137326466636139663330656638376531343233333538313033613861633265353330316265
-65623834383339373466663239346636356232346565623131653361363932353264393263303361
-62326461373237383830316264653732343433393030643135646233343461666462653734663763
-63373035643438643835666563363533316337353866383635643233303430323134663132346465
-62376661323166333132383334396566666265313030333733306161636632323337393663616266
-34353234373262343034613136303365623262376535376264386634323762646661303937313165
-62396465343561393333373233643462326236396134626663393231303236363937663365646661
-39346462393465386532653934643839323633626435323266663662373466626237336632376433
-38366331643536376137313132316436303965383362376566323863663163613766396439663066
-31303136373536376265663636343131363033343063323536303032363864313163643763646233
-65383232363632373035643639346563653363383938633735633162326232306161303933393836
-37323032363532313263323764613966313761386530373732623236313137376430333264343564
-39323536396535333863343537343931336138643334343837383661393861346231663565613631
-33656466616635653836663236333632393365663262383464323963666665643266623866343065
-33326263313234313734393439386331396439666639636236383165386166373565383465383663
-63303037313631333164343365373463653737656435343031306231343137393837373838306664
-3165333435393564363937346534346662316661333561323366
diff --git a/group_vars/all/secrets.yml b/group_vars/all/secrets.yml
deleted file mode 100644
index 0e94bd7..0000000
--- a/group_vars/all/secrets.yml
+++ /dev/null
@@ -1,21 +0,0 @@
-$ANSIBLE_VAULT;1.1;AES256
-36626434666634383462356231373263343366616136306264303332626261363963666131383730
-3138346236653531333433663935346331643562336666640a346533326363663939373365663363
-39633265306233333836346237346161356165386563363437353062343434353665326139356261
-6234386638633833380a616235333366363661656435366137333461666235363566666537346663
-66383564313137663464663231333433343964326230393931393730323365383937646532303538
-33353462386133646137386337313336613163383530376264313937623464303366356234386531
-36663765653334346165633138626166303363616366343261633738633766633064363231393263
-37643937633664333433333237646537313937303335323062656532396561663866373063346536
-36353739333765396633656665616366373464626164366161623664303033313331643336366563
-64323832313466376634383464313631396563383435636136353063356163386463323638373861
-32653261373433346432313863666139393730366637633439376330386431306638303166333364
-33313765626239633539356664356333313563616664346131663466623231343431616265303561
-61666565396532383362356536313234616135623134623331646638363363363661633635393262
-37326632396663663262356533376538353236353333353363336235383366303931326538326539
-37653565356534616666663566316565616536393931306535633935316561323837353632313766
-32636162363266303965313135643837306664383435303139643939643732373464626361316131
-30313533623063623234393562323866663061616164376631633430636130613164323733633861
-66393461623365333036363361643764643066306162346539653635666131376636633862623538
-37613735366334626335636234386435643163323734626634623263623865643032333765633239
-39616165623539643438
diff --git a/group_vars/all/vars.yml b/group_vars/all/vars.yml
deleted file mode 100644
index ad33d2d..0000000
--- a/group_vars/all/vars.yml
+++ /dev/null
@@ -1,24 +0,0 @@
----
-
-public_domain: "streampunk.cc"
-
-admin_emails:
-  - info@streampunk.cc
-
-smtp_server: "mail.gandi.net"
-smtp_auth_user: "info@streampunk.cc"
-
-backup_repository: "s3:https://storage.incal.net:9000/streampunk/backup"
-
-ssl_additional_names:
-  - stream.auto.ondarossa.net
-
-radioprober_streams:
-  - /ondarossa.ogg
-  - /ondarossa.mp3
-  - /wombat.ogg
-
-autoradio_nameservers:
-  - blimp.streampunk.cc
-  - giffard.streampunk.cc
-  - zeppelin.streampunk.cc
diff --git a/hosts-vagrant.ini b/hosts-vagrant.ini
deleted file mode 100644
index bb03ba3..0000000
--- a/hosts-vagrant.ini
+++ /dev/null
@@ -1,23 +0,0 @@
-[vagrant]
-host1 ansible_host=10.236.82.10
-host2 ansible_host=10.236.82.11
-host3 ansible_host=10.236.82.12
-
-[etcd:children]
-vagrant
-
-[etcd-master:children]
-vagrant
-
-[monitor]
-host1 ansible_host=10.236.82.10
-host2 ansible_host=10.236.82.11
-
-[test-source]
-host3 ansible_host=10.236.82.12
-
-[vagrant:vars]
-ansible_become=true
-ansible_ssh_user=vagrant
-ansible_ssh_private_key_file=~/.vagrant.d/insecure_private_key
-
diff --git a/hosts.ini b/hosts.ini
deleted file mode 100644
index c09f06c..0000000
--- a/hosts.ini
+++ /dev/null
@@ -1,30 +0,0 @@
-giffard ansible_host=giffard.streampunk.cc public_ip=49.12.77.64
-blimp ansible_host=blimp.streampunk.cc public_ip=116.203.245.27
-zeppelin ansible_host=zeppelin.streampunk.cc public_ip=95.217.20.254
-
-[hetzner]
-giffard
-blimp
-zeppelin
-
-[hetzner:vars]
-internal_network_interface=enp7s0
-public_network_interface=eth0
-
-[etcd:children]
-hetzner
-
-[etcd-master:children]
-hetzner
-
-[monitor]
-giffard
-blimp
-
-[test-source]
-zeppelin
-
-[all:vars]
-ansible_become=false
-ansible_ssh_user=root
-
diff --git a/hosts.ini.in b/hosts.ini.in
new file mode 100644
index 0000000..c34c497
--- /dev/null
+++ b/hosts.ini.in
@@ -0,0 +1,30 @@
+host1 ansible_host=@IP_NET@.10 peer_ip=@IP_NET@.10
+host2 ansible_host=@IP_NET@.11 peer_ip=@IP_NET@.11
+host3 ansible_host=@IP_NET@.12 peer_ip=@IP_NET@.12
+
+[vagrant]
+host1
+host2
+host3
+
+[etcd:children]
+vagrant
+
+[etcd-master:children]
+vagrant
+
+[monitor]
+host1
+host2
+
+[test-source]
+host3
+
+[vagrant:vars]
+ansible_become=true
+ansible_ssh_user=vagrant
+ansible_ssh_private_key_file=~/.vagrant.d/insecure_private_key
+
+[all:vars]
+public_domain=streampunk.cc
+apt_proxy=@APT_PROXY@
diff --git a/roles/base/tasks/main.yml b/roles/base/tasks/main.yml
index 4ccb901..a7ad1e4 100644
--- a/roles/base/tasks/main.yml
+++ b/roles/base/tasks/main.yml
@@ -16,6 +16,10 @@
       {{ hostvars[h].peer_ip }} monitor
       {% endfor %}
 
+- apt:
+    name: man-db
+    state: absent
+
 - apt:
     name: "{{ packages }}"
     state: present
@@ -28,9 +32,10 @@
   template:
     dest: "/etc/apt/apt.conf.d/{{ item }}"
     src: "apt/{{ item }}.j2"
-  with_items:
+  loop:
     - 20auto-upgrades
     - 50unattended-upgrades
+    - 90proxy
     - 99recommends
     - 99translations
 
@@ -53,6 +58,7 @@
   apt:
     name: "{{ packages }}"
     state: present
+    update_cache: true
   vars:
     packages:
       - bsd-mailx
diff --git a/roles/base/templates/apt/50unattended-upgrades.j2 b/roles/base/templates/apt/50unattended-upgrades.j2
index f8bc1f9..e311579 100644
--- a/roles/base/templates/apt/50unattended-upgrades.j2
+++ b/roles/base/templates/apt/50unattended-upgrades.j2
@@ -4,9 +4,9 @@ Unattended-Upgrade::Origins-Pattern {
         // Note that this will silently match a different release after
         // migration to the specified archive (e.g. testing becomes the
         // new stable).
-//      "o=Debian,a=stable";
-//      "o=Debian,a=stable-updates";
-//      "o=Debian,a=proposed-updates";
+        "o=Debian,a=stable";
+        "o=Debian,a=stable-updates";
+        "o=Debian,a=proposed-updates";
         "origin=Debian,archive=stable,label=Debian-Security";
 };
 
diff --git a/roles/base/templates/apt/90proxy.j2 b/roles/base/templates/apt/90proxy.j2
new file mode 100644
index 0000000..442e5e9
--- /dev/null
+++ b/roles/base/templates/apt/90proxy.j2
@@ -0,0 +1,4 @@
+{% if apt_proxy is defined %}
+Acquire::http::Proxy "http://{{ apt_proxy }}";
+Acquire::http::Proxy::{{ apt_proxy | regex_replace(':[0-9]*$', '') }} "DIRECT";
+{% endif %}
diff --git a/roles/monitor/defaults/main.yml b/roles/monitor/defaults/main.yml
index a01e536..4a4ef30 100644
--- a/roles/monitor/defaults/main.yml
+++ b/roles/monitor/defaults/main.yml
@@ -4,4 +4,4 @@ graphite_secret_key: "changeme"
 grafana_secret_key: "changeme"
 test_source_stream: "/test.ogg"
 prometheus_tsdb_retention_time: "180d"
-
+monitor_auth_password: "password"
diff --git a/roles/monitor/tasks/grafana.yml b/roles/monitor/tasks/grafana.yml
index 4de1f04..e519bd9 100644
--- a/roles/monitor/tasks/grafana.yml
+++ b/roles/monitor/tasks/grafana.yml
@@ -8,7 +8,7 @@
 
 - name: Install Grafana APT repository
   apt_repository:
-    repo: "deb {% if apt_proxy is defined %}http://{{ apt_proxy }}/HTTPS/{% else %}https://{% endif %}packages.grafana.com/oss/deb stable main"
+    repo: "deb https://packages.grafana.com/oss/deb stable main"
     state: present
 
 - name: Install Grafana
diff --git a/roles/network-config/defaults/main.yml b/roles/network-config/defaults/main.yml
deleted file mode 100644
index 75f86a6..0000000
--- a/roles/network-config/defaults/main.yml
+++ /dev/null
@@ -1,7 +0,0 @@
----
-
-# In order to support varied network topologies, every host needs a
-# public and an internal (peer) IP address. We can autodetect them
-# given a network interface name.
-public_network_interface: "ens6"
-internal_network_interface: "ens6"
diff --git a/roles/network-config/tasks/main.yml b/roles/network-config/tasks/main.yml
index 1f563de..e209e0a 100644
--- a/roles/network-config/tasks/main.yml
+++ b/roles/network-config/tasks/main.yml
@@ -1,5 +1,13 @@
 ---
 
+# Autodetect the interfaces if no parameters are specified.
+- set_fact:
+    public_network_interface: "{{ ansible_default_ipv4.interface }}"
+  when: "public_ip is not defined and public_network_interface is not defined"
+- set_fact:
+    internal_network_interface: "{{ ansible_default_ipv4.interface }}"
+  when: "peer_ip is not defined and internal_network_interface is not defined"
+
 # Verify that the network configuration is set up properly, and
 # autodetect public/internal addresses if necessary.
 - set_fact:
diff --git a/site.yml b/site.yml
index b205245..9498a82 100644
--- a/site.yml
+++ b/site.yml
@@ -1,18 +1,5 @@
 ---
 
-# First update packages on all hosts, staggering execution so as to
-# not restart all radiod nodes at once on package upgrades.
-- hosts: all
-  tasks:
-    - name: Upgrade packages
-      apt:
-        update_cache: true
-        upgrade: true
-  serial:
-    - 1
-    - 1
-    - "100%"
-
 - hosts: all
   roles:
     - base
@@ -20,18 +7,10 @@
 - hosts: etcd
   roles:
     - etcd
-  serial:
-    - 1
-    - 1
-    - "100%"
 
 - hosts: all
   roles:
     - autoradio
-  serial:
-    - 1
-    - 1
-    - "100%"
 
 - hosts: monitor
   roles:
diff --git a/test.sh b/test.sh
new file mode 100755
index 0000000..db10059
--- /dev/null
+++ b/test.sh
@@ -0,0 +1,116 @@
+#!/bin/bash
+#
+# Test driver for the streampunk Ansible configuration (and associated
+# software).
+#
+# The tool will generate a test environment using ephemeral virtual
+# machines, and run Ansible on it using the test configuration
+# parameters in group_vars.
+#
+# Configuration is entirely controlled via environment variables:
+#
+# - DIST: select the Debian distribution to use (default "buster")
+#
+# - LIBVIRT_USER, LIBVIRT_HOST: use libvirt-over-SSH, needs SSH
+# passwordless authentication to be set up.
+#
+# - APT_PROXY: set to a host:port address of apt-cacher-ng. When using
+# this directive, ensure that the apt-cacher-ng configuration has a
+# permissive PassThroughPattern to allow access to https://
+# repositories.
+#
+# - VM: select the VM manager to use, one of "vagrant" (default) or
+# "vmine".
+#
+# - VERBOSE: set to a non-empty value to run ansible-playbook with
+# increased verbosity.
+#
+# - AUTORADIO_SRC: if set, points at a local copy of the autoradio
+# source repository which will be compiled and installed instead of
+# using the default packages from the autoradio Debian repository.
+#
+
+repo_root=$(dirname $(realpath "$0"))
+target="${1:-.}"
+
+die() {
+    echo "ERROR: $*" >&2
+    exit 1
+}
+
+wait_for_vms() {
+    # Wait at most 30 seconds for the vms to become reachable.
+    local i=0
+    local ok=1
+    while [ $i -lt 10 ]; do
+        sleep 1
+        if ansible -i hosts.ini all -m ping; then
+            ok=0
+            break
+        fi
+        i=$(($i + 1))
+    done
+    return $ok
+}
+
+vm_manager() {
+    local what="$1"
+    case "${VM:-vagrant}" in
+        vagrant)
+            vagrant ${what}
+            ;;
+        vmine)
+            local args=
+            if [ "${what}" = up ]; then
+                args="--image=${dist} host1=${ip_net_prefix}.10 host2=${ip_net_prefix}.11 host3=${ip_net_prefix}.12"
+            fi
+            ${repo_root}/ci/vmine.py ${libvirt_userhost:+--ssh ${libvirt_userhost}} ${what} ${args}
+            ;;
+        *)
+            die "Unsupported VM manager"
+            ;;
+    esac
+}
+
+set -eu
+
+# Select the Debian distribution to use.
+dist="${DIST:-buster}"
+
+# Choose a random network in the 10.x range.
+ip_net_prefix="10.$(( $RANDOM % 255 )).$(( $RANDOM % 255 ))"
+
+# Find the path for Mitogen.
+mitogen_path="$(python3 -c 'import ansible_mitogen;print(ansible_mitogen.__path__[0])')"
+[ -n "${mitogen_path}" ] || die "Mitogen not found. Please run 'pip3 install mitogen'"
+
+# If LIBVIRT_* parameters are specified, set up SSH to go through the proxy host.
+libvirt_userhost=
+ssh_opts=
+if [ -n "${LIBVIRT_HOST:-}" ]; then
+    libvirt_userhost="${LIBVIRT_USER:-}${LIBVIRT_USER:+@}${LIBVIRT_HOST}"
+    ssh_opts="-o ProxyJump=${libvirt_userhost}"
+fi
+
+# Render the .in templates.
+mkdir -p ${target}
+for file in ansible.cfg Vagrantfile hosts.ini ; do
+    sed -e "s,@IP_NET@,${ip_net_prefix},g" \
+        -e "s,@MITOGEN_PATH@,${mitogen_path},g" \
+        -e "s,@SSH_OPTS@,${ssh_opts},g" \
+        -e "s,@DIST@,${dist},g" \
+        -e "s,@REPO_ROOT@,${repo_root},g" \
+        -e "s,@APT_PROXY@,${APT_PROXY:-},g" \
+        <${repo_root}/${file}.in >${target}/${file}
+done
+cp ${repo_root}/site.yml ${target}/site.yml
+
+# Environment is ready, start up the VMs and wait for them to be ready.
+(cd ${target} && vm_manager up && wait_for_vms)
+
+# Run Ansible.
+(cd ${target} &&
+     ansible-playbook -i hosts.ini \
+                      ${VERBOSE:+-vv} \
+                      ${AUTORADIO_SRC:+-e source_repository_path=${AUTORADIO_SRC}} \
+                      site.yml)
-- 
GitLab