diff options
| -rw-r--r-- | molecule/default/converge.yml | 83 | ||||
| -rw-r--r-- | molecule/default/molecule.yml | 18 | ||||
| -rw-r--r-- | molecule/default/tests/test_client_setup.py | 192 | ||||
| -rw-r--r-- | molecule/default/tests/test_decryption_keys.py | 122 | ||||
| -rw-r--r-- | molecule/default/tests/test_installation.py | 18 | ||||
| -rw-r--r-- | molecule/default/tests/test_manual_backup.py | 30 | ||||
| -rw-r--r-- | molecule/default/tests/test_server_setup.py | 157 | ||||
| -rw-r--r-- | molecule/default/tests/test_ssh_connectivity.py | 116 | ||||
| -rw-r--r-- | molecule/default/tests/test_systemd.py | 168 | ||||
| -rw-r--r-- | molecule/default/tests/test_systemd_success_exit_status.py | 33 | ||||
| -rw-r--r-- | tasks/client_setup.yml | 2 |
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 }}" |