Initial commit: arr-stack homelab + local AI

Docker Compose media stack (gluetun, *arr, Jellyfin), Ternary-Bonsai AI
(Open WebUI, Forgejo, optional OpenHands), and Ansible bootstrap for the
SER5 Ubuntu host.
This commit is contained in:
tim 2026-07-22 06:28:49 -07:00
commit 3645d1314c
33 changed files with 2673 additions and 0 deletions

View file

@ -0,0 +1,6 @@
---
- name: Reload udev
ansible.builtin.shell: |
udevadm control --reload-rules
udevadm trigger
changed_when: true

View file

@ -0,0 +1,102 @@
---
# Radeon 680M (RDNA2 iGPU) on SER5 PRO — Mesa Vulkan for llama.cpp / Jellyfin VAAPI
- name: Install software-properties-common (for add-apt-repository)
ansible.builtin.apt:
name: software-properties-common
state: present
tags: [amd, vulkan, gpu]
- name: Enable Ubuntu universe and multiverse components
ansible.builtin.command: add-apt-repository -y {{ item }}
loop:
- universe
- multiverse
register: apt_components
# With loop, changed_when runs per item — use item stdout, not .results
changed_when: "'Adding component' in (apt_components.stdout | default(''))"
failed_when: apt_components.rc is defined and apt_components.rc != 0
tags: [amd, vulkan, gpu]
- name: Update apt cache after enabling universe/multiverse
ansible.builtin.apt:
update_cache: true
tags: [amd, vulkan, gpu]
- name: Install AMD / Mesa / Vulkan packages
ansible.builtin.apt:
name: "{{ amd_vulkan_packages }}"
state: present
update_cache: false
tags: [amd, vulkan, gpu]
- name: Ensure video and render groups exist
ansible.builtin.group:
name: "{{ item }}"
state: present
loop:
- video
- render
tags: [amd, vulkan, gpu]
- name: Add stack user to video and render (GPU device access)
ansible.builtin.user:
name: "{{ stack_owner }}"
groups:
- video
- render
append: true
tags: [amd, vulkan, gpu]
- name: Check for DRM render nodes
ansible.builtin.find:
paths: /dev/dri
patterns: "renderD*,card*"
file_type: any
register: dri_nodes
tags: [amd, vulkan, gpu]
failed_when: false
- name: Report /dev/dri devices
ansible.builtin.debug:
msg: >-
DRM nodes:
{{ dri_nodes.files | map(attribute='path') | list | default(['(none found — check kernel / firmware)'], true) }}
tags: [amd, vulkan, gpu]
- name: Probe Vulkan (as stack user when possible)
become: true
become_user: "{{ stack_owner }}"
ansible.builtin.command: vulkaninfo --summary
register: vulkan_summary
changed_when: false
failed_when: false
tags: [amd, vulkan, gpu]
- name: Show Vulkan summary (first lines)
ansible.builtin.debug:
msg: "{{ (vulkan_summary.stdout_lines | default(['vulkaninfo failed or not usable yet']))[:40] }}"
tags: [amd, vulkan, gpu]
- name: Probe VA-API
ansible.builtin.command: vainfo
register: vainfo_out
changed_when: false
failed_when: false
tags: [amd, vulkan, gpu]
- name: Show vainfo snippet
ansible.builtin.debug:
msg: "{{ (vainfo_out.stdout_lines | default(vainfo_out.stderr_lines | default(['vainfo unavailable'])))[:25] }}"
tags: [amd, vulkan, gpu]
- name: udev rule — ensure render nodes are accessible by render group
ansible.builtin.copy:
dest: /etc/udev/rules.d/99-amd-render.rules
mode: "0644"
content: |
# Homelab: AMD GPU / APU render nodes for Docker + Vulkan
KERNEL=="renderD*", GROUP="render", MODE="0660"
KERNEL=="card*", GROUP="video", MODE="0660"
notify: Reload udev
tags: [amd, vulkan, gpu]

View file

@ -0,0 +1,5 @@
---
- name: Restart fail2ban
ansible.builtin.service:
name: fail2ban
state: restarted

View file

