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
132
ansible/README.md
Normal file
132
ansible/README.md
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
# Homelab Ansible — Ubuntu 24.04 (SER5 PRO)
|
||||
|
||||
Bootstraps the headless mini PC for **arr-stack + Ternary-Bonsai (Vulkan)**.
|
||||
|
||||
| Item | Value |
|
||||
|------|--------|
|
||||
| Host | `192.168.8.123:22` |
|
||||
| User | `tim` |
|
||||
| SSH key | `~/.ssh/id_ed25519` |
|
||||
| Stack | `/opt/stack` |
|
||||
| Media | `/storage` |
|
||||
|
||||
## What it installs
|
||||
|
||||
1. **common** — apt upgrade, essentials (`curl` `wget` `nano` `build-essential` `net-tools`), [Microsoft Edit](https://github.com/microsoft/edit), tools, unattended-upgrades, fail2ban
|
||||
2. **docker** — Docker CE + Compose plugin, enabled on boot, user in `docker`
|
||||
3. **amd_vulkan** — Mesa Vulkan, VA-API, firmware, `video`/`render` groups, udev rules
|
||||
4. **firewall** — UFW: deny inbound, allow stack ports **from LAN only** (`192.168.8.0/24`)
|
||||
5. **stack** — `/opt/stack` + `/storage`, rsync project, `.env`, Vulkan compose override, **`arr-stack.service`** so compose comes up after reboot (`restart: unless-stopped` on every service)
|
||||
|
||||
## Prerequisites (control machine)
|
||||
|
||||
```bash
|
||||
# Debian/Ubuntu control node
|
||||
sudo apt install -y ansible git rsync
|
||||
|
||||
cd /path/to/arr-stack/ansible
|
||||
ansible-galaxy collection install -r requirements.yml
|
||||
```
|
||||
|
||||
SSH must already work:
|
||||
|
||||
```bash
|
||||
ssh -i ~/.ssh/id_ed25519 tim@192.168.8.123
|
||||
```
|
||||
|
||||
On the **server**, either enable passwordless sudo (recommended for automation):
|
||||
|
||||
```bash
|
||||
echo 'tim ALL=(ALL) NOPASSWD:ALL' | sudo tee /etc/sudoers.d/tim
|
||||
sudo chmod 440 /etc/sudoers.d/tim
|
||||
```
|
||||
|
||||
…or pass the sudo password each run with `-K` / `--ask-become-pass`.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
cd ansible
|
||||
|
||||
# Full bootstrap (prompt for sudo if not NOPASSWD)
|
||||
ansible-playbook site.yml -K
|
||||
|
||||
# Or limit / tags
|
||||
ansible-playbook site.yml -K --tags docker,amd,firewall
|
||||
ansible-playbook site.yml -K --tags stack,deploy
|
||||
ansible-playbook site.yml -K --check # dry-run (partial)
|
||||
```
|
||||
|
||||
First connection may prompt for host key (`accept-new` is set in `ansible.cfg`).
|
||||
|
||||
## After the playbook
|
||||
|
||||
On the **server** (or via SSH):
|
||||
|
||||
```bash
|
||||
ssh tim@192.168.8.123
|
||||
# new shell so docker/video/render groups apply
|
||||
cd /opt/stack
|
||||
|
||||
# WireGuard (if not copied): edit wireguard/wg0.conf
|
||||
# Model (~7.2G):
|
||||
./scripts/download-bonsai-model.sh
|
||||
|
||||
# Vulkan image + stack
|
||||
docker compose build bonsai
|
||||
docker compose up -d
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
Verify GPU / tools inside the host:
|
||||
|
||||
```bash
|
||||
vulkaninfo --summary
|
||||
ls -l /dev/dri
|
||||
groups # should include docker, video, render
|
||||
edit --version # Microsoft terminal editor
|
||||
curl --version && wget --version && nano --version
|
||||
```
|
||||
|
||||
Survive reboots:
|
||||
|
||||
```bash
|
||||
systemctl is-enabled docker arr-stack
|
||||
systemctl status arr-stack
|
||||
# containers use restart: unless-stopped; arr-stack.service runs compose up -d on boot
|
||||
```
|
||||
|
||||
## Layout on the server
|
||||
|
||||
```
|
||||
/opt/stack/ # compose, config, models, workspace, wireguard
|
||||
docker-compose.yml
|
||||
docker-compose.override.yml # /dev/dri for bonsai + jellyfin
|
||||
.env
|
||||
config/
|
||||
models/bonsai/
|
||||
workspace/
|
||||
wireguard/wg0.conf
|
||||
|
||||
/storage/ # 1TB media disk
|
||||
media/{movies,tv,music}
|
||||
torrents/{movies,tv,music}
|
||||
```
|
||||
|
||||
`.env` points `DATA_DIR=/storage` and `CONFIG_DIR=/opt/stack/config`.
|
||||
|
||||
## Variables
|
||||
|
||||
Edit `group_vars/all.yml` (or host_vars) for:
|
||||
|
||||
- `lan_subnet`, `timezone`
|
||||
- `bonsai_backend` / `bonsai_ngl` (default `vulkan` / `99`)
|
||||
- `ufw_allow_ssh_from_anywhere` (default `false` — SSH only from LAN)
|
||||
- `deploy_stack_files` — set `false` to skip rsync
|
||||
|
||||
## Security notes
|
||||
|
||||
- Do not commit `wireguard/wg0.conf` or production `.env`.
|
||||
- UFW allows service ports only from `lan_subnet`.
|
||||
- Fail2ban protects SSH.
|
||||
- Re-login after first run so group membership (`docker`, `render`) is active.
|
||||
25
ansible/ansible.cfg
Normal file
25
ansible/ansible.cfg
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
[defaults]
|
||||
inventory = inventory/hosts.ini
|
||||
roles_path = roles
|
||||
remote_user = tim
|
||||
private_key_file = ~/.ssh/id_ed25519
|
||||
host_key_checking = True
|
||||
retry_files_enabled = False
|
||||
# community.general.yaml was removed; use the built-in default callback's YAML format
|
||||
stdout_callback = ansible.builtin.default
|
||||
# (ansible-core 2.13+; ignored on older cores that don't know this key)
|
||||
callback_result_format = yaml
|
||||
interpreter_python = auto_silent
|
||||
deprecation_warnings = False
|
||||
# Faster on a single host
|
||||
forks = 5
|
||||
timeout = 30
|
||||
|
||||
[privilege_escalation]
|
||||
# become is set per-play in site.yml (not global) so ad-hoc ping works without sudo
|
||||
become_method = sudo
|
||||
become_user = root
|
||||
|
||||
[ssh_connection]
|
||||
pipelining = True
|
||||
ssh_args = -o ControlMaster=auto -o ControlPersist=60s -o StrictHostKeyChecking=accept-new
|
||||
221
ansible/group_vars/all.yml
Normal file
221
ansible/group_vars/all.yml
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
---
|
||||
# ---------------------------------------------------------------------------
|
||||
# Host layout
|
||||
# ---------------------------------------------------------------------------
|
||||
stack_root: /opt/stack
|
||||
storage_root: /storage
|
||||
stack_owner: tim
|
||||
stack_group: tim
|
||||
|
||||
# Media tree lives on the 1TB disk
|
||||
data_dir: "{{ storage_root }}"
|
||||
config_dir: "{{ stack_root }}/config"
|
||||
models_dir: "{{ stack_root }}/models/bonsai"
|
||||
workspace_dir: "{{ stack_root }}/workspace"
|
||||
wireguard_dir: "{{ stack_root }}/wireguard"
|
||||
|
||||
# LAN (SER5 is 192.168.8.123)
|
||||
lan_subnet: 192.168.8.0/24
|
||||
timezone: America/Los_Angeles
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Packages
|
||||
# ---------------------------------------------------------------------------
|
||||
# Always-on essentials (explicit — also listed in common_packages)
|
||||
essential_packages:
|
||||
- curl
|
||||
- wget
|
||||
- nano
|
||||
- build-essential
|
||||
- net-tools
|
||||
|
||||
# Microsoft Edit (https://github.com/microsoft/edit) — terminal editor
|
||||
ms_edit_version: "2.0.0"
|
||||
ms_edit_install: true
|
||||
|
||||
common_packages:
|
||||
# essentials
|
||||
- curl
|
||||
- wget
|
||||
- nano
|
||||
- build-essential
|
||||
- net-tools
|
||||
# system / packaging
|
||||
- ca-certificates
|
||||
- gnupg
|
||||
- apt-transport-https
|
||||
- software-properties-common
|
||||
- git
|
||||
- python3
|
||||
- python3-pip
|
||||
- python3-venv
|
||||
- cmake
|
||||
- ninja-build
|
||||
- pkg-config
|
||||
# editors / viewers
|
||||
- vim
|
||||
- less
|
||||
# monitoring / disk
|
||||
- htop
|
||||
- btop
|
||||
- iotop
|
||||
- iftop
|
||||
- ncdu
|
||||
- duf
|
||||
- sysstat
|
||||
- smartmontools
|
||||
- lm-sensors
|
||||
- nvme-cli
|
||||
# network
|
||||
- iproute2
|
||||
- dnsutils
|
||||
- traceroute
|
||||
- mtr-tiny
|
||||
- nmap
|
||||
- tcpdump
|
||||
# utils
|
||||
- tree
|
||||
- jq
|
||||
- ripgrep
|
||||
- fd-find
|
||||
- fzf
|
||||
- tmux
|
||||
- rsync
|
||||
- unzip
|
||||
- zip
|
||||
- p7zip-full
|
||||
- lsof
|
||||
- strace
|
||||
- pciutils
|
||||
- usbutils
|
||||
- plocate
|
||||
- bat
|
||||
- aria2
|
||||
# security / updates
|
||||
- fail2ban
|
||||
- unattended-upgrades
|
||||
- update-notifier-common
|
||||
|
||||
# Start stack on boot (docker compose up -d); pairs with restart: unless-stopped
|
||||
stack_systemd_enable: true
|
||||
stack_compose_profiles: [] # e.g. ["coding"] to also start openhands
|
||||
|
||||
# AMD / Vulkan / media accel (Radeon 680M on SER5 PRO)
|
||||
# Note: Ubuntu 24.04 uses mesa-va-drivers (not the older libva-mesa-driver name).
|
||||
# Many of these live in the "universe" component — role enables it first.
|
||||
amd_vulkan_packages:
|
||||
- linux-firmware
|
||||
- mesa-vulkan-drivers
|
||||
- mesa-utils
|
||||
- libvulkan1
|
||||
- vulkan-tools
|
||||
- libgl1-mesa-dri
|
||||
- libegl1
|
||||
- libgles2
|
||||
- vainfo
|
||||
- libva2
|
||||
- libva-drm2
|
||||
- mesa-va-drivers
|
||||
- mesa-vdpau-drivers
|
||||
- clinfo
|
||||
|
||||
# Groups for operator account (tim) — local trusted host, convenience over lockdown
|
||||
extra_user_groups:
|
||||
- sudo
|
||||
- docker
|
||||
- video
|
||||
- render
|
||||
- adm
|
||||
- disk
|
||||
- plugdev
|
||||
- dialout
|
||||
- cdrom
|
||||
- users
|
||||
- systemd-journal
|
||||
# present on many Ubuntu images; harmless if already primary
|
||||
- tim
|
||||
|
||||
# Passwordless full sudo for stack_owner (local monitored network)
|
||||
stack_passwordless_sudo: true
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Docker
|
||||
# ---------------------------------------------------------------------------
|
||||
docker_packages:
|
||||
- docker-ce
|
||||
- docker-ce-cli
|
||||
- containerd.io
|
||||
- docker-buildx-plugin
|
||||
- docker-compose-plugin
|
||||
|
||||
docker_daemon_json:
|
||||
log-driver: json-file
|
||||
log-opts:
|
||||
max-size: "50m"
|
||||
max-file: "5"
|
||||
# Leave room for media + inference
|
||||
default-address-pools:
|
||||
- base: 172.30.0.0/16
|
||||
size: 24
|
||||
# Enable cgroup device access patterns used by /dev/dri
|
||||
features:
|
||||
buildkit: true
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Firewall (UFW) — default deny in, allow out; open stack ports to LAN only
|
||||
# ---------------------------------------------------------------------------
|
||||
ufw_enabled: true
|
||||
ufw_default_incoming: deny
|
||||
ufw_default_outgoing: allow
|
||||
|
||||
# SSH from anywhere on LAN (and optionally world — keep LAN-only by default)
|
||||
ufw_allow_ssh_from_anywhere: false
|
||||
|
||||
ufw_lan_tcp_ports:
|
||||
- { port: 22, comment: "SSH" }
|
||||
- { port: 8080, comment: "qBittorrent" }
|
||||
- { port: 8096, comment: "Jellyfin" }
|
||||
- { port: 8989, comment: "Sonarr" }
|
||||
- { port: 7878, comment: "Radarr" }
|
||||
- { port: 9696, comment: "Prowlarr" }
|
||||
- { port: 6767, comment: "Bazarr" }
|
||||
- { port: 8686, comment: "Lidarr" }
|
||||
- { port: 3000, comment: "Open WebUI" }
|
||||
- { port: 8081, comment: "Bonsai llama-server" }
|
||||
- { port: 3001, comment: "OpenHands (optional)" }
|
||||
- { port: 3002, comment: "Forgejo HTTP" }
|
||||
- { port: 2222, comment: "Forgejo Git SSH" }
|
||||
|
||||
# Forgejo (self-hosted Git)
|
||||
forgejo_domain: "192.168.8.123"
|
||||
forgejo_http_port: 3002
|
||||
forgejo_ssh_port: 2222
|
||||
forgejo_root_url: "http://{{ forgejo_domain }}:{{ forgejo_http_port }}/"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stack deployment
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sync this repo (parent of ansible/) to stack_root on the server.
|
||||
# Set false if you manage /opt/stack yourself (git clone, etc.).
|
||||
deploy_stack_files: true
|
||||
|
||||
# Paths on the control machine (the machine running ansible-playbook)
|
||||
# Default: the arr-stack repo that contains this ansible/ directory
|
||||
stack_src: "{{ playbook_dir | dirname }}"
|
||||
|
||||
# AI defaults for SER5 + Vulkan
|
||||
bonsai_backend: vulkan
|
||||
bonsai_ngl: "99"
|
||||
bonsai_ctx: "16384"
|
||||
bonsai_threads: "12"
|
||||
bonsai_kv4: "0"
|
||||
open_webui_auth: "true"
|
||||
|
||||
# Media subdirs under storage_root
|
||||
media_subdirs:
|
||||
- media/movies
|
||||
- media/tv
|
||||
- media/music
|
||||
- torrents/movies
|
||||
- torrents/tv
|
||||
- torrents/music
|
||||
7
ansible/inventory/hosts.ini
Normal file
7
ansible/inventory/hosts.ini
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# Homelab mini PC (Beelink SER5 PRO / Ubuntu Server 24.04)
|
||||
[homelab]
|
||||
ser5 ansible_host=192.168.8.123 ansible_port=22 ansible_user=tim
|
||||
|
||||
[homelab:vars]
|
||||
ansible_ssh_private_key_file=~/.ssh/id_ed25519
|
||||
ansible_python_interpreter=/usr/bin/python3
|
||||
6
ansible/requirements.yml
Normal file
6
ansible/requirements.yml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
collections:
|
||||
- name: community.general
|
||||
version: ">=8.0.0"
|
||||
- name: ansible.posix
|
||||
version: ">=1.5.0"
|
||||
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]
|
||||
69
ansible/site.yml
Normal file
69
ansible/site.yml
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
---
|
||||
# Bootstrap Ubuntu Server 24.04 headless host for arr-stack + local AI (Vulkan)
|
||||
#
|
||||
# Usage (from this directory):
|
||||
# ansible-galaxy collection install -r requirements.yml
|
||||
# ansible-playbook site.yml -l ser5
|
||||
#
|
||||
# Tags: common, docker, amd, vulkan, firewall, stack, upgrade, deploy
|
||||
|
||||
- name: Prepare homelab host
|
||||
hosts: homelab
|
||||
gather_facts: true
|
||||
become: true
|
||||
|
||||
pre_tasks:
|
||||
- name: Verify target is Ubuntu
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- ansible_distribution == "Ubuntu"
|
||||
- ansible_distribution_major_version is version('24', '>=')
|
||||
fail_msg: "This playbook targets Ubuntu 24.04+. Found {{ ansible_distribution }} {{ ansible_distribution_version }}"
|
||||
tags: [always]
|
||||
|
||||
- name: Resolve stack user uid/gid
|
||||
ansible.builtin.getent:
|
||||
database: passwd
|
||||
key: "{{ stack_owner }}"
|
||||
tags: [always]
|
||||
|
||||
- name: Set PUID/PGID facts from passwd
|
||||
ansible.builtin.set_fact:
|
||||
ansible_user_uid: "{{ getent_passwd[stack_owner][1] }}"
|
||||
ansible_user_gid: "{{ getent_passwd[stack_owner][2] }}"
|
||||
tags: [always]
|
||||
|
||||
roles:
|
||||
- role: common
|
||||
tags: [common]
|
||||
- role: docker
|
||||
tags: [docker]
|
||||
- role: amd_vulkan
|
||||
tags: [amd, vulkan, gpu]
|
||||
- role: firewall
|
||||
tags: [firewall]
|
||||
- role: stack
|
||||
tags: [stack]
|
||||
# After stack paths exist — ownership, sudo, groups, GPU nodes
|
||||
- role: users
|
||||
tags: [users, permissions]
|
||||
|
||||
post_tasks:
|
||||
- name: Summary
|
||||
ansible.builtin.debug:
|
||||
msg: |
|
||||
Host ready: {{ inventory_hostname }} ({{ ansible_host }})
|
||||
Stack root: {{ stack_root }} (owned by {{ stack_owner }})
|
||||
Media root: {{ storage_root }} (owned by {{ stack_owner }})
|
||||
User: {{ stack_owner }} — passwordless sudo, docker/video/render/…
|
||||
Docker: enabled on boot
|
||||
Vulkan: Mesa packages + permissive /dev/dri udev (trusted host)
|
||||
Firewall: UFW LAN {{ lan_subnet }}
|
||||
|
||||
Next on the server (re-login once for groups):
|
||||
cd {{ stack_root }}
|
||||
docker compose build bonsai && docker compose up -d
|
||||
|
||||
Open WebUI: http://{{ ansible_host }}:3000
|
||||
Jellyfin: http://{{ ansible_host }}:8096
|
||||
tags: [always]
|
||||
Loading…
Add table
Add a link
Reference in a new issue