Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e580de5e4c
|
||
|
|
9b77592091
|
||
|
|
a343205fd3
|
||
|
|
00f465e5e1
|
||
|
|
5f757914be
|
||
|
|
f222b1c615
|
||
|
|
279ee1929a
|
||
|
|
41a645f748
|
||
|
|
517d9c1c54
|
||
|
|
095705643c
|
||
|
|
c34b59633c
|
||
|
|
68ee8ebbcd
|
||
|
|
91ba7845da
|
||
|
|
242f291200
|
||
|
|
68be6c5cdf
|
||
|
|
2a32849bd0 | ||
|
|
3c0b5b7386
|
||
|
|
9410b70830 | ||
|
|
780e6df57f
|
||
|
|
da18299804 | ||
|
|
9c1bfdba3b
|
||
|
|
4f272fa518 | ||
|
|
b147d9e827
|
||
|
|
d59bed93c6 | ||
|
|
142df1da6e
|
||
|
|
c7a8e3c5ab | ||
|
|
3afee5bce7
|
||
|
|
73adc91eb3 | ||
|
|
cc6c1b1189
|
||
|
|
151bb67e3d | ||
|
|
b1e7bc5993
|
||
|
|
f34d963618 | ||
|
|
14325d5f4a
|
||
|
|
f322c8078a | ||
|
|
a2bf5cac96
|
||
|
|
2a514a3588 | ||
|
|
c64e2b85b2
|
||
|
|
b14f3ee512
|
||
|
|
a4b28d441b
|
||
|
|
1ec88f37fb
|
||
|
|
09c4173eaf
|
||
|
|
14f856fdea
|
||
|
|
86d85c431c
|
||
|
|
88e9a163e1
|
||
|
|
69491c9aa0
|
||
|
|
47d9a58910
|
||
|
|
e176bb0bee | ||
|
|
9acd6de876
|
||
|
|
c0566e2416 | ||
|
|
509882a193
|
||
|
|
e98925af4b | ||
|
|
8a25dac377
|
||
|
|
7602cc621c | ||
|
|
b7560320dc
|
||
|
|
4241502728 | ||
|
|
f5062ff179
|
||
|
|
e4c12b9856
|
@@ -12,9 +12,11 @@ jobs:
|
||||
runs-on:
|
||||
- ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5.0.1
|
||||
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
- name: Run ansible-lint
|
||||
uses: ansible/ansible-lint@v25.11.0
|
||||
uses: ansible/ansible-lint@665d9e07a1943254d2910faffc106adaf7ea7294 # v26.8.0
|
||||
with:
|
||||
args: "--config-file .ansible-lint"
|
||||
# The molecule scenario is linted as well, so its collections are required beside the ones of the role.
|
||||
requirements_file: "molecule/default/collections.yml"
|
||||
setup_python: "true"
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
runs-on:
|
||||
- ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5.0.1
|
||||
- uses: DavidAnson/markdownlint-cli2-action@v21.0.0
|
||||
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
- uses: DavidAnson/markdownlint-cli2-action@ded1f9488f68a970bc66ea5619e13e9b52e601cd # v23.2.0
|
||||
with:
|
||||
globs: '**/*.md'
|
||||
|
||||
@@ -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@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
with:
|
||||
path: unix-users
|
||||
- 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: unix-users
|
||||
@@ -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#*/}
|
||||
@@ -1 +1,2 @@
|
||||
.ansible
|
||||
molecule/default/files/ssh
|
||||
Vendored
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"ansible.python.interpreterPath": "/bin/python",
|
||||
"files.associations": {
|
||||
"**/.gitea/**/*.yml": "yaml",
|
||||
"**/.gitea/**/*.yaml": "yaml",
|
||||
"docker-compose*.yml": "dockercompose",
|
||||
"*.yml": "ansible",
|
||||
"*.yaml": "ansible",
|
||||
".yamllint": "yaml",
|
||||
".yamllint.yml": "yaml",
|
||||
".yamllint.yaml": "yaml"
|
||||
},
|
||||
"rewrap.wrappingColumn": 120
|
||||
}
|
||||
@@ -6,6 +6,57 @@ The ansible role `volker-raschek.unix-users` create and manage users on Linux ba
|
||||
Linux, Fedora and Ubuntu. Furthermore, the role can also be used to create groups, `~/.forward`, `~/.netrc` and to
|
||||
manage the `~/.ssh` directory.
|
||||
|
||||
## Requirements
|
||||
|
||||
The role requires `ansible-core` 2.11 or newer. A home directory can optionally be created as btrfs subvolume, which
|
||||
relies on the `btrfs_subvolume` module of the collection `community.general`.
|
||||
|
||||
```bash
|
||||
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
|
||||
@@ -34,6 +85,10 @@ unix_users:
|
||||
Optionally, the home directory of a user can also be created as dedicated btrfs subvolume. This make it possible to
|
||||
create snapshots of the home directory, for example via `btrbk`.
|
||||
|
||||
> [!WARNING]
|
||||
> Removing a user with `state: absent` also deletes the btrfs subvolume of the home directory. Snapshots taken from that
|
||||
> subvolume are not removed and keep the data available.
|
||||
|
||||
```yaml
|
||||
unix_users:
|
||||
toor:
|
||||
@@ -77,7 +132,11 @@ The SSH client directory `~/.ssh` can also be managed via the Ansible role. This
|
||||
`~/.ssh/config`, `~/.ssh/authorized_keys` as well as the maintenance of private and public SSH keys.
|
||||
|
||||
The following example create two entries in `~/.ssh/authorized_keys`. One normal SSH access for `claire`. If `bob`
|
||||
establish a SSH connection the command `/usr/local/bin/upload-file.sh` will be executed and exited.
|
||||
establish a SSH connection the command `/usr/local/bin/upload-file.sh` will be executed and exited. Furthermore,
|
||||
environment variables can be espcilitly defined, to consume it during execution of the command.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> To allow consuming environment variables must be set `PermitUserEnvironment yes` in `/etc/ssh/sshd_config`.
|
||||
|
||||
The private key `toor@toor-pc.ed25519.key` must be stored in `ssh/private_keys`. The public key will be automatically
|
||||
extracted from the private key.
|
||||
@@ -99,6 +158,9 @@ unix_users:
|
||||
authorized_keys:
|
||||
- filename: claire@claire-pc.pub
|
||||
- command: /usr/local/bin/upload-file.sh
|
||||
envs:
|
||||
- key: SSH_KEY_NAME
|
||||
value: bob@bob-pc
|
||||
filename: bob@bob-pc.pub
|
||||
private_keys:
|
||||
- toor@toor-pc.ed25519.key
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ galaxy_info:
|
||||
- unix
|
||||
- linux
|
||||
license: "MIT"
|
||||
min_ansible_version: "2.9"
|
||||
min_ansible_version: "2.11"
|
||||
namespace: volker-raschek
|
||||
platforms:
|
||||
- name: ArchLinux
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
|
||||
collections:
|
||||
- name: community.docker
|
||||
- name: community.general
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
|
||||
# The role has no role dependencies, but molecule warns about the missing file.
|
||||
roles: []
|
||||
@@ -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"
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
|
||||
collections:
|
||||
# The btrfs_subvolume module has been added in community.general 6.6.0.
|
||||
- name: community.general
|
||||
version: ">=6.6.0"
|
||||
+11
-10
@@ -1,17 +1,18 @@
|
||||
---
|
||||
|
||||
- name: "Exist file {{ skel_file }}"
|
||||
- name: "Check if the skel file exists: /etc/skel/{{ skel_file }}"
|
||||
ansible.builtin.stat:
|
||||
path: "{{ user_user_home }}/{{ skel_file }}"
|
||||
register: _skel_file
|
||||
path: "/etc/skel/{{ skel_file }}"
|
||||
register: _unix_users_skel_file
|
||||
|
||||
- name: Copy skel file
|
||||
when: _skel_file.stat is defined and
|
||||
_skel_file.stat.exist is defined and
|
||||
not _skel_file.stat.exist
|
||||
# force: false keeps an already customized dotfile in the home directory untouched.
|
||||
- name: "Copy skel file: {{ skel_file }}"
|
||||
ansible.builtin.copy:
|
||||
src: "/etc/skel/{{ _skel_file }}"
|
||||
dest: "{{ user_user_home }}/{{ skel_file }}"
|
||||
src: "/etc/skel/{{ skel_file }}"
|
||||
dest: "{{ _unix_users_home }}/{{ skel_file }}"
|
||||
remote_src: true
|
||||
force: false
|
||||
owner: "{{ unix_user.key }}"
|
||||
group: "{{ unix_user.value.group | default('users') }}"
|
||||
mode: "0600"
|
||||
mode: "0644"
|
||||
when: _unix_users_skel_file.stat.exists
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
- name: "Determine shell rc directory"
|
||||
ansible.builtin.set_fact:
|
||||
# path_join examples: https://docs.ansible.com/ansible/latest/collections/ansible/builtin/path_join_filter.html#examples
|
||||
_shell_rc_file: "{{ (user_user_home, '.bashrc.d', shell_rc_file.file) | path_join }}"
|
||||
_shell_rc_file: "{{ (_unix_users_home, '.bashrc.d', shell_rc_file.file) | path_join }}"
|
||||
|
||||
- name: "Create shell rc directory: {{ _shell_rc_file | dirname }}"
|
||||
ansible.builtin.file:
|
||||
@@ -23,8 +23,10 @@
|
||||
|
||||
- name: "Source shell rc file: {{ _shell_rc_file }}"
|
||||
ansible.builtin.lineinfile:
|
||||
path: "{{ user_user_home }}/.bashrc"
|
||||
path: "{{ _unix_users_home }}/.bashrc"
|
||||
line: "source \"{{ _shell_rc_file }}\""
|
||||
create: true
|
||||
owner: "{{ unix_user.key }}"
|
||||
group: "{{ unix_user.value.group | default('users') }}"
|
||||
mode: "0644"
|
||||
state: "present"
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
---
|
||||
|
||||
- name: "Create unix group with random gid: {{ unix_group.key }}"
|
||||
- name: "Create unix group: {{ unix_group.key }}"
|
||||
ansible.builtin.group:
|
||||
name: "{{ unix_group.key }}"
|
||||
# The boolean form of default also omits an empty gid, which means let the system assign one.
|
||||
gid: "{{ unix_group.value.gid | default(omit, true) }}"
|
||||
state: "{{ unix_group.value.state | default('present') }}"
|
||||
when: unix_group.value.gid is not defined or unix_group.value.gid is defined and unix_group.value.gid | length <= 0
|
||||
|
||||
- name: "Create unix group with pre-defined gid: {{ unix_group.key }}"
|
||||
ansible.builtin.group:
|
||||
name: "{{ unix_group.key }}"
|
||||
gid: "{{ unix_group.value.gid }}"
|
||||
state: "{{ unix_group.value.state | default('present') }}"
|
||||
when: unix_group.value.gid is defined and unix_group.value.gid | length > 0
|
||||
|
||||
+52
-77
@@ -2,74 +2,47 @@
|
||||
|
||||
- name: "Define home directory for unix user: {{ unix_user.key }}"
|
||||
ansible.builtin.set_fact:
|
||||
user_user_home: "{{ unix_user.value.home | default('/home/' + unix_user.key) }}"
|
||||
_unix_users_home: "{{ unix_user.value.home | default('/home/' + unix_user.key) }}"
|
||||
|
||||
- name: "Create btrfs volume for unix user: {{ unix_user.key }}"
|
||||
when: unix_user.value.btrfs is defined and
|
||||
unix_user.value.btrfs
|
||||
block:
|
||||
- name: "Find btrfs device"
|
||||
ansible.builtin.command:
|
||||
cmd: /bin/bash -c "findmnt -no SOURCE -T {{ _unix_users_home }} | sed 's/\[.*\]//'"
|
||||
register: _unix_users_btrfs_device
|
||||
changed_when: false
|
||||
|
||||
- name: "Determine filesystem of device"
|
||||
ansible.builtin.set_fact:
|
||||
_unix_users_device_filesystem: "{{ ansible_facts['mounts'] | selectattr('device', 'equalto', _unix_users_btrfs_device.stdout) | map(attribute='fstype') | first }}"
|
||||
|
||||
- name: "Fail if device does not have a btrfs file system"
|
||||
ansible.builtin.fail:
|
||||
msg: "Determined device {{ _unix_users_btrfs_device.stdout }} does not have a btrfs filesystem"
|
||||
when: _unix_users_device_filesystem != 'btrfs'
|
||||
|
||||
# The subvolume stays root owned until the user exists. It is chowned further below, after the user was created.
|
||||
- name: "Create btrfs volume for unix user: {{ unix_user.key }}"
|
||||
community.general.btrfs_subvolume:
|
||||
name: "{{ user_user_home }}"
|
||||
- name: "Adapt home dir permissions"
|
||||
ansible.builtin.file:
|
||||
path: "{{ user_user_home }}"
|
||||
owner: "{{ unix_user.key }}"
|
||||
group: "{{ unix_user.value.group | default('users') }}"
|
||||
state: directory
|
||||
mode: "0755"
|
||||
filesystem_device: "{{ _unix_users_btrfs_device.stdout }}"
|
||||
name: "{{ _unix_users_home }}"
|
||||
state: present
|
||||
|
||||
- name: "Create unix user without additional groups and uid: {{ unix_user.key }}"
|
||||
- name: "Create unix user: {{ unix_user.key }}"
|
||||
ansible.builtin.user:
|
||||
name: "{{ unix_user.key }}"
|
||||
uid: "{{ unix_user.value.uid | default(omit) }}"
|
||||
group: "{{ unix_user.value.group | default('users') }}"
|
||||
comment: "{{ unix_user.value.name }}"
|
||||
groups: "{{ unix_user.value.groups | default(omit) }}"
|
||||
comment: "{{ unix_user.value.name | default(omit) }}"
|
||||
create_home: "{{ unix_user.value.create_home | default(true) }}"
|
||||
home: "{{ user_user_home }}"
|
||||
home: "{{ _unix_users_home }}"
|
||||
shell: "{{ unix_user.value.shell | default('/bin/bash') }}"
|
||||
password: "{{ unix_user.value.password | password_hash('sha512') if unix_user.value.password is defined and unix_user.value.password | length > 0 else '!' }}"
|
||||
# The salt is derived from the user name, a random one would produce a new hash and a change on every run.
|
||||
password: "{{ unix_user.value.password | password_hash('sha512', unix_user.key | hash('sha512') | truncate(16, true, '')) if unix_user.value.password is defined and unix_user.value.password | length > 0 else '!' }}"
|
||||
state: present
|
||||
when: unix_user.value.groups is not defined and unix_user.value.uid is not defined
|
||||
|
||||
- name: "Create unix user without additional groups and with uid: {{ unix_user.key }}"
|
||||
ansible.builtin.user:
|
||||
name: "{{ unix_user.key }}"
|
||||
uid: "{{ unix_user.value.uid }}"
|
||||
group: "{{ unix_user.value.group | default('users') }}"
|
||||
comment: "{{ unix_user.value.name }}"
|
||||
create_home: "{{ unix_user.value.create_home | default(true) }}"
|
||||
home: "{{ user_user_home }}"
|
||||
shell: "{{ unix_user.value.shell | default('/bin/bash') }}"
|
||||
password: "{{ unix_user.value.password | password_hash('sha512') if unix_user.value.password is defined and unix_user.value.password | length > 0 else '!' }}"
|
||||
state: present
|
||||
when: unix_user.value.groups is not defined and unix_user.value.uid is defined
|
||||
|
||||
- name: "Create unix user with additional groups and uid: {{ unix_user.key }}"
|
||||
ansible.builtin.user:
|
||||
name: "{{ unix_user.key }}"
|
||||
uid: "{{ unix_user.value.uid }}"
|
||||
group: "{{ unix_user.value.group | default('users') }}"
|
||||
groups: "{{ unix_user.value.groups | join(',') }}"
|
||||
comment: "{{ unix_user.value.name }}"
|
||||
create_home: "{{ unix_user.value.create_home | default(true) }}"
|
||||
home: "{{ user_user_home }}"
|
||||
shell: "{{ unix_user.value.shell | default('/bin/bash') }}"
|
||||
password: "{{ unix_user.value.password | password_hash('sha512') if unix_user.value.password is defined and unix_user.value.password | length > 0 else '!' }}"
|
||||
state: present
|
||||
when: unix_user.value.groups is defined and unix_user.value.uid is defined
|
||||
|
||||
- name: "Create unix user with additional groups and without uid: {{ unix_user.key }}"
|
||||
ansible.builtin.user:
|
||||
name: "{{ unix_user.key }}"
|
||||
group: "{{ unix_user.value.group | default('users') }}"
|
||||
groups: "{{ unix_user.value.groups | join(',') }}"
|
||||
comment: "{{ unix_user.value.name }}"
|
||||
create_home: "{{ unix_user.value.create_home | default(true) }}"
|
||||
home: "{{ user_user_home }}"
|
||||
shell: "{{ unix_user.value.shell | default('/bin/bash') }}"
|
||||
password: "{{ unix_user.value.password | password_hash('sha512') if unix_user.value.password is defined and unix_user.value.password | length > 0 else '!' }}"
|
||||
state: present
|
||||
when: unix_user.value.groups is defined and unix_user.value.uid is not defined
|
||||
|
||||
- name: "Adapt permissions and copy skel for unix user: {{ unix_user.key }}"
|
||||
when: unix_user.value.btrfs is defined and
|
||||
@@ -85,7 +58,7 @@
|
||||
- ".bashrc"
|
||||
- name: "Change permission unix users home dir: {{ unix_user.key }}"
|
||||
ansible.builtin.file:
|
||||
path: "{{ user_user_home }}"
|
||||
path: "{{ _unix_users_home }}"
|
||||
owner: "{{ unix_user.key }}"
|
||||
group: "{{ unix_user.value.group | default('users') }}"
|
||||
state: directory
|
||||
@@ -93,16 +66,17 @@
|
||||
|
||||
- name: "Create .ssh directory for unix user: {{ unix_user.key }}"
|
||||
ansible.builtin.file:
|
||||
path: "{{ user_user_home }}/.ssh"
|
||||
path: "{{ _unix_users_home }}/.ssh"
|
||||
owner: "{{ unix_user.key }}"
|
||||
group: "{{ unix_user.value.group | default('users') }}"
|
||||
mode: "0700"
|
||||
state: directory
|
||||
when: unix_user.value.ssh is defined
|
||||
|
||||
- name: "Create authorized_keys file for unix user: {{ unix_user.key }}"
|
||||
ansible.builtin.template:
|
||||
src: authorized_keys.j2
|
||||
dest: "{{ user_user_home }}/.ssh/authorized_keys"
|
||||
dest: "{{ _unix_users_home }}/.ssh/authorized_keys"
|
||||
owner: "{{ unix_user.key }}"
|
||||
group: "{{ unix_user.value.group | default('users') }}"
|
||||
mode: "0600"
|
||||
@@ -110,60 +84,60 @@
|
||||
|
||||
- name: "Remove authorized_keys file for unix user: {{ unix_user.key }}"
|
||||
ansible.builtin.file:
|
||||
path: "{{ user_user_home }}/.ssh/authorized_keys"
|
||||
path: "{{ _unix_users_home }}/.ssh/authorized_keys"
|
||||
state: absent
|
||||
when: unix_user.value.ssh.authorized_keys is not defined or unix_user.value.ssh.authorized_keys | length <= 0
|
||||
|
||||
# The relative source is resolved against the files directory of the playbook, like the lookup in authorized_keys.j2.
|
||||
- name: "Create private SSH keys for unix user: {{ unix_user.key }}"
|
||||
ansible.builtin.copy:
|
||||
src: "{{ playbook_dir }}/ssh/private_keys/{{ item }}"
|
||||
dest: "{{ user_user_home }}/.ssh/{{ item }}"
|
||||
src: "ssh/private_keys/{{ item }}"
|
||||
dest: "{{ _unix_users_home }}/.ssh/{{ item }}"
|
||||
owner: "{{ unix_user.key }}"
|
||||
group: "{{ unix_user.value.group | default('users') }}"
|
||||
mode: "0600"
|
||||
no_log: true
|
||||
with_items:
|
||||
- "{{ unix_user.value.ssh.private_keys }}"
|
||||
when: unix_user.value.ssh.private_keys is defined and unix_user.value.ssh.private_keys | length > 0
|
||||
|
||||
- name: "Extract public SSH keys from private keys for unix user: {{ unix_user.key }}"
|
||||
ansible.builtin.shell:
|
||||
args:
|
||||
executable: /bin/bash
|
||||
cmd: "ssh-keygen -y -f {{ user_user_home }}/.ssh/{{ item }} > {{ user_user_home }}/.ssh/{{ item }}.pub"
|
||||
creates: "{{ user_user_home }}/.ssh/{{ item }}.pub"
|
||||
cmd: "ssh-keygen -y -f {{ _unix_users_home }}/.ssh/{{ item }} > {{ _unix_users_home }}/.ssh/{{ item }}.pub"
|
||||
creates: "{{ _unix_users_home }}/.ssh/{{ item }}.pub"
|
||||
with_items:
|
||||
- "{{ unix_user.value.ssh.private_keys }}"
|
||||
when: unix_user.value.ssh.private_keys is defined and unix_user.value.ssh.private_keys | length > 0
|
||||
|
||||
- name: "Correct permissions of public SSH keys for unix user: {{ unix_user.key }}"
|
||||
ansible.builtin.file:
|
||||
path: "{{ user_user_home }}/.ssh/{{ item }}.pub"
|
||||
path: "{{ _unix_users_home }}/.ssh/{{ item }}.pub"
|
||||
owner: "{{ unix_user.key }}"
|
||||
group: "{{ unix_user.value.group | default('users') }}"
|
||||
mode: "0644"
|
||||
with_items:
|
||||
- "{{ unix_user.value.ssh.private_keys }}"
|
||||
when: unix_user.value.ssh.private_keys is defined and unix_user.value.ssh.private_keys | length >= 0
|
||||
when: unix_user.value.ssh.private_keys is defined and unix_user.value.ssh.private_keys | length > 0
|
||||
|
||||
- name: "Create custom SSH client config for unix user: {{ unix_user.key }}"
|
||||
ansible.builtin.template:
|
||||
src: config.j2
|
||||
dest: "{{ user_user_home }}/.ssh/config"
|
||||
dest: "{{ _unix_users_home }}/.ssh/config"
|
||||
owner: "{{ unix_user.key }}"
|
||||
group: "{{ unix_user.value.group | default('users') }}"
|
||||
mode: "0644"
|
||||
when: unix_user.value.ssh.config is defined and unix_user.value.ssh.config | length >= 0
|
||||
when: unix_user.value.ssh.config is defined and unix_user.value.ssh.config | length > 0
|
||||
|
||||
- name: "Remove custom SSH client config for unix user: {{ unix_user.key }}"
|
||||
ansible.builtin.file:
|
||||
path: "{{ user_user_home }}/.ssh/config"
|
||||
path: "{{ _unix_users_home }}/.ssh/config"
|
||||
state: absent
|
||||
when: unix_user.value.ssh.config is not defined
|
||||
when: unix_user.value.ssh.config is not defined or unix_user.value.ssh.config | length <= 0
|
||||
|
||||
- name: "Create .forward file to forward emails for unix user: {{ unix_user.key }}"
|
||||
ansible.builtin.template:
|
||||
src: forward.j2
|
||||
dest: "{{ user_user_home }}/.forward"
|
||||
dest: "{{ _unix_users_home }}/.forward"
|
||||
owner: "{{ unix_user.key }}"
|
||||
group: "{{ unix_user.value.group | default('users') }}"
|
||||
mode: "0644"
|
||||
@@ -171,7 +145,7 @@
|
||||
|
||||
- name: "Remove .forward file to forward emails for unix user: {{ unix_user.key }}"
|
||||
ansible.builtin.file:
|
||||
path: "{{ user_user_home }}/.forward"
|
||||
path: "{{ _unix_users_home }}/.forward"
|
||||
state: absent
|
||||
when: unix_user.value.email is not defined
|
||||
|
||||
@@ -183,10 +157,10 @@
|
||||
mode: "0755"
|
||||
state: "directory"
|
||||
with_items:
|
||||
- "{{ unix_user.value.xdg.dirs.cache | default(user_user_home + '/.cache') }}"
|
||||
- "{{ unix_user.value.xdg.dirs.config | default(user_user_home + '/.config') }}"
|
||||
- "{{ unix_user.value.xdg.dirs.data | default(user_user_home + '/.local/share') }}"
|
||||
- "{{ unix_user.value.xdg.dirs.state | default(user_user_home + '/.local/state') }}"
|
||||
- "{{ unix_user.value.xdg.dirs.cache | default(_unix_users_home + '/.cache') }}"
|
||||
- "{{ unix_user.value.xdg.dirs.config | default(_unix_users_home + '/.config') }}"
|
||||
- "{{ unix_user.value.xdg.dirs.data | default(_unix_users_home + '/.local/share') }}"
|
||||
- "{{ unix_user.value.xdg.dirs.state | default(_unix_users_home + '/.local/state') }}"
|
||||
|
||||
- name: "Create shell rc files"
|
||||
when: unix_user.value.shell_rc_files is defined
|
||||
@@ -200,7 +174,8 @@
|
||||
when: unix_user.value.netrc is defined and unix_user.value.netrc | length > 0
|
||||
ansible.builtin.template:
|
||||
src: netrc.j2
|
||||
dest: "{{ user_user_home }}/.netrc"
|
||||
dest: "{{ _unix_users_home }}/.netrc"
|
||||
owner: "{{ unix_user.key }}"
|
||||
group: "{{ unix_user.value.group | default('users') }}"
|
||||
mode: "0600"
|
||||
no_log: true
|
||||
|
||||
@@ -1,7 +1,37 @@
|
||||
---
|
||||
|
||||
- name: Remove unix user {{ unix_user.key }}
|
||||
- name: "Define home directory for unix user: {{ unix_user.key }}"
|
||||
ansible.builtin.set_fact:
|
||||
_unix_users_home: "{{ unix_user.value.home | default('/home/' + unix_user.key) }}"
|
||||
|
||||
# userdel cannot remove a btrfs subvolume. Such a home is deleted afterwards via the btrfs_subvolume module.
|
||||
- name: "Remove unix user: {{ unix_user.key }}"
|
||||
ansible.builtin.user:
|
||||
name: "{{ unix_user.key }}"
|
||||
state: absent
|
||||
remove: true
|
||||
remove: "{{ not (unix_user.value.btrfs | default(false)) }}"
|
||||
|
||||
- name: "Remove btrfs home of unix user: {{ unix_user.key }}"
|
||||
when: unix_user.value.btrfs is defined and
|
||||
unix_user.value.btrfs
|
||||
block:
|
||||
- name: "Stat home directory"
|
||||
ansible.builtin.stat:
|
||||
path: "{{ _unix_users_home }}"
|
||||
register: _unix_users_home_stat
|
||||
|
||||
# findmnt fails on a missing path, so the device is only determined as long as the home directory exists.
|
||||
- name: "Delete btrfs subvolume of an existing home directory"
|
||||
when: _unix_users_home_stat.stat.exists
|
||||
block:
|
||||
- name: "Find btrfs device"
|
||||
ansible.builtin.command:
|
||||
cmd: /bin/bash -c "findmnt -no SOURCE -T {{ _unix_users_home }} | sed 's/\[.*\]//'"
|
||||
register: _unix_users_btrfs_device
|
||||
changed_when: false
|
||||
|
||||
- name: "Delete btrfs subvolume: {{ _unix_users_home }}"
|
||||
community.general.btrfs_subvolume:
|
||||
filesystem_device: "{{ _unix_users_btrfs_device.stdout }}"
|
||||
name: "{{ _unix_users_home }}"
|
||||
state: absent
|
||||
|
||||
@@ -3,8 +3,25 @@
|
||||
# {{ ansible_managed }}
|
||||
#
|
||||
{% for authorized_key in unix_user.value.ssh.authorized_keys %}
|
||||
{% set _args = [] %}
|
||||
{% if authorized_key.command is defined and authorized_key.command | length > 0 %}
|
||||
command="{{ authorized_key.command }}" {{ lookup('file', 'ssh/authorized_keys/' + authorized_key.filename ) }}
|
||||
{% set _args = _args + [ "command=\"" + authorized_key.command + "\"" ] %}
|
||||
{% endif %}
|
||||
{% if authorized_key.envs is defined %}
|
||||
{% set ns = namespace(envs=[]) %}
|
||||
{% for environment in authorized_key.envs %}
|
||||
{% if environment.key is defined and environment.key | length > 0 and
|
||||
environment.value is defined and environment.value | length > 0
|
||||
%}
|
||||
{% set ns.envs = ns.envs + [ environment.key + "=" + environment.value ] %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if ns.envs | length > 0 %}
|
||||
{% set _args = _args + [ "environment=\"" + (ns.envs | join(',')) + "\"" ] %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if _args | length > 0 %}
|
||||
{{ _args | join(',') }} {{ lookup('file', 'ssh/authorized_keys/' + authorized_key.filename ) }}
|
||||
{% else %}
|
||||
{{ lookup('file', 'ssh/authorized_keys/' + authorized_key.filename ) }}
|
||||
{% endif %}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{% if shell_rc_file.functions is defined %}
|
||||
{% for function in shell_rc_file.functions %}
|
||||
function {{ function.name }} {
|
||||
{{ function.body | indent(2, True) }}
|
||||
{{ function.value | indent(2, True) }}
|
||||
}
|
||||
|
||||
{% endfor %}
|
||||
|
||||
Reference in New Issue
Block a user