@ -0,0 +1,179 @@
---
- name: Set system timezone
community.general.timezone:
name: "{{ timezone }}"
tags: [common, timezone]
- name: Wait for any existing unattended-upgrade / dpkg lock
ansible.builtin.shell: |
while fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1 \
|| fuser /var/lib/apt/lists/lock >/dev/null 2>&1; do
sleep 5
done
changed_when: false
tags: [common, apt]
- name: Update apt cache
ansible.builtin.apt:
update_cache: true
cache_valid_time: 3600
tags: [common, apt]
- name: Upgrade all packages
ansible.builtin.apt:
upgrade: dist
autoremove: true
autoclean: true
tags: [common, apt, upgrade]
- name: Install essential packages (curl wget nano build-essential net-tools)
ansible.builtin.apt:
name: "{{ essential_packages }}"
state: present
tags: [common, packages, essentials]
- name: Install common packages
ansible.builtin.apt:
name: "{{ common_packages }}"
state: present
tags: [common, packages]
# ---------------------------------------------------------------------------
# Microsoft Edit — terminal-based editor (https://github.com/microsoft/edit)
# ---------------------------------------------------------------------------
- name: Resolve Microsoft Edit archive name for this arch
ansible.builtin.set_fact:
ms_edit_arch_triple: >-
{{
'x86_64-linux-gnu' if ansible_architecture in ['x86_64', 'amd64']
else (
'aarch64-linux-gnu' if ansible_architecture in ['aarch64', 'arm64']
else 'UNSUPPORTED'
)
}}
when: ms_edit_install | bool
tags: [common, packages, edit]
- name: Fail if Microsoft Edit has no binary for this architecture
ansible.builtin.fail:
msg: "No Microsoft Edit release for architecture {{ ansible_architecture }}"
when:
- ms_edit_install | bool
- ms_edit_arch_triple == 'UNSUPPORTED'
tags: [common, packages, edit]
- name: Check whether Microsoft Edit is already installed at the desired version
ansible.builtin.command: /usr/local/bin/edit --version
register: ms_edit_installed
changed_when: false
failed_when: false
when: ms_edit_install | bool
tags: [common, packages, edit]
- name: Download Microsoft Edit release tarball
ansible.builtin.get_url:
url: >-
https://github.com/microsoft/edit/releases/download/v{{ ms_edit_version }}/edit-{{ ms_edit_version }}-{{ ms_edit_arch_triple | trim }}.tar.gz
dest: "/tmp/edit-{{ ms_edit_version }}.tar.gz"
mode: "0644"
when:
- ms_edit_install | bool
- ms_edit_installed.rc is not defined or ms_edit_installed.rc != 0
or (ms_edit_version not in (ms_edit_installed.stdout | default('')))
tags: [common, packages, edit]
- name: Install Microsoft Edit binary to /usr/local/bin/edit
ansible.builtin.unarchive:
src: "/tmp/edit-{{ ms_edit_version }}.tar.gz"
dest: /usr/local/bin
remote_src: true
mode: "0755"
owner: root
group: root
when:
- ms_edit_install | bool
- ms_edit_installed.rc is not defined or ms_edit_installed.rc != 0
or (ms_edit_version not in (ms_edit_installed.stdout | default('')))
tags: [common, packages, edit]
- name: Verify Microsoft Edit is on PATH
ansible.builtin.command: /usr/local/bin/edit --version
register: ms_edit_verify
changed_when: false
failed_when: ms_edit_verify.rc != 0
when: ms_edit_install | bool
tags: [common, packages, edit]
- name: Show Microsoft Edit version
ansible.builtin.debug:
msg: "{{ ms_edit_verify.stdout | default(ms_edit_installed.stdout) | default('edit installed') }}"
when: ms_edit_install | bool
tags: [common, packages, edit]
- name: Enable unattended-upgrades
ansible.builtin.debconf:
name: unattended-upgrades
question: unattended-upgrades/enable_auto_updates
value: "true"
vtype: boolean
tags: [common, unattended]
- name: Configure unattended-upgrades (security only)
ansible.builtin.copy:
dest: /etc/apt/apt.conf.d/20auto-upgrades
mode: "0644"
content: |
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::AutocleanInterval "7";
tags: [common, unattended]
- name: Prefer security updates in unattended-upgrades
ansible.builtin.lineinfile:
path: /etc/apt/apt.conf.d/50unattended-upgrades
regexp: '{{ item.regexp }}'
line: '{{ item.line }}'
state: present
loop:
- regexp: '^\s*//\s*"\$\{distro_id\}:\$\{distro_codename\}-security";'
line: ' "${distro_id}:${distro_codename}-security";'
- regexp: 'Unattended-Upgrade::Remove-Unused-Dependencies'
line: 'Unattended-Upgrade::Remove-Unused-Dependencies "true";'
failed_when: false
tags: [common, unattended]
- name: Enable and start fail2ban
ansible.builtin.service:
name: fail2ban
state: started
enabled: true
tags: [common, fail2ban]
- name: Fail2ban SSH jail (sshd)
ansible.builtin.copy:
dest: /etc/fail2ban/jail.d/sshd.local
mode: "0644"
content: |
[sshd]
enabled = true
port = ssh
filter = sshd
maxretry = 5
bantime = 1h
findtime = 10m
notify: Restart fail2ban
tags: [common, fail2ban]
- name: Ensure plocate database is built
ansible.builtin.command: updatedb
changed_when: false
failed_when: false
tags: [common]
- name: Detect sensors (lm-sensors)
ansible.builtin.command: sensors-detect --auto
args:
creates: /etc/sensors3.conf
failed_when: false
changed_when: false
tags: [common]

