11 Commits
Author SHA1 Message Date
volker.raschekandCopilot c7be4a7999 fix(ci): use renamed ansible-lint configuration
Ansible Linter / ansible-lint (push) Failing after 1m17s
Lint Markdown files / markdown-lint (push) Successful in 11s
Molecule / Molecule (push) Successful in 4m51s
Release Ansible Role / Release Ansible Role (push) Successful in 3m7s
Point the Gitea lint workflow at .ansible-lint.yml so it can load the renamed configuration.

Co-authored-by: Copilot <copilot@github.com>
2026-10-18 14:51:01 +02:00
volker.raschekandCopilot 7c1939b54a ci: run Molecule on pushes and pull requests
Ansible Linter / ansible-lint (push) Failing after 4m7s
Lint Markdown files / markdown-lint (push) Successful in 12s
Molecule / Molecule (push) Successful in 5m19s
Exercise the role across supported test platforms to catch regressions before merging.

Co-authored-by: Copilot <copilot@github.com>
2026-10-18 14:46:48 +02:00
volker.raschekandCopilot d7ab7e34d1 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>
2026-10-18 14:46:48 +02:00
volker.raschekandCopilot c2f50c4423 fix(ssh)!: refresh public keys after key rotation
Derive public keys on every run and update their files only when the content changes, so existing public keys cannot become stale.

BREAKING CHANGE: The role no longer generates a key pair automatically when root.ssh.private_keys is empty or omitted. Provide private keys explicitly when needed.

Co-authored-by: Copilot <copilot@github.com>
2026-10-18 14:46:48 +02:00
volker.raschekandCopilot 44716aa722 chore: standardize YAML file extensions
Use .yml consistently across role metadata and tooling configuration.

Co-authored-by: Copilot <copilot@github.com>
2026-10-18 14:46:48 +02:00
volker.raschekandCopilot 7fa94793d9 chore: rename ansible-lint configuration
Use a YAML extension for explicit file type detection.

Co-authored-by: Copilot <copilot@github.com>
2026-10-18 14:44:27 +02:00
volker.raschekandCopilot 14984e7469 fix(ssh): use inline SSH key content
Lint Markdown files / markdown-lint (push) Successful in 26s
Ansible Linter / ansible-lint (push) Successful in 1m54s
Release Ansible Role / Release Ansible Role (push) Successful in 3m9s
Co-authored-by: Copilot <copilot@github.com>
2026-10-17 23:17:59 +02:00
volker.raschek 874e1d633a fix(ci): add workflow dispatch for releases
Ansible Linter / ansible-lint (push) Successful in 1m1s
Lint Markdown files / markdown-lint (push) Successful in 14s
2026-01-11 21:16:04 +01:00
volker.raschek a55e12762f fix(ci): use dynamic github repository name
Ansible Linter / ansible-lint (push) Successful in 17s
Lint Markdown files / markdown-lint (push) Successful in 4s
2026-01-07 16:11:13 +01:00
volker.raschek 2afad33ad2 fix(ci): add release workflow
Ansible Linter / ansible-lint (push) Successful in 19s
Lint Markdown files / markdown-lint (push) Successful in 5s
2026-01-07 16:05:37 +01:00
volker.raschek adc35381da fix: replace deprecated INJECT_FACTS_AS_VARS
Ansible Linter / ansible-lint (push) Successful in 38s
Lint Markdown files / markdown-lint (push) Successful in 4s
2026-01-05 10:27:52 +01:00
23 changed files with 432 additions and 50 deletions

No files matched your search

