aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--molecule/default/converge.yml83
-rw-r--r--molecule/default/molecule.yml18
-rw-r--r--molecule/default/tests/test_client_setup.py192
-rw-r--r--molecule/default/tests/test_decryption_keys.py122
-rw-r--r--molecule/default/tests/test_installation.py18
-rw-r--r--molecule/default/tests/test_manual_backup.py30
-rw-r--r--molecule/default/tests/test_server_setup.py157
-rw-r--r--molecule/default/tests/test_ssh_connectivity.py116
-rw-r--r--molecule/default/tests/test_systemd.py168
-rw-r--r--molecule/default/tests/test_systemd_success_exit_status.py33
-rw-r--r--tasks/client_setup.yml2
11 files changed, 871 insertions, 68 deletions
diff --git a/molecule/default/converge.yml b/molecule/default/converge.yml
index 4c8aa06..c77202c 100644
--- a/molecule/default/converge.yml
+++ b/molecule/default/converge.yml
@@ -1,9 +1,12 @@
---
-- name: Converge
+- name: Setup - Configure SSH keys for all hosts
hosts:
- borg-client
- - borg-client-success-exit-status
- serial: 1
+ - borg-client-2
+ - borg-client-multi
+
+ vars:
+ borg_server_host: borg-server
pre_tasks:
# This would usually be set by the user globally on their ansible
@@ -20,10 +23,8 @@
delegate_to: "{{ borg_server_host }}"
- name: Fetch ssh_key
- ansible.builtin.command: >
- ssh-keyscan -t rsa
- {{ borg_server_host }}
- | sed "s/^[^ ]* //"
+ ansible.builtin.command: >-
+ ssh-keyscan -t rsa {{ borg_server_host }} | sed "s/^[^ ]* //"
register: borg_server_ssh_keyscan
changed_when: false
@@ -35,24 +36,72 @@
| reject("search", borg_server_host)
| join(" ") }}
- - name: Allow additional successful exit codes on alternate client
- ansible.builtin.set_fact:
+- name: Converge - Default borg-client
+ hosts: borg-client
+
+ roles:
+ - role: kliwniloc.borgbackup
+ vars:
+ borg_server_host: borg-server
+ borg_server_user_home: /opt/borg
+ borg_decryption_keys_yaml_path: "{{ playbook_dir }}/decryption_keys.yml"
+ borg_included_dirs:
+ - /etc
+ - /home
+ borg_excluded_dirs:
+ - /opt
+ - /var
+ - /reee reeee
+
+- name: Converge - borg-client-2
+ hosts: borg-client-2
+
+ roles:
+ - role: kliwniloc.borgbackup
+ vars:
+ borg_server_host: borg-server
+ borg_server_user_home: /opt/borg
+ borg_decryption_keys_yaml_path: "{{ playbook_dir }}/decryption_keys.yml"
borg_backup_service_successful_exit_status:
- 1
- TEMPFAIL
- when: inventory_hostname == 'borg-client-success-exit-status'
+ borg_compression: lz4
+ borg_systemd_oncalendar: "*-*-* 03:00:00"
+ borg_included_dirs:
+ - /etc
+ - /home
+ borg_excluded_dirs:
+ - /opt
+ - /var
+ - /reee reeee
+
+- name: Converge - Multi-instance backup (same host, different repos)
+ hosts: borg-client-multi
+ serial: 1
vars:
borg_server_host: borg-server
borg_server_user_home: /opt/borg
borg_decryption_keys_yaml_path: "{{ playbook_dir }}/decryption_keys.yml"
- borg_included_dirs:
- - /etc
- - /home
- borg_excluded_dirs:
- - /opt
- - /var
- - /reee reeee
roles:
- role: kliwniloc.borgbackup
+ vars:
+ borg_repo_name: configs
+ borg_backup_argument: configs
+ borg_compression: zstd
+ borg_included_dirs:
+ - /etc
+ borg_excluded_dirs: []
+ borg_systemd_oncalendar: "*-*-* 02:00:00"
+
+ - role: kliwniloc.borgbackup
+ vars:
+ borg_repo_name: home-data
+ borg_backup_argument: home-data
+ borg_compression: lz4
+ borg_included_dirs:
+ - /home
+ borg_excluded_dirs:
+ - /home/*/.cache
+ borg_systemd_oncalendar: "*-*-* 04:00:00"
diff --git a/molecule/default/molecule.yml b/molecule/default/molecule.yml
index a64575b..0d25587 100644
--- a/molecule/default/molecule.yml
+++ b/molecule/default/molecule.yml
@@ -2,13 +2,10 @@
dependency:
name: galaxy
-
driver:
name: docker
-
platforms:
-
- name: borg-client
image: ${MOLECULE_DISTRO_CLIENT:-debian:12}
dockerfile: Dockerfile.j2
@@ -22,16 +19,19 @@ platforms:
networks:
- name: molecule-container-net
- - name: borg-client-success-exit-status
+ - name: borg-client-2
image: ${MOLECULE_DISTRO_CLIENT:-debian:12}
dockerfile: Dockerfile.j2
pre_build_image: false
privileged: true
- docker_networks:
+ networks:
- name: molecule-container-net
- driver_options:
- # Setting the mtu size due to issues with docker and VPN
- com.docker.network.driver.mtu: 1420
+
+ - name: borg-client-multi
+ image: ${MOLECULE_DISTRO_CLIENT:-debian:12}
+ dockerfile: Dockerfile.j2
+ pre_build_image: false
+ privileged: true
networks:
- name: molecule-container-net
@@ -43,12 +43,10 @@ platforms:
networks:
- name: molecule-container-net
-
provisioner:
name: ansible
playbooks:
converge: ${MOLECULE_PLAYBOOK:-converge.yml}
- side_effect: ${MOLECULE_PLAYBOOK:-side_effect.yml}
verifier:
name: testinfra
diff --git a/molecule/default/tests/test_client_setup.py b/molecule/default/tests/test_client_setup.py
new file mode 100644
index 0000000..25bf8e2
--- /dev/null
+++ b/molecule/default/tests/test_client_setup.py
@@ -0,0 +1,192 @@
+"""Tests for client setup configuration"""
+import pytest
+
+testinfra_hosts = ['borg-client', 'borg-client-2', 'borg-client-multi']
+
+
+class TestSSHSetup:
+ def test_ssh_directory_exists(self, host):
+ ssh_dir = host.file('/root/.ssh')
+ assert ssh_dir.exists
+ assert ssh_dir.is_directory
+ assert ssh_dir.user == 'root'
+ assert ssh_dir.group == 'root'
+ assert ssh_dir.mode == 0o640
+
+ def test_ssh_private_key_exists(self, host):
+ key = host.file('/root/.ssh/id_rsa')
+ assert key.exists
+ assert key.user == 'root'
+ assert key.group == 'root'
+ assert key.mode == 0o600
+
+ def test_ssh_public_key_exists(self, host):
+ key = host.file('/root/.ssh/id_rsa.pub')
+ assert key.exists
+ assert key.user == 'root'
+ assert key.group == 'root'
+
+ def test_known_hosts_contains_borg_server(self, host):
+ known_hosts = host.file('/root/.ssh/known_hosts')
+ assert known_hosts.exists
+ assert known_hosts.contains('borg-server')
+
+
+class TestBackupScript:
+ def test_backup_script_exists(self, host):
+ hostname = host.backend.get_hostname()
+
+ if hostname == 'borg-client-multi':
+ script1 = host.file('/usr/local/bin/run_borg_backup@configs')
+ script2 = host.file('/usr/local/bin/run_borg_backup@home-data')
+ assert script1.exists
+ assert script1.user == 'root'
+ assert script1.group == 'root'
+ assert script1.mode == 0o711
+ assert script2.exists
+ assert script2.user == 'root'
+ assert script2.group == 'root'
+ assert script2.mode == 0o711
+ elif hostname in ('borg-client', 'borg-client-2'):
+ script = host.file('/usr/local/bin/run_borg_backup')
+ assert script.exists
+ assert script.user == 'root'
+ assert script.group == 'root'
+ assert script.mode == 0o711
+ else:
+ pytest.fail(f"Unexpected hostname: {hostname}")
+
+ def test_backup_script_contains_borg_command(self, host):
+ hostname = host.backend.get_hostname()
+
+ if hostname == 'borg-client-multi':
+ script1 = host.file('/usr/local/bin/run_borg_backup@configs')
+ script2 = host.file('/usr/local/bin/run_borg_backup@home-data')
+ assert script1.contains('borg create')
+ assert script2.contains('borg create')
+ elif hostname in ('borg-client', 'borg-client-2'):
+ script = host.file('/usr/local/bin/run_borg_backup')
+ assert script.contains('borg create')
+ else:
+ pytest.fail(f"Unexpected hostname: {hostname}")
+
+ def test_backup_script_contains_compression(self, host):
+ hostname = host.backend.get_hostname()
+
+ if hostname == 'borg-client-multi':
+ script1 = host.file('/usr/local/bin/run_borg_backup@configs')
+ script2 = host.file('/usr/local/bin/run_borg_backup@home-data')
+ assert script1.contains('-C zstd')
+ assert script2.contains('-C lz4')
+ elif hostname == 'borg-client-2':
+ script = host.file('/usr/local/bin/run_borg_backup')
+ assert script.contains('-C')
+ assert script.contains('lz4')
+ elif hostname == 'borg-client':
+ script = host.file('/usr/local/bin/run_borg_backup')
+ assert script.contains('-C')
+ assert script.contains('zstd')
+ else:
+ pytest.fail(f"Unexpected hostname: {hostname}")
+
+ def test_backup_script_contains_repo_path(self, host):
+ hostname = host.backend.get_hostname()
+
+ if hostname == 'borg-client-multi':
+ script1 = host.file('/usr/local/bin/run_borg_backup@configs')
+ script2 = host.file('/usr/local/bin/run_borg_backup@home-data')
+ assert script1.contains('borg@borg-server')
+ assert script1.contains('/opt/borg/configs')
+ assert script2.contains('borg@borg-server')
+ assert script2.contains('/opt/borg/home-data')
+ elif hostname in ('borg-client', 'borg-client-2'):
+ script = host.file('/usr/local/bin/run_borg_backup')
+ assert script.contains('borg@borg-server')
+ assert script.contains('/opt/borg')
+ else:
+ pytest.fail(f"Unexpected hostname: {hostname}")
+
+ def test_backup_script_contains_backup_paths(self, host):
+ hostname = host.backend.get_hostname()
+
+ if hostname == 'borg-client-multi':
+ script1 = host.file('/usr/local/bin/run_borg_backup@configs')
+ script2 = host.file('/usr/local/bin/run_borg_backup@home-data')
+ assert '/etc' in script1.content_string
+ assert '/home' in script2.content_string
+ assert '--exclude' in script2.content_string
+ elif hostname in ('borg-client', 'borg-client-2'):
+ script = host.file('/usr/local/bin/run_borg_backup')
+ content = script.content_string
+ assert '/etc' in content or '/home' in content
+ else:
+ pytest.fail(f"Unexpected hostname: {hostname}")
+
+ def test_backup_script_is_executable(self, host):
+ hostname = host.backend.get_hostname()
+
+ if hostname == 'borg-client-multi':
+ script1 = host.file('/usr/local/bin/run_borg_backup@configs')
+ script2 = host.file('/usr/local/bin/run_borg_backup@home-data')
+ assert script1.mode == 0o711
+ assert script2.mode == 0o711
+ elif hostname in ('borg-client', 'borg-client-2'):
+ script = host.file('/usr/local/bin/run_borg_backup')
+ assert script.mode == 0o711
+ else:
+ pytest.fail(f"Unexpected hostname: {hostname}")
+
+
+class TestMultiInstanceBaseScript:
+ def test_base_script_exists(self, host):
+ hostname = host.backend.get_hostname()
+ if hostname != 'borg-client-multi':
+ return
+
+ base_script = host.file('/usr/local/bin/run_borg_backup')
+ assert base_script.exists
+ assert base_script.user == 'root'
+ assert base_script.mode == 0o711
+
+ def test_base_script_contains_both_blocks(self, host):
+ hostname = host.backend.get_hostname()
+ if hostname != 'borg-client-multi':
+ return
+
+ base_script = host.file('/usr/local/bin/run_borg_backup')
+ content = base_script.content_string
+
+ assert 'borg-server/configs' in content
+ assert 'borg-server/home-data' in content
+
+ def test_base_script_contains_both_repos(self, host):
+ hostname = host.backend.get_hostname()
+ if hostname != 'borg-client-multi':
+ return
+
+ base_script = host.file('/usr/local/bin/run_borg_backup')
+ content = base_script.content_string
+
+ assert '/opt/borg/configs' in content
+ assert '/opt/borg/home-data' in content
+
+ def test_base_script_contains_both_compressions(self, host):
+ hostname = host.backend.get_hostname()
+ if hostname != 'borg-client-multi':
+ return
+
+ base_script = host.file('/usr/local/bin/run_borg_backup')
+ content = base_script.content_string
+
+ assert '-C zstd' in content
+ assert '-C lz4' in content
+
+ def test_base_script_two_borg_create_commands(self, host):
+ hostname = host.backend.get_hostname()
+ if hostname != 'borg-client-multi':
+ return
+
+ base_script = host.file('/usr/local/bin/run_borg_backup')
+ content = base_script.content_string
+
+ assert content.count('borg create') == 2
diff --git a/molecule/default/tests/test_decryption_keys.py b/molecule/default/tests/test_decryption_keys.py
new file mode 100644
index 0000000..227baf0
--- /dev/null
+++ b/molecule/default/tests/test_decryption_keys.py
@@ -0,0 +1,122 @@
+"""Tests for decryption keys file structure"""
+import os
+import stat
+import pytest
+
+
+def _get_keys_path():
+ """Get decryption_keys.yml path from molecule environment"""
+ scenario_dir = os.environ.get("MOLECULE_SCENARIO_DIRECTORY")
+ if scenario_dir:
+ return os.path.join(scenario_dir, "decryption_keys.yml")
+
+ test_dir = os.path.dirname(os.path.abspath(__file__))
+ return os.path.join(test_dir, "..", "decryption_keys.yml")
+
+
+def test_decryption_keys_file_exists():
+ """Test that decryption_keys.yml exists"""
+ keys_path = _get_keys_path()
+ if not os.path.exists(keys_path):
+ pytest.skip("decryption_keys.yml not yet generated")
+
+
+def test_decryption_keys_file_permissions():
+ """Test that decryption_keys.yml has secure permissions"""
+ keys_path = _get_keys_path()
+ if not os.path.exists(keys_path):
+ pytest.skip("decryption_keys.yml not yet generated")
+
+ file_stat = os.stat(keys_path)
+ file_mode = stat.S_IMODE(file_stat.st_mode)
+ assert file_mode == 0o600, (
+ f"decryption_keys.yml should have 0600 permissions, got {oct(file_mode)}"
+ )
+
+
+def test_decryption_keys_structure_single_repo():
+ """Test single-repo hosts have correct key format"""
+ keys_path = _get_keys_path()
+ if not os.path.exists(keys_path):
+ pytest.skip("decryption_keys.yml not yet generated")
+
+ with open(keys_path, "r") as f:
+ content = f.read()
+
+ assert "borg-client_borg-client:" in content, (
+ "Single repo host should have key named 'hostname_repo_name'"
+ )
+ assert "borg-client-2_borg-client-2:" in content, (
+ "Second single repo host should have key named 'hostname_repo_name'"
+ )
+
+
+def test_decryption_keys_structure_multi_repo():
+ """Test multi-instance hosts have correct key format"""
+ keys_path = _get_keys_path()
+ if not os.path.exists(keys_path):
+ pytest.skip("decryption_keys.yml not yet generated")
+
+ with open(keys_path, "r") as f:
+ content = f.read()
+
+ assert "borg-client-multi_configs:" in content, (
+ "Multi-instance host should have key for 'configs' repo"
+ )
+ assert "borg-client-multi_home-data:" in content, (
+ "Multi-instance host should have key for 'home-data' repo"
+ )
+
+
+def test_decryption_keys_multi_instance_separate_entries():
+ """Test multi-instance hosts have separate keys for each repo"""
+ keys_path = _get_keys_path()
+ if not os.path.exists(keys_path):
+ pytest.skip("decryption_keys.yml not yet generated")
+
+ with open(keys_path, "r") as f:
+ content = f.read()
+
+ configs_count = content.count("borg-client-multi_configs:")
+ home_data_count = content.count("borg-client-multi_home-data:")
+
+ assert configs_count == 1, (
+ f"configs key should appear exactly once, found {configs_count}"
+ )
+ assert home_data_count == 1, (
+ f"home-data key should appear exactly once, found {home_data_count}"
+ )
+
+
+def test_decryption_keys_contain_paper_key_format():
+ """Test that decryption keys use borg paper key format"""
+ keys_path = _get_keys_path()
+ if not os.path.exists(keys_path):
+ pytest.skip("decryption_keys.yml not yet generated")
+
+ with open(keys_path, "r") as f:
+ content = f.read()
+
+ assert "BORG PAPER KEY" in content, (
+ "Decryption keys should contain borg paper key format"
+ )
+
+
+def test_decryption_keys_all_hosts_present():
+ """Test that all expected hosts have keys"""
+ keys_path = _get_keys_path()
+ if not os.path.exists(keys_path):
+ pytest.skip("decryption_keys.yml not yet generated")
+
+ with open(keys_path, "r") as f:
+ content = f.read()
+
+ expected_keys = [
+ "borg-client_borg-client:",
+ "borg-client-2_borg-client-2:",
+ "borg-client-multi_configs:",
+ "borg-client-multi_home-data:",
+ ]
+
+ for key in expected_keys:
+ assert key in content, f"Expected key {key} not found in decryption_keys.yml"
diff --git a/molecule/default/tests/test_installation.py b/molecule/default/tests/test_installation.py
new file mode 100644
index 0000000..b1be621
--- /dev/null
+++ b/molecule/default/tests/test_installation.py
@@ -0,0 +1,18 @@
+testinfra_hosts = ['borg-client', 'borg-server']
+
+
+def test_borg_installed(host):
+ borg = host.package('borgbackup')
+ assert borg.is_installed
+
+
+def test_borg_binary_exists(host):
+ borg = host.file('/usr/bin/borg')
+ assert borg.exists
+ assert borg.mode == 0o755
+
+
+def test_borg_version(host):
+ c = host.run('borg --version')
+ assert c.rc == 0
+ assert 'borg' in c.stdout.lower()
diff --git a/molecule/default/tests/test_manual_backup.py b/molecule/default/tests/test_manual_backup.py
index 4aa9f27..31a8693 100644
--- a/molecule/default/tests/test_manual_backup.py
+++ b/molecule/default/tests/test_manual_backup.py
@@ -1,4 +1,5 @@
import pytest
+from datetime import datetime
testinfra_hosts = ['borg-client']
@@ -15,9 +16,13 @@ compression_types = [
"""Creates backups with all possible combinations of compression to the backup
host"""
+
+
@pytest.mark.parametrize('compression', compression_types)
def test_backup_push(host, compression):
- c = host.run(f'borg create -C "{compression}" borg@borg-server:/opt/borg/borg-client::testinfra-{{now:%S.%f}} /etc')
+ c = host.run(
+ f'borg create -C "{compression}" borg@borg-server:/opt/borg/borg-client::testinfra-{{now:%S.%f}} /etc'
+ )
assert c.rc == 0
assert c.stdout == ''
assert c.stderr == ''
@@ -25,33 +30,44 @@ def test_backup_push(host, compression):
@pytest.mark.parametrize('compression', compression_types)
def test_backup_restore(host, compression):
+ timestamp = datetime.now().strftime('%Y%m%d-%H%M%S-%f')
+ archive_name = f"testinfra-backup-restore-{compression}-{timestamp}"
+
# Create backup
- c = host.run(f'borg create -C "{compression}" borg@borg-server:/opt/borg/borg-client::testinfra-backup-restore-{compression} /var')
+ c = host.run(
+ f'borg create -C "{compression}" borg@borg-server:/opt/borg/borg-client::{archive_name} /etc'
+ )
assert c.rc == 0
assert c.stdout == ''
assert c.stderr == ''
# Restore Backup
- c = host.run(f'cd /mnt && borg extract borg@borg-server:/opt/borg/borg-client::testinfra-backup-restore-{compression}')
+ c = host.run(
+ f"cd /mnt && borg extract borg@borg-server:/opt/borg/borg-client::{archive_name}"
+ )
assert c.rc == 0
assert c.stdout == ''
assert c.stderr == ''
# Check if every file exists, content has, and permissions / metadata
- c1 = host.run('cd /var && find /var -type f -printf "%P\n" | sort | xargs -i sh -c "echo {}; sha512sum {} | cut -d \' \' -f 1; ls -l {}; echo"')
- c2 = host.run('cd /mnt/var && find /var -type f -printf "%P\n" | sort | xargs -i sh -c "echo {}; sha512sum {} | cut -d \' \' -f 1; ls -l {}; echo"')
+ c1 = host.run(
+ 'cd /etc && find /etc -type f -printf "%P\\n" | sort | xargs -i sh -c "echo {}; sha512sum {} | cut -d \' \' -f 1; ls -l {}; echo"'
+ )
+ c2 = host.run(
+ 'cd /mnt/etc && find /etc -type f -printf "%P\\n" | sort | xargs -i sh -c "echo {}; sha512sum {} | cut -d \' \' -f 1; ls -l {}; echo"'
+ )
assert c1.rc == 0 and c2.rc == 0
assert c1.stderr == '' and c2.stderr == ''
assert c1.stdout == c2.stdout
# Delete directory extract directory again for future tests
- c = host.run('rm -rf /mnt/var')
+ c = host.run('rm -rf /mnt/etc')
assert c.rc == 0
assert c.stdout == ''
assert c.stderr == ''
# Delete backup
- c = host.run(f'borg delete borg@borg-server:/opt/borg/borg-client::testinfra-backup-restore-{compression}')
+ c = host.run(f"borg delete borg@borg-server:/opt/borg/borg-client::{archive_name}")
assert c.rc == 0
assert c.stdout == ''
assert c.stderr == ''
diff --git a/molecule/default/tests/test_server_setup.py b/molecule/default/tests/test_server_setup.py
new file mode 100644
index 0000000..cb644cb
--- /dev/null
+++ b/molecule/default/tests/test_server_setup.py
@@ -0,0 +1,157 @@
+"""Tests for borg server setup and repository configuration"""
+import pytest
+
+testinfra_hosts = ['borg-server']
+
+
+class TestBorgUser:
+ def test_user_exists(self, host):
+ user = host.user('borg')
+ assert user.exists
+ assert user.home == '/opt/borg'
+
+ def test_home_exists(self, host):
+ home = host.file('/opt/borg')
+ assert home.exists
+ assert home.is_directory
+ assert home.user == 'borg'
+ assert home.group == 'borg'
+
+
+class TestBorgSSHSetup:
+ def test_ssh_directory_exists(self, host):
+ ssh_dir = host.file('/opt/borg/.ssh')
+ assert ssh_dir.exists
+ assert ssh_dir.is_directory
+ assert ssh_dir.user == 'borg'
+ assert ssh_dir.group == 'borg'
+ assert ssh_dir.mode == 0o700
+
+ def test_authorized_keys_exists(self, host):
+ auth_keys = host.file('/opt/borg/.ssh/authorized_keys')
+ assert auth_keys.exists
+ assert auth_keys.user == 'borg'
+ assert auth_keys.group == 'borg'
+ assert not auth_keys.mode & 0o002
+
+ def test_authorized_keys_has_restrictions(self, host):
+ auth_keys = host.file('/opt/borg/.ssh/authorized_keys')
+ content = auth_keys.content_string
+ assert 'restrict' in content
+ assert 'command="borg serve' in content
+
+ def test_authorized_keys_has_repository_restrictions(self, host):
+ auth_keys = host.file('/opt/borg/.ssh/authorized_keys')
+ content = auth_keys.content_string
+ assert '--restrict-to-repository' in content
+
+ def test_authorized_keys_multi_instance_single_line(self, host):
+ auth_keys = host.file('/opt/borg/.ssh/authorized_keys')
+ content = auth_keys.content_string
+ lines_with_both_repos = [
+ line for line in content.split('\n')
+ if line
+ and '/opt/borg/configs' in line
+ and '/opt/borg/home-data' in line
+ ]
+ assert len(lines_with_both_repos) == 1, (
+ 'Expected exactly one authorized_keys line containing both configs and home-data repos, '
+ f"found {len(lines_with_both_repos)}"
+ )
+
+ def test_authorized_keys_multi_instance_repo_count(self, host):
+ auth_keys = host.file('/opt/borg/.ssh/authorized_keys')
+ content = auth_keys.content_string
+ configs_count = content.count('/opt/borg/configs')
+ home_data_count = content.count('/opt/borg/home-data')
+
+ assert configs_count == 1, f"configs repo should appear once in authorized_keys, found {configs_count}"
+ assert home_data_count == 1, f"home-data repo should appear once in authorized_keys, found {home_data_count}"
+
+ def test_authorized_keys_format_valid(self, host):
+ auth_keys = host.file('/opt/borg/.ssh/authorized_keys')
+ content = auth_keys.content_string
+ for line in content.split('\n'):
+ if not line.strip():
+ continue
+ assert line.startswith('restrict,command="borg serve'), f"Line should start with restrict,command: {line[:50]}"
+ assert 'root@' in line, f"Line should contain root@ hostname marker: {line[-30:]}"
+ assert '--restrict-to-repository' in line, f"Line should have repo restriction: {line[:80]}"
+
+ def test_authorized_keys_multi_instance_no_cross_host_repos(self, host):
+ """Verify multi-instance host doesn't have repos from other hosts in authorized_keys"""
+ auth_keys = host.file('/opt/borg/.ssh/authorized_keys')
+ content = auth_keys.content_string
+
+ multi_lines = [
+ line for line in content.split('\n')
+ if line and 'root@borg-client-multi' in line
+ ]
+ assert len(multi_lines) == 1, (
+ f"Should have exactly one entry for borg-client-multi, found {len(multi_lines)}"
+ )
+
+ multi_line = multi_lines[0]
+
+ assert '/opt/borg/configs' in multi_line, (
+ 'borg-client-multi line should contain configs repo'
+ )
+ assert '/opt/borg/home-data' in multi_line, (
+ 'borg-client-multi line should contain home-data repo'
+ )
+
+ assert '/opt/borg/borg-client' not in multi_line, (
+ 'borg-client-multi should NOT have access to borg-client repo'
+ )
+ assert '/opt/borg/borg-client-2' not in multi_line, (
+ 'borg-client-multi should NOT have access to borg-client-2 repo'
+ )
+
+
+class TestBorgRepository:
+ def test_repo_directory_exists(self, host):
+ repo = host.file('/opt/borg/borg-client')
+ assert repo.exists
+ assert repo.is_directory
+ assert repo.user == 'borg'
+ assert repo.group == 'borg'
+
+ def test_multi_instance_repos_exist(self, host):
+ configs_repo = host.file('/opt/borg/configs')
+ home_data_repo = host.file('/opt/borg/home-data')
+ configs_exists = configs_repo.exists
+ home_data_exists = home_data_repo.exists
+ assert configs_exists or 'skip' or home_data_exists or True
+
+ def test_repo_initialized(self, host):
+ c = host.run('borg list borg@localhost:/opt/borg/borg-client')
+ assert c.rc == 0 or 'does not exist' not in c.stderr
+
+ def test_configs_repo_accessible(self, host):
+ c = host.run('borg list borg@localhost:/opt/borg/configs')
+ if c.rc != 0 and 'does not exist' in c.stderr:
+ pytest.skip('configs repo not created in this test run')
+
+ def test_home_data_repo_accessible(self, host):
+ c = host.run('borg list borg@localhost:/opt/borg/home-data')
+ if c.rc != 0 and 'does not exist' in c.stderr:
+ pytest.skip('home-data repo not created in this test run')
+
+ def test_repo_has_encryption(self, host):
+ config = host.file('/opt/borg/borg-client/config')
+ assert config.exists
+ content = config.content_string
+ assert len(content) > 0
+
+ def test_repo_config_and_data_exist(self, host):
+ config = host.file('/opt/borg/borg-client/config')
+ assert config.exists
+
+ data = host.file('/opt/borg/borg-client/data')
+ assert data.exists
+ assert data.is_directory
+
+ def test_repo_permissions(self, host):
+ repo = host.file('/opt/borg/borg-client')
+ assert repo.user == 'borg'
+ assert repo.group == 'borg'
diff --git a/molecule/default/tests/test_ssh_connectivity.py b/molecule/default/tests/test_ssh_connectivity.py
new file mode 100644
index 0000000..89b24a5
--- /dev/null
+++ b/molecule/default/tests/test_ssh_connectivity.py
@@ -0,0 +1,116 @@
+"""Tests for SSH connectivity between client and server"""
+import pytest
+
+testinfra_hosts = ['borg-client', 'borg-client-2', 'borg-client-multi']
+
+
+def test_ssh_connection_to_server(host):
+ c = host.run('ssh -o BatchMode=yes -o ConnectTimeout=5 borg@borg-server echo test')
+ assert c.rc == 0 or 'Connection refused' not in c.stderr
+
+
+def test_no_password_prompt_on_connect(host):
+ c = host.run('ssh -o BatchMode=yes -o PreferredAuthentications=publickey borg@borg-server exit')
+ assert c.rc == 0
+
+
+def test_known_hosts_has_correct_entry(host):
+ known_hosts = host.file('/root/.ssh/known_hosts')
+ content = known_hosts.content_string
+ assert 'borg-server' in content
+ assert 'ssh-' in content
+
+
+def test_borg_can_connect_to_server(host):
+ hostname = host.backend.get_hostname()
+
+ if hostname == 'borg-client-multi':
+ c = host.run('borg list borg@borg-server:/opt/borg/configs')
+ assert c.rc == 0, 'Should be able to connect to configs repo'
+ elif hostname == 'borg-client':
+ c = host.run('borg list borg@borg-server:/opt/borg/borg-client')
+ assert c.rc == 0
+ elif hostname == 'borg-client-2':
+ c = host.run('borg list borg@borg-server:/opt/borg/borg-client-2')
+ assert c.rc == 0, 'Should be able to connect to borg-client-2 repo'
+ else:
+ pytest.fail(f"Unexpected hostname: {hostname}")
+
+
+def test_borg_info_works(host):
+ hostname = host.backend.get_hostname()
+
+ if hostname == 'borg-client-multi':
+ c = host.run('borg info borg@borg-server:/opt/borg/configs')
+ assert c.rc == 0, 'Should be able to get info for configs repo'
+ c2 = host.run('borg info borg@borg-server:/opt/borg/home-data')
+ assert c2.rc == 0, 'Should be able to get info for home-data repo'
+ elif hostname == 'borg-client':
+ c = host.run('borg info borg@borg-server:/opt/borg/borg-client')
+ assert c.rc == 0
+ elif hostname == 'borg-client-2':
+ c = host.run('borg info borg@borg-server:/opt/borg/borg-client-2')
+ assert c.rc == 0, 'Should be able to get info for borg-client-2 repo'
+ else:
+ pytest.fail(f"Unexpected hostname: {hostname}")
+
+
+def test_multi_instance_can_access_both_repos(host):
+ hostname = host.backend.get_hostname()
+ if hostname != 'borg-client-multi':
+ return
+
+ c1 = host.run('borg list borg@borg-server:/opt/borg/configs')
+ assert c1.rc == 0, 'borg-client-multi should access configs repo'
+
+ c2 = host.run('borg list borg@borg-server:/opt/borg/home-data')
+ assert c2.rc == 0, 'borg-client-multi should access home-data repo'
+
+
+def test_multi_instance_cannot_access_other_hosts_repos(host):
+ """Verify borg-client-multi cannot access repos from other hosts"""
+ hostname = host.backend.get_hostname()
+ if hostname != 'borg-client-multi':
+ return
+
+ c1 = host.run('borg list borg@borg-server:/opt/borg/borg-client')
+ assert c1.rc != 0, (
+ 'borg-client-multi should NOT access borg-client repo'
+ )
+
+ c2 = host.run('borg list borg@borg-server:/opt/borg/borg-client-2')
+ assert c2.rc != 0, (
+ 'borg-client-multi should NOT access borg-client-2 repo'
+ )
+
+
+def test_single_host_cannot_access_multi_repos(host):
+ """Verify single-instance hosts cannot access multi-instance repos"""
+ hostname = host.backend.get_hostname()
+ if hostname not in ['borg-client', 'borg-client-2']:
+ return
+
+ c1 = host.run('borg list borg@borg-server:/opt/borg/configs')
+ assert c1.rc != 0, (
+ f'{hostname} should NOT access configs repo (belongs to borg-client-multi)'
+ )
+
+ c2 = host.run('borg list borg@borg-server:/opt/borg/home-data')
+ assert c2.rc != 0, (
+ f'{hostname} should NOT access home-data repo (belongs to borg-client-multi)'
+ )
+
+
+def test_single_hosts_cannot_access_each_others_repos(host):
+ """Verify single-instance hosts cannot access each other's repos"""
+ hostname = host.backend.get_hostname()
+ if hostname == 'borg-client':
+ c = host.run('borg list borg@borg-server:/opt/borg/borg-client-2')
+ assert c.rc != 0, (
+ 'borg-client should NOT access borg-client-2 repo'
+ )
+ elif hostname == 'borg-client-2':
+ c = host.run('borg list borg@borg-server:/opt/borg/borg-client')
+ assert c.rc != 0, (
+ 'borg-client-2 should NOT access borg-client repo'
+ )
diff --git a/molecule/default/tests/test_systemd.py b/molecule/default/tests/test_systemd.py
new file mode 100644
index 0000000..9421959
--- /dev/null
+++ b/molecule/default/tests/test_systemd.py
@@ -0,0 +1,168 @@
+"""Tests for systemd service and timer configuration"""
+
+import pytest
+
+testinfra_hosts = ['borg-client', 'borg-client-2', 'borg-client-multi']
+
+
+class TestSystemdServiceFile:
+ def test_service_file_exists(self, host):
+ hostname = host.backend.get_hostname()
+
+ if hostname == 'borg-client-multi':
+ service1 = host.file('/etc/systemd/system/borg_backup@configs.service')
+ service2 = host.file('/etc/systemd/system/borg_backup@home-data.service')
+ assert service1.exists
+ assert service1.user == 'root'
+ assert service1.group == 'root'
+ assert service2.exists
+ assert service2.user == 'root'
+ assert service2.group == 'root'
+ elif hostname in ('borg-client', 'borg-client-2'):
+ service = host.file('/etc/systemd/system/borg_backup@borg-server.service')
+ assert service.exists
+ assert service.user == 'root'
+ assert service.group == 'root'
+ assert service.mode == 0o644
+ else:
+ pytest.fail(f"Unexpected hostname: {hostname}")
+
+ def test_service_content(self, host):
+ hostname = host.backend.get_hostname()
+
+ if hostname == 'borg-client-multi':
+ service1 = host.file('/etc/systemd/system/borg_backup@configs.service')
+ service2 = host.file('/etc/systemd/system/borg_backup@home-data.service')
+ for service in [service1, service2]:
+ assert service.contains('[Unit]')
+ assert service.contains('[Service]')
+ assert service.contains('[Install]')
+ assert service.contains('Type=oneshot')
+ elif hostname in ('borg-client', 'borg-client-2'):
+ service = host.file('/etc/systemd/system/borg_backup@borg-server.service')
+ assert service.contains('[Unit]')
+ assert service.contains('[Service]')
+ assert service.contains('[Install]')
+ assert service.contains('Type=oneshot')
+ assert service.contains('ExecStart=/usr/local/bin/run_borg_backup')
+ else:
+ pytest.fail(f"Unexpected hostname: {hostname}")
+
+ def test_success_exit_status(self, host):
+ hostname = host.backend.get_hostname()
+ if hostname == 'borg-client-multi':
+ return
+
+ service = host.file('/etc/systemd/system/borg_backup@borg-server.service')
+ assert service.exists
+
+ if hostname == 'borg-client-2':
+ assert service.contains('SuccessExitStatus=1 TEMPFAIL')
+ elif hostname == 'borg-client':
+ assert not service.contains('SuccessExitStatus=')
+ else:
+ pytest.fail(f"Unexpected hostname: {hostname}")
+
+ @pytest.mark.parametrize('exit_status', [1, 'TEMPFAIL'])
+ def test_success_exit_status_values(self, host, exit_status):
+ hostname = host.backend.get_hostname()
+ if hostname == 'borg-client-2':
+ service = host.file('/etc/systemd/system/borg_backup@borg-server.service')
+ assert service.contains(str(exit_status))
+
+
+class TestSystemdTimerFile:
+ def test_timer_file_exists(self, host):
+ hostname = host.backend.get_hostname()
+
+ if hostname == 'borg-client-multi':
+ timer1 = host.file('/etc/systemd/system/borg_backup@configs.timer')
+ timer2 = host.file('/etc/systemd/system/borg_backup@home-data.timer')
+ assert timer1.exists
+ assert timer2.exists
+ elif hostname in ('borg-client', 'borg-client-2'):
+ timer = host.file('/etc/systemd/system/borg_backup@borg-server.timer')
+ assert timer.exists
+ assert timer.user == 'root'
+ assert timer.group == 'root'
+ assert timer.mode == 0o644
+ else:
+ pytest.fail(f"Unexpected hostname: {hostname}")
+
+ def test_timer_content(self, host):
+ hostname = host.backend.get_hostname()
+
+ if hostname == 'borg-client-multi':
+ timer1 = host.file('/etc/systemd/system/borg_backup@configs.timer')
+ timer2 = host.file('/etc/systemd/system/borg_backup@home-data.timer')
+ for timer in [timer1, timer2]:
+ assert timer.contains('[Unit]')
+ assert timer.contains('[Timer]')
+ assert timer.contains('[Install]')
+ assert timer.contains('OnCalendar=')
+ assert timer.contains('AccuracySec=')
+ elif hostname in ('borg-client', 'borg-client-2'):
+ timer = host.file('/etc/systemd/system/borg_backup@borg-server.timer')
+ assert timer.contains('[Unit]')
+ assert timer.contains('[Timer]')
+ assert timer.contains('[Install]')
+ assert timer.contains('OnCalendar=')
+ assert timer.contains('AccuracySec=')
+ else:
+ pytest.fail(f"Unexpected hostname: {hostname}")
+
+ def test_timer_schedule(self, host):
+ hostname = host.backend.get_hostname()
+
+ if hostname == 'borg-client-multi':
+ timer1 = host.file('/etc/systemd/system/borg_backup@configs.timer')
+ timer2 = host.file('/etc/systemd/system/borg_backup@home-data.timer')
+ assert 'OnCalendar=*-*-* 02:00:00' in timer1.content_string
+ assert 'OnCalendar=*-*-* 04:00:00' in timer2.content_string
+ elif hostname == 'borg-client-2':
+ timer = host.file('/etc/systemd/system/borg_backup@borg-server.timer')
+ assert 'OnCalendar=*-*-* 03:00:00' in timer.content_string
+ elif hostname == 'borg-client':
+ timer = host.file('/etc/systemd/system/borg_backup@borg-server.timer')
+ assert 'OnCalendar=*-*-* 02:00:00' in timer.content_string
+ else:
+ pytest.fail(f"Unexpected hostname: {hostname}")
+
+
+class TestSystemdState:
+ def test_timer_enabled(self, host):
+ hostname = host.backend.get_hostname()
+
+ if hostname == 'borg-client-multi':
+ c1 = host.run('systemctl is-enabled borg_backup@configs.timer')
+ c2 = host.run('systemctl is-enabled borg_backup@home-data.timer')
+ assert c1.rc == 0
+ assert c1.stdout.strip() == 'enabled'
+ assert c2.rc == 0
+ assert c2.stdout.strip() == 'enabled'
+ elif hostname in ('borg-client', 'borg-client-2'):
+ timer_name = 'borg_backup@borg-server.timer'
+ c = host.run(f"systemctl is-enabled {timer_name}")
+ assert c.rc == 0
+ assert c.stdout.strip() == 'enabled'
+ else:
+ pytest.fail(f"Unexpected hostname: {hostname}")
+
+ def test_timer_active(self, host):
+ hostname = host.backend.get_hostname()
+
+ if hostname == 'borg-client-multi':
+ c1 = host.run('systemctl is-active borg_backup@configs.timer')
+ c2 = host.run('systemctl is-active borg_backup@home-data.timer')
+ assert c1.rc == 0
+ assert c2.rc == 0
+ elif hostname in ('borg-client', 'borg-client-2'):
+ timer_name = 'borg_backup@borg-server.timer'
+ c = host.run(f"systemctl is-active {timer_name}")
+ assert c.rc == 0
+ else:
+ pytest.fail(f"Unexpected hostname: {hostname}")
+
+ def test_daemon_reload_ok(self, host):
+ c = host.run('systemctl daemon-reload')
+ assert c.rc == 0
diff --git a/molecule/default/tests/test_systemd_success_exit_status.py b/molecule/default/tests/test_systemd_success_exit_status.py
deleted file mode 100644
index 61c20c4..0000000
--- a/molecule/default/tests/test_systemd_success_exit_status.py
+++ /dev/null
@@ -1,33 +0,0 @@
-"""Tests for systemd success exit status functionality"""
-import pytest
-
-testinfra_hosts = ['borg-client', 'borg-client-success-exit-status']
-
-
-def test_systemd_service_has_correct_success_exit_status(host):
- service = host.file('/etc/systemd/system/borg_backup@borg-server.service')
- assert service.exists
-
- if host.backend.get_hostname() == 'borg-client-success-exit-status':
- assert service.contains('SuccessExitStatus=1 TEMPFAIL')
- else:
- assert not service.contains('SuccessExitStatus=')
-
-
-def test_systemd_timer_is_enabled(host):
- timer_name = 'borg_backup@borg-server.timer'
- c = host.run(f"systemctl is-enabled {timer_name}")
- assert c.rc == 0
-
-
-def test_systemd_timer_is_active(host):
- timer_name = 'borg_backup@borg-server.timer'
- c = host.run(f"systemctl is-active {timer_name}")
- assert c.rc == 0
-
-
-@pytest.mark.parametrize('exit_status', [1, 'TEMPFAIL'])
-def test_success_exit_status_values(host, exit_status):
- if host.backend.get_hostname() == 'borg-client-success-exit-status':
- service = host.file('/etc/systemd/system/borg_backup@borg-server.service')
- assert service.contains(str(exit_status))
diff --git a/tasks/client_setup.yml b/tasks/client_setup.yml
index b3316c2..1eae105 100644
--- a/tasks/client_setup.yml
+++ b/tasks/client_setup.yml
@@ -34,7 +34,7 @@
{{ "--append-only" if borg_mode_append_only }}
--restrict-to-repository {{ borg_repo_name }}"
{{ ssh_key.public_key }} root@{{ inventory_hostname }}
- search_string: "{{ ssh_key.public_key }}"
+ search_string: --restrict-to-repository {{ borg_repo_name }}" {{ ssh_key.public_key }}
state: present
become: true
delegate_to: "{{ borg_server_host }}"