Migrate the users role

This commit is contained in:
Alexander Gorelyshev
2025-12-18 13:34:32 +04:00
parent cb18dcf6c0
commit 14e6bf12fd
18 changed files with 471 additions and 0 deletions

55
roles/users/README.md Normal file
View File

@@ -0,0 +1,55 @@
ansible-users
=========
Create user accounts according to a YAML manifest.
Controls:
- username and UID
- groupname and GID
- homedir creation
- GECOS field (typically full user name)
- shell
- SSH public key deployment to `.ssh/authorized_keys`
- account expiration
- memory, swap and CPU allocation limits via user slices
Check out [corvus-migratorius/ansible-disk-quotas](https://github.com/corvus-migratorius/ansible-disk-quotas) for controlling non-root filesystem quotas.
Requirements
------------
- systemd
- openssh
Role Variables
--------------
`manifest_path`: a YAML file containing user definitions (see `molecule/default/users.yml` for an example)
`users`: a list of objects mirroring the YAML structure expected by `manifest_path`; takes precedence over it
`common_memory_max`: e.g. `"500M"`, optional
`common_swap_max`: e.g. `"2G"`, optional
`common_cpu_quota`: e.g. `"100%"`, optional
Dependencies
------------
Example Playbook
----------------
- Configuring users in a YAML manifest: [converge.yml](molecule/default/converge.yml)
- Configuring user list as a variable: [converge.yml](molecule/users_from_var/converge.yml)
License
-------
BSD
Author Information
------------------
corvus-migratorius@proton.me
masayganova@gmail.com

View File

@@ -0,0 +1,2 @@
---
default_home_root: /home

View File

@@ -0,0 +1 @@
---

16
roles/users/meta/main.yml Normal file
View File

@@ -0,0 +1,16 @@
galaxy_info:
role_name: users
namespace: genlab
author: Alexander Gorelyshev
company: Genlab, LLC
description: Deploy user accounts
license: GPL-2.0-or-later
min_ansible_version: "2.1"
platforms:
- name: Ubuntu
versions: [ "focal", "jammy", "noble" ]
galaxy_tags: []
dependencies: []

View File

@@ -0,0 +1,10 @@
---
- name: Converge
hosts: all
roles:
- role: genlab.users
manifest_path: "users.yml"
common_memory_max: "500M"
common_swap_max: "2G"
common_cpu_quota: "100%"

View File

@@ -0,0 +1,27 @@
---
dependency:
name: galaxy
driver:
name: docker
platforms:
- name: ubuntu
image: geerlingguy/docker-${MOLECULE_DISTRO:-ubuntu2404}-ansible:latest
pre_build_image: true
command: ${MOLECULE_DOCKER_COMMAND:-""}
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw
cgroupns_mode: host
privileged: true
provisioner:
name: ansible
verifier:
name: ansible
lint: |
set -e
yamllint .
ansible-lint .

View File

@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMh9Y+wR4LH8lWJjJXqHn76kSoTRujkab+PYwD3IReFh user0@hostname

View File

@@ -0,0 +1,22 @@
---
users:
- name: "user0"
uid: 1001
gid: 1004
full_name: "User Zero"
state: "present"
groups: [sudo]
create_home: true
home_root: "/tmp/someplace"
shell: "/bin/bash"
pubkeys_file: "user0.keys"
- name: "user1"
uid: 1002
full_name: "User One"
state: "present"
groups: []
create_home: true
shell: "/bin/sh"
expires: "2024-07-23 12:00:00"
generate_ssh_key: true

View File

@@ -0,0 +1,64 @@
---
- name: Verify
hosts: all
gather_facts: true
any_errors_fatal: true
tasks:
- name: "Get user info from /etc/passwd"
register: etc_passwd
changed_when: false
ansible.builtin.shell:
cmd: set -o pipefail; cat /etc/passwd | grep user
executable: /bin/bash
- name: "Get user info from /etc/shadow"
register: etc_shadow
changed_when: false
ansible.builtin.shell:
cmd: set -o pipefail; cat /etc/shadow | grep user
executable: /bin/bash
# using a hack since here the date of last password change == date of account creation
- name: "Verify expected account configuration"
vars:
expected_shadow:
- "user0:!:{{ (ansible_date_time.epoch | int) // 86400 }}:0:99999:7:::"
- "user1:!:{{ (ansible_date_time.epoch | int) // 86400 }}:0:99999:7::19927:"
expected_passwd:
- "user0:x:1001:1004:User Zero:/tmp/someplace/user0:/bin/bash"
- "user1:x:1002:1002:User One:/home/user1:/bin/sh"
ansible.builtin.assert:
that:
- etc_passwd.stdout_lines == expected_passwd
- etc_shadow.stdout_lines == expected_shadow
- name: "Get SSH key contents for user1"
register: user1_ssh_key
changed_when: false
ansible.builtin.command:
cmd: cat /home/user1/.ssh/id_ed25519.pub
- name: "Verify the SSH key contents for user1"
ansible.builtin.assert:
that: "'user1@ubuntu' in user1_ssh_key.stdout"
- name: "Get the user slice drop-in for user0"
register: user0_slice_dropin
changed_when: false
ansible.builtin.command:
cmd: cat /etc/systemd/system/user-1001.slice.d/50-limits.conf
- name: "Verify drop-in contens"
vars:
expected:
- "[Slice]"
- "MemoryAccounting=1"
- "MemoryMax=500M"
- "MemorySwapMax=2G"
- "CPUAccounting=1"
- "CPUQuota=100%"
ansible.builtin.assert:
that: "user0_slice_dropin.stdout_lines == expected"
success_msg: "Got the expected limits slice drop-in"
fail_msg: "Unexpected limits slice drop-in contents"

View File

@@ -0,0 +1,31 @@
---
- name: Converge
hosts: all
roles:
- role: genlab.users
manifest_path: "some-none-existent-file" # the 'users' variable is supposed to take precedence
common_memory_max: "500M"
common_swap_max: "2G"
common_cpu_quota: "100%"
users:
- name: "user0"
uid: 1001
gid: 1004
full_name: "User Zero"
state: "present"
groups: [sudo]
create_home: true
home_root: "/tmp/someplace"
shell: "/bin/bash"
pubkeys_file: "user0.keys"
- name: "user1"
uid: 1002
full_name: "User One"
state: "present"
groups: []
create_home: true
shell: "/bin/sh"
expires: "2024-07-23 12:00:00"
generate_ssh_key: true

View File

@@ -0,0 +1,27 @@
---
dependency:
name: galaxy
driver:
name: docker
platforms:
- name: ubuntu
image: geerlingguy/docker-${MOLECULE_DISTRO:-ubuntu2404}-ansible:latest
pre_build_image: true
command: ${MOLECULE_DOCKER_COMMAND:-""}
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw
cgroupns_mode: host
privileged: true
provisioner:
name: ansible
verifier:
name: ansible
lint: |
set -e
yamllint .
ansible-lint .

View File

@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMh9Y+wR4LH8lWJjJXqHn76kSoTRujkab+PYwD3IReFh user0@hostname

View File

@@ -0,0 +1,64 @@
---
- name: Verify
hosts: all
gather_facts: true
any_errors_fatal: true
tasks:
- name: "Get user info from /etc/passwd"
register: etc_passwd
changed_when: false
ansible.builtin.shell:
cmd: set -o pipefail; cat /etc/passwd | grep user
executable: /bin/bash
- name: "Get user info from /etc/shadow"
register: etc_shadow
changed_when: false
ansible.builtin.shell:
cmd: set -o pipefail; cat /etc/shadow | grep user
executable: /bin/bash
# using a hack since here the date of last password change == date of account creation
- name: "Verify expected account configuration"
vars:
expected_shadow:
- "user0:!:{{ (ansible_date_time.epoch | int) // 86400 }}:0:99999:7:::"
- "user1:!:{{ (ansible_date_time.epoch | int) // 86400 }}:0:99999:7::19927:"
expected_passwd:
- "user0:x:1001:1004:User Zero:/tmp/someplace/user0:/bin/bash"
- "user1:x:1002:1002:User One:/home/user1:/bin/sh"
ansible.builtin.assert:
that:
- etc_passwd.stdout_lines == expected_passwd
- etc_shadow.stdout_lines == expected_shadow
- name: "Get SSH key contents for user1"
register: user1_ssh_key
changed_when: false
ansible.builtin.command:
cmd: cat /home/user1/.ssh/id_ed25519.pub
- name: "Verify the SSH key contents for user1"
ansible.builtin.assert:
that: "'user1@ubuntu' in user1_ssh_key.stdout"
- name: "Get the user slice drop-in for user0"
register: user0_slice_dropin
changed_when: false
ansible.builtin.command:
cmd: cat /etc/systemd/system/user-1001.slice.d/50-limits.conf
- name: "Verify drop-in contens"
vars:
expected:
- "[Slice]"
- "MemoryAccounting=1"
- "MemoryMax=500M"
- "MemorySwapMax=2G"
- "CPUAccounting=1"
- "CPUQuota=100%"
ansible.builtin.assert:
that: "user0_slice_dropin.stdout_lines == expected"
success_msg: "Got the expected limits slice drop-in"
fail_msg: "Unexpected limits slice drop-in contents"

View File

@@ -0,0 +1,106 @@
---
- name: "Create | Get current user's group entity info ({{ user.name }})"
failed_when: false
ansible.builtin.getent:
database: group
key: "{{ user.name }}"
split: ":"
- name: "Create | Create user group ('{{ user.name }}')" # to handle cases where GID!=UID
when:
- getent_group[user.name] is not defined
ansible.builtin.group:
name: "{{ user.name }}"
gid: "{{ user.gid is defined | ternary(user.gid, user.uid) }}"
state: "{{ user.state }}"
- name: "Create | Create user account ('{{ user.name }}')"
vars:
home_root: "{{ user.home_root is defined | ternary(user.home_root, default_home_root) }}"
ansible.builtin.user:
name: "{{ user.name }}"
state: "{{ user.state }}"
uid: "{{ user.uid }}"
group: "{{ user.name }}"
create_home: "{{ user.create_home | default(true) }}"
home: "{{ home_root }}/{{ user.name }}"
comment: "{{ user.full_name }}"
shell: "{{ user.shell | default('/bin/bash') }}"
groups: "{{ user.groups | default([]) }}"
append: false
generate_ssh_key: "{{ user.generate_ssh_key | default(false) }}"
ssh_key_type: "{{ user.ssh_key_type | default('ed25519') }}"
ssh_key_comment: "{{ user.name }}@{{ ansible_nodename }}"
# password_lock: "{{ user.password_lock | default('false') }}"
- name: "Create | Set user account expiration date where defined ('{{ user.name }}')"
when: user.expires is defined
ansible.builtin.user:
name: "{{ user.name }}"
state: "{{ user.state }}"
expires: "{{ (user.expires | to_datetime).strftime('%s') }}"
- name: "Create | Ensure no user account expiration date where undefined ('{{ user.name }}')"
when: user.expires is undefined
ansible.builtin.user:
name: "{{ user.name }}"
state: "{{ user.state }}"
expires: "-1"
- name: "Create | Deploy SSH public key to 'authorized_keys' files ('{{ user.name }}')"
when: (user.pubkeys_file is defined) and (user.pubkeys_file != "")
ansible.posix.authorized_key:
user: "{{ user.name }}"
key: "{{ lookup('file', user.pubkeys_file) }}"
key_options: "{{ user.options | default('') }}"
exclusive: true
- name: "Create | Create a systemd slice directory ('{{ user.name }}')"
ansible.builtin.file:
path: /etc/systemd/system/user-{{ user.uid }}.slice.d
state: directory
owner: root
group: root
mode: '0750'
# Set user's maximum memory limit to 'memory_limit' defined in the user manifest_path.
# If 'memory_limit' is undefined there, use 'common_memory_max' as fallback.
# If 'common_memory_max' is also undefined, set to "", which should be ignored by the template.
# - 'memory_max:' may use postfix like K, M, G.
# - 'cpu_quota': "100%" for 1 full core.
# REF https://www.freedesktop.org/software/systemd/man/latest/systemd.resource-control.html
# REF https://serverfault.com/a/1092803
- name: "Create | Create/update a systemd slice limits config ('{{ user.name }}')"
when: user.uid is defined
register: users_limit_state
vars:
memory_max: "{{ (user.memory_max is defined and user.memory_max != '') | ternary(user.memory_max, common_memory_max) | default('') }}"
swap_max: "{{ (user.swap_max is defined and user.swap_max != '') | ternary(user.swap_max, common_swap_max) | default('') }}"
cpu_quota: "{{ (user.cpu_quota is defined and user.cpu_quota != '') | ternary(user.cpu_quota, common_cpu_quota) | default('') }}"
ansible.builtin.template:
src: slice.j2
dest: "/etc/systemd/system/user-{{ user.uid }}.slice.d/50-limits.conf"
owner: root
group: root
mode: '0750'
- name: "Create | Enable user systemd service ('{{ user.name }}')" # noqa: no-handler
when: users_limit_state.changed
ansible.builtin.systemd_service:
name: user@{{ user.uid }}.service
enabled: true
# User service cannot be restarted if:
# - the account is expired (we check for that)
# - password change was enforced
# Sometimes restarting fails with obscure 'status=219/CGROUP', but works fine after a retry
# Didn't debug this yet, sorry ;(
- name: "Restart user service (systemd limits) ('{{ user.name }}')"
when:
- users_limit_state.changed
- user.expires is undefined or (user.expires | to_datetime).strftime('%s') > now(fmt='%s')
retries: 3
ansible.builtin.systemd_service:
name: user@{{ user.uid }}.service
state: restarted
daemon_reload: true

View File

@@ -0,0 +1,14 @@
---
- name: "Include OpenSSH client installation tasks"
ansible.builtin.include_tasks: openssh.yml
- name: "Include user definition vars"
when: (manifest_path is defined) and (manifest_path != "") and users is not defined
ansible.builtin.include_vars:
file: "{{ manifest_path }}"
- name: "Create user accounts"
loop: "{{ users }}"
loop_control:
loop_var: user
ansible.builtin.include_tasks: create.yml

View File

@@ -0,0 +1,14 @@
---
- name: "OpenSSH | Ensure openssh client tools are installed"
when: ansible_os_family == "Debian"
ansible.builtin.apt:
name: openssh-client
state: present
cache_valid_time: 3000
- name: "OpenSSH | Ensure openssh client tools are installed"
when: ansible_os_family == "RedHat"
ansible.builtin.dnf:
name: openssh-clients
state: present
update_cache: true

View File

@@ -0,0 +1,12 @@
[Slice]
{% if memory_max is defined and memory_max != "" %}
MemoryAccounting=1
MemoryMax={{ memory_max }}
MemorySwapMax={{ swap_max }}
{% else %}
{% endif %}
{% if cpu_quota is defined and cpu_quota != "" %}
CPUAccounting=1
CPUQuota={{ cpu_quota }}
{% else %}
{% endif %}

View File

@@ -0,0 +1,4 @@
---
# vars file for user
inv_groupname: "{{ hostvars[inventory_hostname].group_names[0] }}"
inv_hostname: "{{ inventory_hostname }}"