View file

@ -0,0 +1,5 @@
---
- name: Restart docker
ansible.builtin.service:
name: docker
state: restarted

View file

@ -0,0 +1,91 @@
---
- name: Install Docker apt prerequisites
ansible.builtin.apt:
name:
- ca-certificates
- curl
- gnupg
state: present
tags: [docker]
- name: Create keyrings directory
ansible.builtin.file:
path: /etc/apt/keyrings
state: directory
mode: "0755"
tags: [docker]
- name: Add Docker official GPG key
ansible.builtin.get_url:
url: https://download.docker.com/linux/ubuntu/gpg
dest: /etc/apt/keyrings/docker.asc
mode: "0644"
force: true
tags: [docker]
- name: Add Docker apt repository (deb822)
ansible.builtin.deb822_repository:
name: docker
types: [deb]
uris: https://download.docker.com/linux/ubuntu
suites: ["{{ ansible_distribution_release }}"]
components: [stable]
architectures: "{{ [ansible_architecture | replace('x86_64', 'amd64') | replace('aarch64', 'arm64')] }}"
signed_by: /etc/apt/keyrings/docker.asc
state: present
enabled: true
tags: [docker]
- name: Install Docker Engine + Compose plugin
ansible.builtin.apt:
name: "{{ docker_packages }}"
state: present
update_cache: true
tags: [docker]
- name: Ensure docker group exists
ansible.builtin.group:
name: docker
state: present
tags: [docker]
- name: Configure Docker daemon.json
ansible.builtin.copy:
dest: /etc/docker/daemon.json
mode: "0644"
content: "{{ docker_daemon_json | to_nice_json }}\n"
notify: Restart docker
tags: [docker]
- name: Enable and start Docker (survives reboots)
ansible.builtin.systemd:
name: docker
state: started
enabled: true
tags: [docker]
- name: Enable containerd (Docker dependency)
ansible.builtin.systemd:
name: containerd
state: started
enabled: true
tags: [docker]
- name: Add stack user to docker / video / render groups
ansible.builtin.user:
name: "{{ stack_owner }}"
groups: "{{ extra_user_groups }}"
append: true
tags: [docker, users]
# Full permission setup also runs in the users role
- name: Verify docker works
ansible.builtin.command: docker version --format '{% raw %}{{.Server.Version}}{% endraw %}'
register: docker_ver
changed_when: false
tags: [docker]
- name: Show docker version
ansible.builtin.debug:
msg: "Docker server {{ docker_ver.stdout }}"
tags: [docker]

View file