File renamed without changes.
@@ -16,5 +16,5 @@ jobs:
- name: Run ansible-lint
uses: ansible/ansible-lint@v25.2.1
with:
args: "--config-file .ansible-lint"
args: "--config-file .ansible-lint.yml"
setup_python: "true"
+29
View File
@@ -0,0 +1,29 @@
name: Molecule
on:
pull_request:
types: [ "opened", "reopened", "synchronize" ]
push:
branches: [ '**' ]
tags-ignore: [ '**' ]
permissions:
contents: read
jobs:
molecule:
name: Molecule
runs-on: ubuntu-latest-amd64
steps:
# The scenario includes the role by its directory name, whose parent is used as roles path.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
path: root
- name: Install molecule
run: |
apt update --yes
apt install --yes python3-pip
pip3 install --break-system-packages molecule docker
- name: Run molecule
run: molecule test
working-directory: root
+22
View File
@@ -0,0 +1,22 @@
name: Release Ansible Role
on:
push:
tags:
- '**'
workflow_dispatch: {}
jobs:
release:
name: Release Ansible Role
runs-on: ubuntu-latest
steps:
- name: Install Ansible Galaxy
run: |
apt update --yes
apt install --yes ansible
- env:
ANSIBLE_GALAXY_TOKEN: ${{ secrets.ANSIBLE_GALAXY_TOKEN }}
name: Update Ansible Role in Ansible Galaxy
run: |
ansible-galaxy role import --token=${ANSIBLE_GALAXY_TOKEN} volker-raschek ${GITHUB_REPOSITORY#*/}
File renamed without changes.
+2 -2
View File
@@ -3,12 +3,12 @@
"files.associations": {
"**/.gitea/**/*.yml": "yaml",
"**/.gitea/**/*.yaml": "yaml",
".drone.yml": "yaml",
"docker-compose*.yml": "dockercompose",
"*.yml": "ansible",
"*.yaml": "ansible",
".yamllint": "yaml",
".yamllint.yml": "yaml",
".yamllint.yaml": "yaml"
}
},
"rewrap.wrappingColumn": 120
}
+2
View File
@@ -1,3 +1,5 @@
---
#
# Documentation:
# https://yamllint.readthedocs.io/en/stable/
-12
View File
@@ -1,12 +0,0 @@
---
root: {}
# password: ""
# ssh:
# config:
# - Host: "*"
# StrictHostKeyChecking: "no"
# UserKnownHostFile: /dev/null
# authorized_keys:
# - filename: markus@markus-pc.pub
# # command: "" # optional
+18
View File
@@ -0,0 +1,18 @@
---
root: {}
# password: ""
# ssh:
# config:
# - Host: "*"
# StrictHostKeyChecking: "no"
# UserKnownHostFile: /dev/null
# authorized_keys:
# - content: ssh-ed25519 AAAAC3NzaC... alice
# # command: "" # optional
# private_keys:
# - filename: alice@alice-pc.ed25519.key
# content: |
# -----BEGIN OPENSSH PRIVATE KEY-----
# ...
# -----END OPENSSH PRIVATE KEY-----
File renamed without changes.
+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 }}"
+3
View File
@@ -0,0 +1,3 @@
---
collections: []
+22 -33
View File
@@ -52,27 +52,6 @@
root.ssh.authorized_keys is not defined or
root.ssh is not defined
- name: "Generate private and public SSH key"
when: root.ssh is defined and
(
root.ssh.private_keys is not defined or
root.ssh.private_keys | length <= 0
)
block:
- name: "Check if private SSH key already exists"
ansible.builtin.stat:
path: "/root/.ssh/{{ inventory_hostname_short }}.key"
register: stat_result
- name: "Generate private SSH key"
community.crypto.openssh_keypair:
path: "/root/.ssh/{{ inventory_hostname_short }}.key"
type: ed25519
size: 512
state: present
comment: "root@{{ inventory_hostname_short }}"
force: false
when: not stat_result.stat.exists
- name: "Create private and public SSH key"
when: root.ssh is defined and
root.ssh.private_keys is defined and
@@ -80,24 +59,34 @@
block:
- name: "Create private SSH keys"
ansible.builtin.copy:
src: "{{ playbook_dir }}/ssh/private_keys/{{ item }}"
dest: "/root/.ssh/{{ item }}"
content: "{{ item.content }}"
dest: "/root/.ssh/{{ item.filename }}"
owner: "root"
group: "root"
mode: "0600"
with_items:
- "{{ root.ssh.private_keys }}"
loop: "{{ root.ssh.private_keys }}"
loop_control:
label: "{{ item.filename }}"
- name: "Extract public SSH keys from private keys"
ansible.builtin.shell:
args:
executable: /bin/bash
cmd: "ssh-keygen -y -f /root/.ssh/{{ item }} > /root/.ssh/{{ item }}.pub"
ansible.builtin.command:
argv: [ "ssh-keygen", "-y", "-f", "/root/.ssh/{{ item.filename }}" ]
register: _root_ssh_pub_key_extraction
changed_when: _root_ssh_pub_key_extraction.rc == 0
failed_when: _root_ssh_pub_key_extraction.rc > 0
with_items:
- "{{ root.ssh.private_keys }}"
changed_when: false
loop: "{{ root.ssh.private_keys }}"
loop_control:
label: "{{ item.filename }}"
- name: "Write derived public SSH keys"
ansible.builtin.copy:
content: "{{ item.stdout }}\n"
dest: "/root/.ssh/{{ item.item.filename }}.pub"
owner: "root"
group: "root"
mode: "0644"
loop: "{{ _root_ssh_pub_key_extraction.results }}"
loop_control:
label: "{{ item.item.filename }}"
- name: "Create custom SSH client config"
ansible.builtin.template:
+2 -2
View File
@@ -3,8 +3,8 @@
#
{% for authorized_key in root.ssh.authorized_keys %}
{% if authorized_key.command is defined and authorized_key.command | length > 0 %}
command="{{ authorized_key.command }}" {{ lookup('file', 'ssh/authorized_keys/' + authorized_key.filename ) }}
command="{{ authorized_key.command }}" {{ authorized_key.content | trim }}
{% else %}
{{ lookup('file', 'ssh/authorized_keys/' + authorized_key.filename ) }}
{{ authorized_key.content | trim }}
{% endif %}
{% endfor %}