diff --git a/.gitignore b/.gitignore index feaf22e..2d60434 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -.ansible \ No newline at end of file +.ansible +molecule/default/files/ssh \ No newline at end of file diff --git a/README.md b/README.md index dbc0f2f..68353fe 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,46 @@ ansible-galaxy collection install -r requirements.yml The role manages users, groups and their home directories, so it has to be executed with `become: true`. +## Tests + +The role is tested with [Molecule](https://ansible.readthedocs.io/projects/molecule/). The scenario starts one docker +container per supported distribution family, applies the role, asserts that a second run reports no change and finally +verifies the created users and groups, the permissions and the content of the managed files and that a user declared +as `absent` is gone again. A btrfs home is not covered, because a container has no btrfs filesystem to create a +subvolume on. + +The ssh key pair the scenario feeds into the role is generated during `molecule create` and removed again during +`molecule destroy`, so no private key is kept in the repository. + +Molecule ships only its `default` driver, therefore `docker` is required besides molecule itself. The collections are +declared in `molecule/default/collections.yml` and installed by molecule. + +```bash +pip install molecule docker +``` + +The complete sequence creates the containers, tests them and removes them afterwards. + +```bash +molecule test +``` + +While working on the role the containers are better kept alive. + +```bash +# create the containers and apply the role +molecule converge + +# run the assertions of molecule/default/verify.yml against the running containers +molecule verify + +# open a shell in one of the containers +molecule login --host unix-users-debian + +# remove the containers +molecule destroy +``` + ## Examples ### User and group diff --git a/molecule/default/collections.yml b/molecule/default/collections.yml new file mode 100644 index 0000000..f8a15ca --- /dev/null +++ b/molecule/default/collections.yml @@ -0,0 +1,5 @@ +--- + +collections: +- name: community.docker +- name: community.general diff --git a/molecule/default/converge.yml b/molecule/default/converge.yml new file mode 100644 index 0000000..39bdc11 --- /dev/null +++ b/molecule/default/converge.yml @@ -0,0 +1,64 @@ +--- + +# A btrfs home is not covered, a container has no btrfs filesystem to create a subvolume on. +- name: Converge + hosts: all + vars: + unix_groups: + molecule-alice: + # An unquoted gid is an integer, the role has to cope with that. + gid: 4242 + state: present + molecule-bob: + state: present + molecule-obsolete: + state: absent + unix_users: + molecule-alice: + state: present + name: Alice + uid: 4242 + group: molecule-alice + home: /home/molecule-alice + shell: /bin/bash + password: alice + email: alice@example.local + ssh: + config: + - Host: "*" + StrictHostKeyChecking: "no" + authorized_keys: + - filename: molecule.pub + command: "/usr/bin/true" + envs: + - key: EDITOR + value: vi + private_keys: + - molecule.ed25519.key + netrc: + - machine: hostname.local + login: alice + password: secret + shell_rc_files: + - file: molecule.bashrc + aliases: + - key: dcd + value: docker compose down + envs: + - export: true + key: PATH + value: "${HOME}/bin:${PATH}" + functions: + - name: foo + value: "echo \"bar\"" + # Bob declares nothing optional, so none of the optional files may show up in his home. + molecule-bob: + state: present + group: molecule-bob + molecule-dave: + state: absent + tasks: + # The role is included by the name of its directory, which molecule put on the roles path. + - name: Include the role unix-users + ansible.builtin.include_role: + name: unix-users diff --git a/molecule/default/create.yml b/molecule/default/create.yml new file mode 100644 index 0000000..1e3aa7c --- /dev/null +++ b/molecule/default/create.yml @@ -0,0 +1,49 @@ +--- + +- name: Create + hosts: localhost + gather_facts: false + vars: + _private_key: "{{ molecule_scenario_directory }}/files/ssh/private_keys/molecule.ed25519.key" + _authorized_key: "{{ molecule_scenario_directory }}/files/ssh/authorized_keys/molecule.pub" + 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: Create the fixture directories + ansible.builtin.file: + path: "{{ item | dirname }}" + state: directory + mode: "0700" + loop: + - "{{ _private_key }}" + - "{{ _authorized_key }}" + + # The key pair is generated instead of committed, private key material does not belong into a repository. + - name: Generate the ssh key pair the role is fed with + ansible.builtin.command: + cmd: "ssh-keygen -t ed25519 -N '' -C molecule -f {{ _private_key }}" + creates: "{{ _private_key }}" + + - name: Offer the public key as authorized key fixture + ansible.builtin.copy: + src: "{{ _private_key }}.pub" + dest: "{{ _authorized_key }}" + mode: "0644" diff --git a/molecule/default/destroy.yml b/molecule/default/destroy.yml new file mode 100644 index 0000000..bcd19e4 --- /dev/null +++ b/molecule/default/destroy.yml @@ -0,0 +1,25 @@ +--- + +- 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 }}" + + # The generated key pair is not kept around, a stale private key in the working tree is asking for trouble. + - name: Remove the ssh fixtures + ansible.builtin.file: + path: "{{ molecule_scenario_directory }}/files/ssh" + state: absent + + - name: Empty the instance config + ansible.builtin.copy: + content: "[]" + dest: "{{ molecule_instance_config }}" + mode: "0600" diff --git a/molecule/default/molecule.yml b/molecule/default/molecule.yml new file mode 100644 index 0000000..cc4c10d --- /dev/null +++ b/molecule/default/molecule.yml @@ -0,0 +1,24 @@ +--- + +driver: + name: default + options: + managed: true + login_cmd_template: "docker exec --interactive --tty {instance} bash" + +platforms: +- name: unix-users-archlinux + image: docker.io/library/archlinux:base +- name: unix-users-debian + image: docker.io/library/debian:13 +- name: unix-users-fedora + image: registry.fedoraproject.org/fedora:43 + +provisioner: + name: ansible + # The role under test is the project directory itself, so its parent has to be on the roles path. + env: + ANSIBLE_ROLES_PATH: "${MOLECULE_PROJECT_DIRECTORY}/.." + config_options: + defaults: + interpreter_python: auto_silent diff --git a/molecule/default/prepare.yml b/molecule/default/prepare.yml new file mode 100644 index 0000000..e900388 --- /dev/null +++ b/molecule/default/prepare.yml @@ -0,0 +1,37 @@ +--- + +- name: Prepare + hosts: all + gather_facts: false + vars: + # The base images ship neither a python interpreter for ansible nor ssh-keygen, which the role shells out to. + _bootstrap: | + set -eu + if command -v pacman > /dev/null; then + pacman --sync --refresh --noconfirm openssh python shadow + elif command -v apt-get > /dev/null; then + apt-get update + apt-get install --yes openssh-client passwd python3 + else + dnf install --assumeyes openssh-clients python3 shadow-utils + fi + tasks: + # The raw command is wrapped explicitly, because the bootstrap relies on shell builtins. + - name: Bootstrap the python interpreter and the tools required by the role + ansible.builtin.raw: "/bin/sh -c {{ _bootstrap | quote }}" + changed_when: true + +# The removal paths of the role can only be observed on objects that exist before the role runs. +- name: Seed the objects the converge removes again + hosts: all + tasks: + - name: Create the group that the converge removes + ansible.builtin.group: + name: molecule-obsolete + state: present + + - name: Create the user that the converge removes + ansible.builtin.user: + name: molecule-dave + group: users + state: present diff --git a/molecule/default/requirements.yml b/molecule/default/requirements.yml new file mode 100644 index 0000000..86b874d --- /dev/null +++ b/molecule/default/requirements.yml @@ -0,0 +1,4 @@ +--- + +# The role has no role dependencies, but molecule warns about the missing file. +roles: [] diff --git a/molecule/default/verify.yml b/molecule/default/verify.yml new file mode 100644 index 0000000..4df9331 --- /dev/null +++ b/molecule/default/verify.yml @@ -0,0 +1,143 @@ +--- + +- name: Verify + hosts: all + vars: + _alice_home: /home/molecule-alice + _bob_home: /home/molecule-bob + _expected_modes: + /home/molecule-alice/.ssh: "0700" + /home/molecule-alice/.ssh/authorized_keys: "0600" + /home/molecule-alice/.ssh/config: "0644" + /home/molecule-alice/.ssh/molecule.ed25519.key: "0600" + /home/molecule-alice/.ssh/molecule.ed25519.key.pub: "0644" + /home/molecule-alice/.forward: "0644" + /home/molecule-alice/.netrc: "0600" + /home/molecule-alice/.bashrc.d: "0755" + /home/molecule-alice/.bashrc.d/molecule.bashrc: "0644" + /home/molecule-alice/.cache: "0755" + /home/molecule-alice/.config: "0755" + /home/molecule-alice/.local/share: "0755" + /home/molecule-alice/.local/state: "0755" + # Only files the role writes are listed, .bashrc originates from the lineinfile task. + _expected_contents: + /home/molecule-alice/.forward: "alice@example.local" + /home/molecule-alice/.netrc: "machine hostname.local login alice password secret" + /home/molecule-alice/.ssh/config: " StrictHostKeyChecking no" + /home/molecule-alice/.bashrc.d/molecule.bashrc: "alias dcd='docker compose down'" + /home/molecule-alice/.bashrc: "source \"/home/molecule-alice/.bashrc.d/molecule.bashrc\"" + tasks: + - name: Stat the files of molecule-alice + ansible.builtin.stat: + path: "{{ item.key }}" + register: _alice_files + loop: "{{ _expected_modes | dict2items }}" + loop_control: + label: "{{ item.key }}" + + - name: Assert that the files of molecule-alice exist with the expected mode and owner + ansible.builtin.assert: + that: + - item.stat.exists + - item.stat.mode == item.item.value + - item.stat.pw_name == 'molecule-alice' + - item.stat.gr_name == 'molecule-alice' + fail_msg: >- + {{ item.item.key }} has mode {{ item.stat.mode | default('none') }} and owner + {{ item.stat.pw_name | default('none') }}:{{ item.stat.gr_name | default('none') }} + instead of {{ item.item.value }} and molecule-alice:molecule-alice + loop: "{{ _alice_files.results }}" + loop_control: + label: "{{ item.item.key }}" + + - name: Read the files of molecule-alice + ansible.builtin.slurp: + src: "{{ item.key }}" + register: _alice_contents + loop: "{{ _expected_contents | dict2items }}" + loop_control: + label: "{{ item.key }}" + + - name: Assert that the files of molecule-alice hold the configured values + ansible.builtin.assert: + that: item.item.value in (item.content | b64decode).splitlines() + fail_msg: "{{ item.item.key }} does not contain the line {{ item.item.value }}" + loop: "{{ _alice_contents.results }}" + loop_control: + label: "{{ item.item.key }}" + + - name: Read the authorized_keys and the extracted public key of molecule-alice + ansible.builtin.slurp: + src: "{{ item }}" + register: _alice_keys + loop: + - "{{ _alice_home }}/.ssh/authorized_keys" + - "{{ _alice_home }}/.ssh/molecule.ed25519.key.pub" + + - name: Assert that the authorized key carries its options and matches the extracted public key + vars: + _authorized_key: "{{ (_alice_keys.results[0].content | b64decode).splitlines() | select('search', 'ssh-ed25519') | first }}" + _public_key: "{{ _alice_keys.results[1].content | b64decode | trim }}" + ansible.builtin.assert: + that: + - _authorized_key.startswith('command="/usr/bin/true",environment="EDITOR=vi" ') + - _public_key.startswith('ssh-ed25519 ') + - _public_key.split()[1] == _authorized_key.split()[2] + fail_msg: "the authorized key of molecule-alice does not match the key extracted from its private key" + + - name: Read the user and group database + ansible.builtin.getent: + database: "{{ item }}" + loop: + - passwd + - group + - shadow + + - name: Assert that molecule-alice was created as declared + vars: + _entry: "{{ ansible_facts['getent_passwd']['molecule-alice'] }}" + ansible.builtin.assert: + that: + - _entry[1] == '4242' + - _entry[2] == '4242' + - _entry[3] == 'Alice' + - _entry[4] == _alice_home + - _entry[5] == '/bin/bash' + fail_msg: "molecule-alice was created as {{ _entry }}" + + - name: Assert that the integer gid of the group molecule-alice was applied + ansible.builtin.assert: + that: ansible_facts['getent_group']['molecule-alice'][1] == '4242' + fail_msg: "the group molecule-alice has gid {{ ansible_facts['getent_group']['molecule-alice'][1] }}" + + # A random salt would produce a new hash on every run, which the idempotence step would report as a change. + - name: Assert that the password hash is derived from a deterministic salt + vars: + _expected_hash: "{{ 'alice' | password_hash('sha512', 'molecule-alice' | hash('sha512') | truncate(16, true, '')) }}" + ansible.builtin.assert: + that: ansible_facts['getent_shadow']['molecule-alice'][0] == _expected_hash + fail_msg: "the password hash of molecule-alice is not reproducible and therefore changes on every run" + + - name: Assert that molecule-dave and the group molecule-obsolete were removed + ansible.builtin.assert: + that: + - "'molecule-dave' not in ansible_facts['getent_passwd']" + - "'molecule-obsolete' not in ansible_facts['getent_group']" + fail_msg: "the removal of molecule-dave or of the group molecule-obsolete did not happen" + + - name: Stat the home of molecule-bob and the files he did not ask for + ansible.builtin.stat: + path: "{{ item }}" + register: _bob_files + loop: + - "{{ _bob_home }}" + - "{{ _bob_home }}/.ssh" + - "{{ _bob_home }}/.forward" + - "{{ _bob_home }}/.netrc" + + - name: Assert that molecule-bob got a home but none of the optional files + ansible.builtin.assert: + that: + - _bob_files.results[0].stat.exists + - not _bob_files.results[1:] | map(attribute='stat.exists') | select | list + fail_msg: "molecule-bob has files that were never declared for him"