@ -0,0 +1,71 @@
---
- name: Install UFW
ansible.builtin.apt:
name: ufw
state: present
tags: [firewall, ufw]
- name: Set UFW default policies
community.general.ufw:
direction: "{{ item.direction }}"
policy: "{{ item.policy }}"
loop:
- { direction: incoming, policy: "{{ ufw_default_incoming }}" }
- { direction: outgoing, policy: "{{ ufw_default_outgoing }}" }
tags: [firewall, ufw]
- name: Allow OpenSSH (rate-limited) from LAN
community.general.ufw:
rule: limit
port: "22"
proto: tcp
src: "{{ lan_subnet }}"
comment: "SSH from LAN"
tags: [firewall, ufw]
- name: Allow OpenSSH from anywhere (optional)
community.general.ufw:
rule: limit
port: "22"
proto: tcp
comment: "SSH world"
when: ufw_allow_ssh_from_anywhere | bool
tags: [firewall, ufw]
- name: Allow stack service ports from LAN
community.general.ufw:
rule: allow
port: "{{ item.port | string }}"
proto: tcp
src: "{{ lan_subnet }}"
comment: "{{ item.comment }}"
loop: "{{ ufw_lan_tcp_ports }}"
# SSH already handled above with limit; skip duplicate 22 if listed
when: item.port | int != 22
tags: [firewall, ufw]
- name: Allow Docker published ports on loopback freely (implicit)
ansible.builtin.debug:
msg: "UFW will leave local/docker internal traffic alone; LAN rules cover published WebUIs."
tags: [firewall, ufw]
# Docker + UFW: without this, published ports can bypass UFW on some setups.
# Route forwarded packets through ufw-before-forward is complex; we rely on
# binding services to LAN + ufw allow from lan_subnet. Documented in README.
- name: Enable UFW
community.general.ufw:
state: enabled
when: ufw_enabled | bool
tags: [firewall, ufw]
- name: Show UFW status
ansible.builtin.command: ufw status verbose
register: ufw_status
changed_when: false
tags: [firewall, ufw]
- name: Print UFW status
ansible.builtin.debug:
msg: "{{ ufw_status.stdout_lines }}"
tags: [firewall, ufw]

View file

@ -0,0 +1,4 @@
---
- name: Reload systemd
ansible.builtin.systemd:
daemon_reload: true

View file

