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:
commit
3645d1314c
33 changed files with 2673 additions and 0 deletions
6
ansible/roles/amd_vulkan/handlers/main.yml
Normal file
6
ansible/roles/amd_vulkan/handlers/main.yml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
- name: Reload udev
|
||||
ansible.builtin.shell: |
|
||||
udevadm control --reload-rules
|
||||
udevadm trigger
|
||||
changed_when: true
|
||||
102
ansible/roles/amd_vulkan/tasks/main.yml
Normal file
102
ansible/roles/amd_vulkan/tasks/main.yml
Normal 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]
|
||||
5
ansible/roles/common/handlers/main.yml
Normal file
5
ansible/roles/common/handlers/main.yml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
- name: Restart fail2ban
|
||||
ansible.builtin.service:
|
||||
name: fail2ban
|
||||
state: restarted
|
||||
179
ansible/roles/common/tasks/main.yml
Normal file
179
ansible/roles/common/tasks/main.yml
Normal 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]
|
||||
5
ansible/roles/docker/handlers/main.yml
Normal file
5
ansible/roles/docker/handlers/main.yml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
- name: Restart docker
|
||||
ansible.builtin.service:
|
||||
name: docker
|
||||
state: restarted
|
||||
91
ansible/roles/docker/tasks/main.yml
Normal file
91
ansible/roles/docker/tasks/main.yml
Normal 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]
|
||||
71
ansible/roles/firewall/tasks/main.yml
Normal file
71
ansible/roles/firewall/tasks/main.yml
Normal 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]
|
||||
4
ansible/roles/stack/handlers/main.yml
Normal file
4
ansible/roles/stack/handlers/main.yml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
---
|
||||
- name: Reload systemd
|
||||
ansible.builtin.systemd:
|
||||
daemon_reload: true
|
||||
207
ansible/roles/stack/tasks/main.yml
Normal file
207
ansible/roles/stack/tasks/main.yml
Normal 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]
|
||||
23
ansible/roles/stack/templates/arr-stack.service.j2
Normal file
23
ansible/roles/stack/templates/arr-stack.service.j2
Normal 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
|
||||
29
ansible/roles/stack/templates/docker-compose.override.yml.j2
Normal file
29
ansible/roles/stack/templates/docker-compose.override.yml.j2
Normal 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 %}
|
||||
41
ansible/roles/stack/templates/env.j2
Normal file
41
ansible/roles/stack/templates/env.j2
Normal 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
|
||||
6
ansible/roles/users/handlers/main.yml
Normal file
6
ansible/roles/users/handlers/main.yml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
- name: Reload udev
|
||||
ansible.builtin.shell: |
|
||||
udevadm control --reload-rules
|
||||
udevadm trigger
|
||||
changed_when: true
|
||||
171
ansible/roles/users/tasks/main.yml
Normal file
171
ansible/roles/users/tasks/main.yml
Normal 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]
|
||||
Loading…
Add table
Add a link
Reference in a new issue