test: cover root configuration with Molecule

Verify SSH key rotation, file permissions, optional file removal and password management on Arch Linux, Debian and Fedora. Keep container dependencies scoped to the test scenario.

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
volker.raschekandCopilot committed 2026-10-18 14:46:48 +02:00
1 parent c2f50c4423
commit d7ab7e34d1
10 files changed
+334

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
---
collections:
- name: community.docker
+11
View File
@@ -0,0 +1,11 @@
---
- name: Converge
hosts: all
vars:
root: "{{ _root_configuration }}"
vars_files:
- vars/main.yml
tasks:
- name: Include the role root
ansible.builtin.include_role:
name: root
+41
View File
@@ -0,0 +1,41 @@
---
- name: Create
hosts: localhost
gather_facts: false
tasks:
- name: Start a container per platform
community.docker.docker_container:
name: "{{ item.name }}"
image: "{{ item.image }}"
command: sleep infinity
state: started
loop: "{{ molecule_yml.platforms }}"
loop_control:
label: "{{ item.name }}"
- name: Write the instance config
ansible.builtin.copy:
content: |
{% for platform in molecule_yml.platforms %}
- instance: {{ platform.name }}
connection: community.docker.docker
{% endfor %}
dest: "{{ molecule_instance_config }}"
mode: "0600"
- name: Generate temporary SSH key pairs
ansible.builtin.command:
argv:
- ssh-keygen
- -t
- ed25519
- -N
- ""
- -C
- molecule-root
- -f
- "{{ molecule_ephemeral_directory }}/{{ item }}"
creates: "{{ molecule_ephemeral_directory }}/{{ item }}"
loop:
- original.ed25519
- replacement.ed25519
+28
View File
@@ -0,0 +1,28 @@
---
- name: Destroy
hosts: localhost
gather_facts: false
tasks:
- name: Remove the container of every platform
community.docker.docker_container:
name: "{{ item.name }}"
state: absent
loop: "{{ molecule_yml.platforms }}"
loop_control:
label: "{{ item.name }}"
- name: Remove temporary SSH key pairs
ansible.builtin.file:
path: "{{ molecule_ephemeral_directory }}/{{ item }}"
state: absent
loop:
- original.ed25519
- original.ed25519.pub
- replacement.ed25519
- replacement.ed25519.pub
- name: Empty the instance config
ansible.builtin.copy:
content: "[]"
dest: "{{ molecule_instance_config }}"
mode: "0600"
+23
View File
@@ -0,0 +1,23 @@
---
driver:
name: default
options:
managed: true
login_cmd_template: "docker exec --interactive --tty {instance} bash"
platforms:
- name: root-archlinux
image: docker.io/library/archlinux:base
- name: root-debian
image: docker.io/library/debian:13
- name: root-fedora
image: registry.fedoraproject.org/fedora:43
provisioner:
name: ansible
env:
ANSIBLE_ROLES_PATH: "${MOLECULE_PROJECT_DIRECTORY}/.."
config_options:
defaults:
interpreter_python: auto_silent
+19
View File
@@ -0,0 +1,19 @@
---
- name: Prepare
hosts: all
gather_facts: false
vars:
_bootstrap: |
set -eu
if command -v pacman > /dev/null; then
pacman --sync --refresh --noconfirm openssh openssl python shadow
elif command -v apt-get > /dev/null; then
apt-get update
apt-get install --yes openssh-client openssl passwd python3
else
dnf install --assumeyes openssh-clients openssl python3 shadow-utils
fi
tasks:
- name: Bootstrap Python and the tools required by the role
ansible.builtin.raw: "/bin/sh -c {{ _bootstrap | quote }}"
changed_when: true
+3
View File
@@ -0,0 +1,3 @@
---
roles: []
+14
View File
@@ -0,0 +1,14 @@
---
_root_configuration:
ssh:
authorized_keys:
- content: '{{ lookup("ansible.builtin.file", molecule_ephemeral_directory + "/original.ed25519.pub") }}'
command: /usr/bin/true
- content: '{{ lookup("ansible.builtin.file", molecule_ephemeral_directory + "/replacement.ed25519.pub") }}'
config:
- Host: "*"
StrictHostKeyChecking: "no"
private_keys:
- content: '{{ lookup("ansible.builtin.file", molecule_ephemeral_directory + "/original.ed25519", rstrip=false) }}'
filename: molecule.ed25519
+188
View File
@@ -0,0 +1,188 @@
---
- name: Verify
hosts: all
vars:
_expected_modes:
/root/.ssh: "0700"
/root/.ssh/authorized_keys: "0600"
/root/.ssh/config: "0644"
/root/.ssh/molecule.ed25519: "0600"
/root/.ssh/molecule.ed25519.pub: "0644"
vars_files:
- vars/main.yml
tasks:
- name: Stat the SSH directory and files
ansible.builtin.stat:
path: "{{ item.key }}"
loop: "{{ _expected_modes | dict2items }}"
loop_control:
label: "{{ item.key }}"
register: _root_files
- name: Assert SSH ownership and permissions
ansible.builtin.assert:
that:
- item.stat.exists
- item.stat.mode == item.item.value
- item.stat.pw_name == 'root'
- item.stat.gr_name == 'root'
loop: "{{ _root_files.results }}"
loop_control:
label: "{{ item.item.key }}"
- name: Read the SSH files
ansible.builtin.slurp:
src: "{{ item }}"
loop:
- /root/.ssh/authorized_keys
- /root/.ssh/config
- /root/.ssh/molecule.ed25519
- /root/.ssh/molecule.ed25519.pub
no_log: true
register: _root_contents
- name: Assert SSH file contents
vars:
_original_public_key: '{{ lookup("ansible.builtin.file", molecule_ephemeral_directory + "/original.ed25519.pub") }}'
_replacement_public_key: '{{ lookup("ansible.builtin.file", molecule_ephemeral_directory + "/replacement.ed25519.pub") }}'
ansible.builtin.assert:
that:
- (('command="/usr/bin/true" ' + _original_public_key) in (_root_contents.results[0].content | b64decode).splitlines())
- _replacement_public_key in (_root_contents.results[0].content | b64decode).splitlines()
- "'Host *' in (_root_contents.results[1].content | b64decode).splitlines()"
- "' StrictHostKeyChecking no' in (_root_contents.results[1].content | b64decode).splitlines()"
- (_root_contents.results[2].content | b64decode) == _root_configuration.ssh.private_keys[0].content
- (_root_contents.results[3].content | b64decode).split()[1] == _original_public_key.split()[1]
no_log: true
- name: Verify SSH key rotation
hosts: all
vars:
root:
ssh:
authorized_keys: "{{ _root_configuration.ssh.authorized_keys }}"
config: "{{ _root_configuration.ssh.config }}"
private_keys:
- content: '{{ lookup("ansible.builtin.file", molecule_ephemeral_directory + "/replacement.ed25519", rstrip=false) }}'
filename: molecule.ed25519
vars_files:
- vars/main.yml
tasks:
- name: Replace the private key while keeping the public key file
ansible.builtin.include_role:
name: root
- name: Read the public key after rotation
ansible.builtin.slurp:
src: /root/.ssh/molecule.ed25519.pub
register: _rotated_public_key
- name: Assert the existing public key was updated
vars:
_expected_public_key: '{{ lookup("ansible.builtin.file", molecule_ephemeral_directory + "/replacement.ed25519.pub") }}'
ansible.builtin.assert:
that:
- (_rotated_public_key.content | b64decode).split()[1] == _expected_public_key.split()[1]
- _rotated_public_key.content != _root_contents.results[3].content
- name: Verify root password and empty authorized keys
hosts: all
vars:
root:
password: molecule-root-password
ssh:
authorized_keys: []
tasks:
- name: Set the root password and remove empty optional SSH files
ansible.builtin.include_role:
name: root
- name: Read the root password entry
ansible.builtin.getent:
database: shadow
key: root
no_log: true
- name: Hash the test password using the stored salt and rounds
ansible.builtin.command:
argv:
- openssl
- passwd
- -6
- -salt
- "{{ ansible_facts.getent_shadow.root[0].split('$')[2:-1] | join('$') }}"
- -stdin
stdin: molecule-root-password
changed_when: false
no_log: true
register: _expected_password_hash
- name: Assert the root password was set and unlocked
ansible.builtin.assert:
that:
- ansible_facts.getent_shadow.root[0].startswith('$6$')
- ansible_facts.getent_shadow.root[0] == _expected_password_hash.stdout
- ansible_become_pass == 'molecule-root-password'
no_log: true
- name: Stat removed optional SSH files
ansible.builtin.stat:
path: "{{ item }}"
loop:
- /root/.ssh/authorized_keys
- /root/.ssh/config
register: _removed_files
- name: Assert empty and undefined optional SSH files were removed
ansible.builtin.assert:
that: not item.stat.exists
loop: "{{ _removed_files.results }}"
loop_control:
label: "{{ item.item }}"
- name: Restore SSH settings
hosts: all
vars:
root: "{{ _root_configuration }}"
vars_files:
- vars/main.yml
tasks:
- name: Restore optional SSH files before testing omitted SSH settings
ansible.builtin.include_role:
name: root
- name: Verify root locking and omitted SSH settings
hosts: all
vars:
root:
password: ""
tasks:
- name: Lock root and remove omitted optional SSH files
ansible.builtin.include_role:
name: root
- name: Read the locked root password entry
ansible.builtin.getent:
database: shadow
key: root
no_log: true
- name: Assert root login is locked
ansible.builtin.assert:
that: ansible_facts.getent_shadow.root[0].startswith('!')
no_log: true
- name: Stat optional SSH files after omitting SSH settings
ansible.builtin.stat:
path: "{{ item }}"
loop:
- /root/.ssh/authorized_keys
- /root/.ssh/config
register: _omitted_files
- name: Assert omitted optional SSH files were removed
ansible.builtin.assert:
that: not item.stat.exists
loop: "{{ _omitted_files.results }}"
loop_control:
label: "{{ item.item }}"