@ -0,0 +1,207 @@
---
# Layout: compose + config under /opt/stack, media under /storage
- name: Ensure stack owner exists
ansible.builtin.user:
name: "{{ stack_owner }}"
state: present
shell: /bin/bash
create_home: true
tags: [stack]
- name: Create /opt/stack hierarchy
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: "{{ stack_owner }}"
group: "{{ stack_group }}"
mode: "0755"
loop:
- "{{ stack_root }}"
- "{{ config_dir }}"
- "{{ models_dir }}"
- "{{ workspace_dir }}"
- "{{ wireguard_dir }}"
- "{{ stack_root }}/ai"
- "{{ stack_root }}/scripts"
- "{{ stack_root }}/searxng"
- "{{ stack_root }}/ansible"
tags: [stack]
- name: Ensure storage root exists
ansible.builtin.file:
path: "{{ storage_root }}"
state: directory
owner: "{{ stack_owner }}"
group: "{{ stack_group }}"
mode: "0755"
tags: [stack, storage]
- name: Create media / torrent directories on /storage
ansible.builtin.file:
path: "{{ storage_root }}/{{ item }}"
state: directory
owner: "{{ stack_owner }}"
group: "{{ stack_group }}"
mode: "0755"
loop: "{{ media_subdirs }}"
tags: [stack, storage]
- name: Deploy stack files from control machine
ansible.posix.synchronize:
src: "{{ stack_src }}/"
dest: "{{ stack_root }}/"
delete: false
recursive: true
rsync_opts:
- "--exclude=.git"
- "--exclude=config/"
- "--exclude=data/"
- "--exclude=models/bonsai/*.gguf"
- "--exclude=*.partial"
- "--exclude=.env"
- "--exclude=wireguard/wg0.conf"
- "--exclude=ansible/.vault_pass"
when: deploy_stack_files | bool
become: false
tags: [stack, deploy]
- name: Fix ownership after rsync
ansible.builtin.file:
path: "{{ stack_root }}"
state: directory
owner: "{{ stack_owner }}"
group: "{{ stack_group }}"
recurse: true
when: deploy_stack_files | bool
tags: [stack, deploy]
- name: Deploy production .env for this host
ansible.builtin.template:
src: env.j2
dest: "{{ stack_root }}/.env"
owner: "{{ stack_owner }}"
group: "{{ stack_group }}"
mode: "0600"
tags: [stack, env]
- name: Deploy WireGuard config if present on control machine
ansible.builtin.copy:
src: "{{ stack_src }}/wireguard/wg0.conf"
dest: "{{ wireguard_dir }}/wg0.conf"
owner: "{{ stack_owner }}"
group: "{{ stack_group }}"
mode: "0600"
when:
- deploy_stack_files | bool
ignore_errors: true
tags: [stack, wireguard]
- name: Note missing local wg0.conf
ansible.builtin.debug:
msg: >-
Place wireguard/wg0.conf on the control machine (or copy to
{{ wireguard_dir }}/wg0.conf on the server) before starting gluetun.
tags: [stack, wireguard]
- name: Resolve host video group GID (for Docker group_add)
ansible.builtin.command: getent group video
register: getent_video
changed_when: false
failed_when: false
tags: [stack, compose, vulkan]
- name: Resolve host render group GID (for Docker group_add)
ansible.builtin.command: getent group render
register: getent_render
changed_when: false
failed_when: false
tags: [stack, compose, vulkan]
- name: Set video/render GID facts
ansible.builtin.set_fact:
video_gid: "{{ (getent_video.stdout.split(':')[2]) if getent_video.rc == 0 else '' }}"
render_gid: "{{ (getent_render.stdout.split(':')[2]) if getent_render.rc == 0 else '' }}"
tags: [stack, compose, vulkan]
- name: Write docker compose override for Vulkan / paths
ansible.builtin.template:
src: docker-compose.override.yml.j2
dest: "{{ stack_root }}/docker-compose.override.yml"
owner: "{{ stack_owner }}"
group: "{{ stack_group }}"
mode: "0644"
tags: [stack, compose, vulkan]
- name: Loginctl linger for stack user (optional user services later)
ansible.builtin.command: loginctl enable-linger {{ stack_owner }}
changed_when: false
failed_when: false
tags: [stack]
# ---------------------------------------------------------------------------
# Persist stack across reboots
# - Every service in docker-compose.yml uses restart: unless-stopped
# - Docker itself is enabled on boot (docker role)
# - This unit runs `docker compose up -d` after docker is ready
# ---------------------------------------------------------------------------
- name: "Assert all active compose services use restart: unless-stopped"
ansible.builtin.shell: |
set -euo pipefail
# Extract service-level restart policies (ignore comments)
bad=$(grep -E '^\s+restart:' "{{ stack_root }}/docker-compose.yml" \
| grep -v 'unless-stopped' \
| grep -v '^\s*#' || true)
if [ -n "$bad" ]; then
echo "Services missing restart: unless-stopped:"
echo "$bad"
exit 1
fi
count=$(grep -cE '^\s+restart:\s*unless-stopped' "{{ stack_root }}/docker-compose.yml" || true)
echo "OK: $count service(s) with restart: unless-stopped"
args:
executable: /bin/bash
register: restart_policy_check
changed_when: false
tags: [stack, restart]
- name: Show restart policy check
ansible.builtin.debug:
msg: "{{ restart_policy_check.stdout }}"
tags: [stack, restart]
- name: Install systemd unit for arr-stack compose
ansible.builtin.template:
src: arr-stack.service.j2
dest: /etc/systemd/system/arr-stack.service
mode: "0644"
when: stack_systemd_enable | bool
notify: Reload systemd
tags: [stack, restart, systemd]
- name: Enable arr-stack systemd unit (start on boot)
ansible.builtin.systemd:
name: arr-stack.service
enabled: true
daemon_reload: true
when: stack_systemd_enable | bool
tags: [stack, restart, systemd]
- name: Start arr-stack now (docker compose up -d)
ansible.builtin.systemd:
name: arr-stack.service
state: started
when: stack_systemd_enable | bool
# May fail on first run if images/model not ready — do not hard-fail bootstrap
failed_when: false
register: arr_stack_start
tags: [stack, restart, systemd]
- name: Report arr-stack service start result
ansible.builtin.debug:
msg: >-
arr-stack.service:
{{ 'started/active' if (arr_stack_start is succeeded and not arr_stack_start.failed | default(false))
else 'enabled for boot; start deferred (build images / download model first, then: sudo systemctl start arr-stack)' }}
when: stack_systemd_enable | bool
tags: [stack, restart, systemd]

View file

@ -0,0 +1,23 @@
[Unit]
Description=arr-stack Docker Compose services
Documentation=file://{{ stack_root }}/README.md
Requires=docker.service
After=docker.service network-online.target
Wants=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
User={{ stack_owner }}
Group={{ stack_group }}
WorkingDirectory={{ stack_root }}
Environment=HOME=/home/{{ stack_owner }}
# All compose services use restart: unless-stopped; this unit brings the stack
# up after boot / docker restarts.
ExecStart=/usr/bin/docker compose {% if stack_compose_profiles | length > 0 %}--profile {{ stack_compose_profiles | join(' --profile ') }} {% endif %}up -d --remove-orphans
ExecStop=/usr/bin/docker compose stop
TimeoutStartSec=0
TimeoutStopSec=120
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,29 @@
# Generated by Ansible — merges with docker-compose.yml
# group_add uses numeric host GIDs (names fail inside Ubuntu base images).
services:
{% if bonsai_backend == "vulkan" %}
bonsai:
devices:
- /dev/dri:/dev/dri
{% if video_gid is defined and video_gid | length > 0 %}
group_add:
- "{{ video_gid }}"
{% if render_gid is defined and render_gid | length > 0 %}
- "{{ render_gid }}"
{% endif %}
{% endif %}
environment:
NGL: "{{ bonsai_ngl }}"
{% endif %}
jellyfin:
devices:
- /dev/dri:/dev/dri
{% if video_gid is defined and video_gid | length > 0 %}
group_add:
- "{{ video_gid }}"
{% if render_gid is defined and render_gid | length > 0 %}
- "{{ render_gid }}"
{% endif %}
{% endif %}

View file

@ -0,0 +1,41 @@
# Generated by Ansible for {{ inventory_hostname }} — do not commit
# Host: {{ ansible_host | default(inventory_hostname) }}
PUID={{ ansible_user_uid | default(1000) }}
PGID={{ ansible_user_gid | default(1000) }}
TZ={{ timezone }}
CONFIG_DIR={{ config_dir }}
DATA_DIR={{ data_dir }}
LAN_SUBNET={{ lan_subnet }}
# --- AI (Ternary-Bonsai + Vulkan on SER5) ---
BONSAI_MODELS_DIR={{ models_dir }}
BONSAI_MODEL_PATH=/models/Ternary-Bonsai-27B-Q2_0.gguf
BONSAI_BACKEND={{ bonsai_backend }}
BONSAI_NGL={{ bonsai_ngl }}
BONSAI_CTX={{ bonsai_ctx }}
BONSAI_KV4={{ bonsai_kv4 }}
BONSAI_THREADS={{ bonsai_threads }}
BONSAI_TEMP=0.7
BONSAI_TOP_P=0.95
BONSAI_TOP_K=20
BONSAI_PORT=8081
OPEN_WEBUI_PORT=3000
OPENAI_API_KEY=sk-local-bonsai
WEBUI_AUTH={{ open_webui_auth }}
AI_WORKSPACE_DIR={{ workspace_dir }}
SEARXNG_PORT=8888
SEARXNG_SECRET={{ lookup('password', playbook_dir + '/.secrets/searxng_secret chars=ascii_letters,digits length=48') }}
OPENHANDS_PORT=3001
OPENHANDS_MODEL=openai/local
# Forgejo (self-hosted Git)
FORGEJO_HTTP_PORT={{ forgejo_http_port }}
FORGEJO_SSH_PORT={{ forgejo_ssh_port }}
FORGEJO_DOMAIN={{ forgejo_domain }}
FORGEJO_ROOT_URL={{ forgejo_root_url }}
FORGEJO_DISABLE_REGISTRATION=false

View file

@ -0,0 +1,6 @@
---
- name: Reload udev
ansible.builtin.shell: |
udevadm control --reload-rules
udevadm trigger
changed_when: true

View file

@ -0,0 +1,171 @@
---
# Local homelab operator account — broad permissions (LAN-trusted host)
- name: Ensure stack user exists with bash shell
ansible.builtin.user:
name: "{{ stack_owner }}"
state: present
shell: /bin/bash
create_home: true
groups: "{{ extra_user_groups }}"
append: true
tags: [users, permissions]
- name: Ensure useful system groups exist (create if missing)
ansible.builtin.group:
name: "{{ item }}"
state: present
loop: "{{ extra_user_groups }}"
failed_when: false
tags: [users, permissions]
- name: Add stack user to all useful groups
ansible.builtin.user:
name: "{{ stack_owner }}"
groups: "{{ extra_user_groups }}"
append: true
tags: [users, permissions]
- name: Passwordless sudo for stack user (full admin)
ansible.builtin.copy:
dest: "/etc/sudoers.d/{{ stack_owner }}"
mode: "0440"
owner: root
group: root
validate: "visudo -cf %s"
content: |
# Homelab operator — passwordless sudo (local trusted host)
{{ stack_owner }} ALL=(ALL) NOPASSWD:ALL
Defaults:{{ stack_owner }} !env_reset
Defaults:{{ stack_owner }} secure_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin"
tags: [users, permissions, sudo]
- name: Allow stack user to manage Docker and arr-stack via systemctl without password
ansible.builtin.copy:
dest: "/etc/sudoers.d/{{ stack_owner }}-services"
mode: "0440"
owner: root
group: root
validate: "visudo -cf %s"
content: |
# Redundant with NOPASSWD:ALL; kept explicit for clarity if sudo is tightened later
{{ stack_owner }} ALL=(root) NOPASSWD: /bin/systemctl * docker*, /bin/systemctl * containerd*, /bin/systemctl * arr-stack*, /usr/bin/systemctl * docker*, /usr/bin/systemctl * containerd*, /usr/bin/systemctl * arr-stack*
tags: [users, permissions, sudo]
- name: Install ACL tools
ansible.builtin.apt:
name: acl
state: present
tags: [users, permissions]
- name: Own stack and storage roots
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: "{{ stack_owner }}"
group: "{{ stack_group }}"
mode: "0775"
loop:
- "{{ stack_root }}"
- "{{ storage_root }}"
tags: [users, permissions]
- name: Recursive ownership of stack tree
ansible.builtin.file:
path: "{{ stack_root }}"
state: directory
owner: "{{ stack_owner }}"
group: "{{ stack_group }}"
recurse: true
tags: [users, permissions]
- name: Recursive ownership of storage tree
ansible.builtin.file:
path: "{{ storage_root }}"
state: directory
owner: "{{ stack_owner }}"
group: "{{ stack_group }}"
recurse: true
# Large trees: may take a moment; safe to re-run
tags: [users, permissions]
- name: Default ACLs so new files under stack stay group-writable
ansible.builtin.command: >
setfacl -R -d -m u:{{ stack_owner }}:rwx,g:{{ stack_group }}:rwx,o::r-x {{ item }}
loop:
- "{{ stack_root }}"
- "{{ storage_root }}"
changed_when: true
failed_when: false
tags: [users, permissions]
- name: Current ACLs for stack owner full access
ansible.builtin.command: >
setfacl -R -m u:{{ stack_owner }}:rwx,g:{{ stack_group }}:rwx {{ item }}
loop:
- "{{ stack_root }}"
- "{{ storage_root }}"
changed_when: true
failed_when: false
tags: [users, permissions]
- name: Discover DRM nodes for permission tweak
ansible.builtin.find:
paths: /dev/dri
patterns: "*"
file_type: any
register: dri_nodes
failed_when: false
tags: [users, permissions, gpu]
- name: Make /dev/dri nodes world-accessible (trusted local host)
ansible.builtin.shell: |
set -e
if [ -d /dev/dri ]; then
chmod a+rw /dev/dri/* 2>/dev/null || true
ls -la /dev/dri
fi
register: dri_chmod
changed_when: "'rw' in dri_chmod.stdout"
failed_when: false
tags: [users, permissions, gpu]
- name: Persist permissive DRM udev rule for trusted host
ansible.builtin.copy:
dest: /etc/udev/rules.d/99-homelab-dri-permissive.rules
mode: "0644"
content: |
# Trusted LAN homelab — allow any user/container access to GPU nodes
KERNEL=="renderD*", MODE="0666"
KERNEL=="card*", MODE="0666"
KERNEL=="controlD*", MODE="0666"
notify: Reload udev
tags: [users, permissions, gpu]
- name: Ensure docker socket is group-writable for docker group
ansible.builtin.file:
path: /var/run/docker.sock
group: docker
mode: "0660"
failed_when: false
tags: [users, permissions, docker]
- name: Verify stack user can sudo without password
become: true
become_user: "{{ stack_owner }}"
ansible.builtin.command: sudo -n true
changed_when: false
tags: [users, permissions]
- name: Show stack user identity and groups
become: true
become_user: "{{ stack_owner }}"
ansible.builtin.command: id
register: stack_user_id
changed_when: false
tags: [users, permissions]
- name: Report stack user permissions
ansible.builtin.debug:
msg: "{{ stack_user_id.stdout }}"
tags: [users, permissions]