From b3b280fbe4aaa4b000cb39b1862bae66a797afb4 Mon Sep 17 00:00:00 2001
From: Marty Oehme <contact@martyoeh.me>
Date: Fri, 28 Feb 2025 20:57:34 +0100
Subject: [PATCH] ref(playbook): Change to role-based structure

---
 books/void_base.yaml                          | 257 ------------------
 books/void_network.yaml                       |  23 --
 books/void_packages.yaml                      |  90 ------
 books/void_user.yaml                          |  28 --
 books/void_wayland.yaml                       | 174 ------------
 play.yaml                                     |  49 +++-
 .../backup}/files/snapper-snap-script         |   0
 roles/backup/tasks/main.yaml                  |   5 +
 roles/backup/tasks/snapper.yaml               |  67 +++++
 .../templates/snapper-configurations/home.j2  |   0
 .../templates/snapper-configurations/root.j2  |   0
 roles/backup/vars/main.yaml                   |   0
 roles/base/handlers/main.yaml                 |  31 +++
 roles/base/tasks/main.yaml                    | 122 +++++++++
 {books => roles/base}/templates/hostname.j2   |   0
 roles/base/vars/main.yaml                     |   6 +
 roles/bluetooth/tasks/main.yaml               |  16 ++
 roles/display_manager/tasks/main.yaml         |  29 ++
 .../templates/greetd-config.toml.j2           |   0
 roles/display_manager/vars/main.yaml          |   2 +
 roles/fonts/handlers/main.yaml                |   9 +
 roles/fonts/tasks/main.yaml                   |   8 +
 roles/gnupg/tasks/main.yaml                   |  18 ++
 roles/host/defaults/main.yml                  |   3 +
 books/host.yaml => roles/host/tasks/main.yaml |  23 +-
 roles/keyd/files/default.conf                 |  41 +++
 roles/keyd/tasks/main.yaml                    |  22 ++
 roles/network/tasks/main.yaml                 |  21 ++
 roles/network/tasks/wireless.yaml             |  13 +
 roles/network/vars/main.yaml                  |   3 +
 roles/packages/tasks/main.yaml                |   4 +
 roles/pipewire/tasks/main.yaml                |  49 ++++
 roles/pipewire/vars/main.yaml                 |   4 +
 roles/power/tasks/main.yaml                   |  20 ++
 roles/power/vars/main.yaml                    |   0
 roles/user/tasks/main.yaml                    |  27 ++
 roles/user/vars/main.yaml                     |  18 ++
 {books => roles/wayland}/files/runit.conf     |   0
 roles/wayland/tasks/main.yaml                 |  54 ++++
 39 files changed, 635 insertions(+), 601 deletions(-)
 delete mode 100644 books/void_base.yaml
 delete mode 100644 books/void_network.yaml
 delete mode 100644 books/void_packages.yaml
 delete mode 100644 books/void_user.yaml
 delete mode 100644 books/void_wayland.yaml
 rename {books => roles/backup}/files/snapper-snap-script (100%)
 create mode 100644 roles/backup/tasks/main.yaml
 create mode 100644 roles/backup/tasks/snapper.yaml
 rename {books => roles/backup}/templates/snapper-configurations/home.j2 (100%)
 rename {books => roles/backup}/templates/snapper-configurations/root.j2 (100%)
 create mode 100644 roles/backup/vars/main.yaml
 create mode 100644 roles/base/handlers/main.yaml
 create mode 100644 roles/base/tasks/main.yaml
 rename {books => roles/base}/templates/hostname.j2 (100%)
 create mode 100644 roles/base/vars/main.yaml
 create mode 100644 roles/bluetooth/tasks/main.yaml
 create mode 100644 roles/display_manager/tasks/main.yaml
 rename {books => roles/display_manager}/templates/greetd-config.toml.j2 (100%)
 create mode 100644 roles/display_manager/vars/main.yaml
 create mode 100644 roles/fonts/handlers/main.yaml
 create mode 100644 roles/fonts/tasks/main.yaml
 create mode 100644 roles/gnupg/tasks/main.yaml
 create mode 100644 roles/host/defaults/main.yml
 rename books/host.yaml => roles/host/tasks/main.yaml (67%)
 create mode 100644 roles/keyd/files/default.conf
 create mode 100644 roles/keyd/tasks/main.yaml
 create mode 100644 roles/network/tasks/main.yaml
 create mode 100644 roles/network/tasks/wireless.yaml
 create mode 100644 roles/network/vars/main.yaml
 create mode 100644 roles/packages/tasks/main.yaml
 create mode 100644 roles/pipewire/tasks/main.yaml
 create mode 100644 roles/pipewire/vars/main.yaml
 create mode 100644 roles/power/tasks/main.yaml
 create mode 100644 roles/power/vars/main.yaml
 create mode 100644 roles/user/tasks/main.yaml
 create mode 100644 roles/user/vars/main.yaml
 rename {books => roles/wayland}/files/runit.conf (100%)
 create mode 100644 roles/wayland/tasks/main.yaml

diff --git a/books/void_base.yaml b/books/void_base.yaml
deleted file mode 100644
index 88efe1b..0000000
--- a/books/void_base.yaml
+++ /dev/null
@@ -1,257 +0,0 @@
-- name: Install void base system
-  hosts: target_system
-  become: true
-  tasks:
-    # Prefer booster to dracut so make sure to never install it
-    - name: Ignore dracut
-      ansible.builtin.lineinfile:
-        path: /etc/xbps.d/ignore-dracut.conf
-        line: ignorepkg=dracut
-        state: present
-        create: true # create file if absent
-
-    - name: Update xbps and system
-      community.general.xbps:
-        name:
-          - xbps
-        state: latest
-        update_cache: true
-        upgrade: true
-
-    - name: Install Base system
-      community.general.xbps:
-        name:
-          - base-system
-        state: present
-
-    - name: Get rid of temporary container metapackage
-      community.general.xbps:
-        name:
-          - base-container-full
-        state: absent
-
-    - name: Install booster
-      community.general.xbps:
-        name: booster
-        state: present
-      notify: installed-booster
-
-  handlers:
-    - name: List kernel module dirs
-      ansible.builtin.find:
-        paths: "/usr/lib/modules"
-        file_type: directory
-      register: found_kernels
-      listen: installed-booster
-
-    - name: Find kernels
-      ansible.builtin.set_fact:
-        kernel_list: "{{ found_kernels['files'] | map(attribute='path') | map('regex_replace', '^.*/(.*)$', '\\1') | list }}"
-      listen: installed-booster
-
-    - name: Create booster initramfs
-      vars:
-        fname: /boot/booster-void
-      ansible.builtin.command:
-        argv:
-          - booster
-          - --verbose
-          - build
-          - --kernel-version={{ item }}
-          - "{{ fname }}-{{ item }}.img"
-        creates: "{{ fname }}-{{ item }}.img"
-      loop: "{{ kernel_list }}"
-      listen: installed-booster
-
-- name: Configure void base system
-  hosts: target_system
-  become: true
-  vars:
-    host_name: voider
-    timezone: Europe/Berlin
-    locales_enabled:
-      - en_US.UTF-8 UTF-8
-
-  tasks:
-    - name: Set hostname
-      ansible.builtin.template:
-        src: hostname.j2
-        dest: /etc/hostname
-
-    - name: Set timezone
-      ansible.builtin.file:
-        path: /etc/localtime
-        src: /usr/share/zoneinfo/{{ timezone }}
-        state: link
-
-    - name: Check if glibc locales exist
-      ansible.builtin.stat:
-        path: /etc/default/libc-locales
-      register: libc_locales_file
-
-    - name: Set correct glibc locales
-      ansible.builtin.lineinfile:
-        path: /etc/default/libc-locales
-        regexp: "^{{ item }}"
-        line: "{{ item }}"
-        state: present
-        create: true
-      loop: "{{ locales_enabled }}"
-      when: libc_locales_file.stat.exists
-      notify: glibc-locales-changed
-
-    - name: Set up chrony for NTP management
-      community.general.xbps:
-        name:
-          - chrony
-        state: present
-      notify: installed-chrony
-
-    - name: Activate acpid service
-      ansible.builtin.file:
-        force: "yes"
-        src: "/etc/sv/acpid"
-        dest: "/etc/runit/runsvdir/default/acpid"
-        state: link
-
-  handlers:
-    - name: Regenerate locales
-      ansible.builtin.command:
-        argv:
-          - xbps-reconfigure
-          - --force
-          - libc-locales
-      listen: glibc-locales-changed
-
-    - name: Activate chronyd service
-      ansible.builtin.file:
-        force: "yes"
-        src: "/etc/sv/{{ item }}"
-        dest: "/etc/runit/runsvdir/default/{{ item }}"
-        state: link
-      with_items: [chronyd]
-      listen: installed-chrony
-
-- name: Set up wireless networking
-  hosts: target_system
-  become: true
-  tags:
-    - wireless
-    - iwd
-  tasks:
-    - name: Install iwd
-      community.general.xbps:
-        name:
-          - iwd
-        state: present
-
-    - name: Activate wireless networking service
-      ansible.builtin.file:
-        src: "/etc/sv/iwd"
-        dest: "/etc/runit/runsvdir/default/iwd"
-        state: link
-
-- name: Set up snapper backups
-  hosts: target_system
-  become: true
-  tags:
-    - btrfs
-    - snapshots
-  tasks:
-    - name: Install snapper
-      community.general.xbps:
-        name:
-          - snapper
-        state: present
-      notify: installed-snapper
-
-    # https://wiki.archlinux.org/title/Snapper#updatedb
-    - name: Disable updatedb indexing for snapshot directories
-      ansible.builtin.copy:
-        content: 'PRUNENAMES = ".snapshots"'
-        dest: "/etc/updatedb.conf"
-        owner: root
-        group: root
-        mode: 0644
-        force: true
-
-    - name: Ensure snapper configs directory exists
-      ansible.builtin.file:
-        dest: "/etc/snapper/configs"
-        state: directory
-        recurse: true
-
-    - name: Ensure root /.snapshots directory exists
-      ansible.builtin.file:
-        dest: "/.snapshots"
-        state: directory
-        mode: 0755
-
-    - name: Create root backup configuration
-      ansible.builtin.template:
-        src: snapper-configurations/root.j2
-        dest: "/etc/snapper/configs/root"
-        mode: 0640
-        force: true # ensure contents are always exact
-
-    - name: Ensure home /.snapshots directory exists
-      ansible.builtin.file:
-        dest: "/home/.snapshots"
-        state: directory
-        mode: 0755
-
-    - name: Create homedir backup configuration
-      ansible.builtin.template:
-        src: snapper-configurations/home.j2
-        dest: "/etc/snapper/configs/home"
-        mode: 0640
-        force: true
-
-    - name: Add snap manual safety command
-      ansible.builtin.copy:
-        src: snapper-snap-script
-        dest: "/usr/bin/snap"
-        owner: root
-        group: root
-        mode: 0755
-
-  handlers:
-  #   # Do NOT activate the snapperd service -
-  #   # on systems without elogind I guess? Unsure
-  #   - name: Activate snapper service
-  #     ansible.builtin.file:
-  #       force: "yes"
-  #       src: "/etc/sv/snapperd"
-  #       dest: "/etc/runit/runsvdir/default/snapperd"
-  #       state: link
-  #     listen: installed-snapper
-    - name: Snapper handler stub
-      ansible.builtin.debug:
-        msg: ""
-      listen: installed-snapper
-
-- name: Set up snooze as cron daemon
-  hosts: target_system
-  become: true
-  tags:
-    - cron
-    - snooze
-  tasks:
-    - name: Install snooze
-      community.general.xbps:
-        name:
-          - snooze
-        state: present
-
-    - name: Activate snooze cron services
-      ansible.builtin.file:
-        force: "yes"
-        src: "/etc/sv/{{ item }}"
-        dest: "/etc/runit/runsvdir/default/{{ item }}"
-        state: link
-      loop:
-        - snooze-hourly
-        - snooze-daily
-        - snooze-weekly
-        - snooze-monthly
-
diff --git a/books/void_network.yaml b/books/void_network.yaml
deleted file mode 100644
index a99e5ed..0000000
--- a/books/void_network.yaml
+++ /dev/null
@@ -1,23 +0,0 @@
-- name: Set up voidlinux networking
-  hosts: target_system
-  become: true
-  vars:
-    nameserver1: 9.9.9.9
-    nameserver2: 9.9.9.10
-  tasks:
-    - name: Configure resolv DNS
-      ansible.builtin.copy:
-        mode: 0644
-        dest: "/etc/resolv.conf"
-        content: |
-          nameserver {{ nameserver1 }}
-          nameserver {{ nameserver2 }}
-      failed_when: false # can't ever fail
-
-    - name: Activate dhcp service
-      ansible.builtin.file:
-        force: 'yes'
-        src: "/etc/sv/{{ item }}"
-        dest: "/etc/runit/runsvdir/default/{{ item }}"
-        state: link
-      with_items: [ dhcpcd ]
diff --git a/books/void_packages.yaml b/books/void_packages.yaml
deleted file mode 100644
index c826dac..0000000
--- a/books/void_packages.yaml
+++ /dev/null
@@ -1,90 +0,0 @@
-- name: Install basic custom void packages
-  hosts: interface
-  become: true
-  tags:
-    - interface
-    - packages
-  tasks:
-    - name: Set up keyd for custom key events
-      community.general.xbps:
-        name:
-          - keyd
-        state: "{{ desired_package_state }}"
-      notify: installed-keyd
-
-    - name: Install many fonts
-      community.general.xbps:
-        name: "{{ fonts }}"
-        state: "{{ desired_package_state }}"
-      notify: installed-fonts
-      when: fonts
-
-  handlers:
-    - name: Activate keyd service
-      ansible.builtin.file:
-        force: "yes"
-        src: "/etc/sv/{{ item }}"
-        dest: "/etc/runit/runsvdir/default/{{ item }}"
-        state: link
-      with_items: [keyd]
-      listen: installed-keyd
-
-    - name: Regenerate fontconfig
-      ansible.builtin.command:
-        argv:
-          - xbps-reconfigure
-          - --force
-          - fontconfig
-      listen: installed-fonts
-
-- name: Install bluetooth
-  hosts: target_system
-  become: true
-  tags:
-    - bluetooth
-  tasks:
-    - name: Install bluetooth packages
-      community.general.xbps:
-        name:
-          - bluez
-        state: "{{ desired_package_state }}"
-      notify: installed-bluetooth
-
-  handlers:
-    - name: Activate bluetooth service
-      ansible.builtin.file:
-        force: "yes"
-        src: "/etc/sv/{{ item }}"
-        dest: "/etc/runit/runsvdir/default/{{ item }}"
-        state: link
-      with_items: [bluetoothd, dbus]
-      listen: installed-bluetooth
-
-- name: Install gnupg
-  hosts: target_system
-  become: true
-  tags:
-    - packages
-    - gpg
-  tasks:
-    - name: Install a bunch of base custom stuff
-      community.general.xbps:
-        name: [gnupg, gnupg2-scdaemon]
-        state: "{{ desired_package_state }}"
-  handlers:
-    - name: Put user in plugdev group
-      ansible.builtin.user:
-        name: "{{ user_name }}"
-        groups: [plugdev]
-        append: true
-
-- name: Install basic custom void packages
-  hosts: target_system
-  become: true
-  tags:
-    - packages
-  tasks:
-    - name: Install a bunch of base custom stuff
-      community.general.xbps:
-        name: "{{ lookup('community.general.merge_variables', '^packages_.*') }}"
-        state: "{{ desired_package_state }}"
diff --git a/books/void_user.yaml b/books/void_user.yaml
deleted file mode 100644
index 64474bf..0000000
--- a/books/void_user.yaml
+++ /dev/null
@@ -1,28 +0,0 @@
-- name: Set up primary user
-  hosts: target_system
-  become: true
-  tasks:
-    - name: Ensure user groups all exist
-      ansible.builtin.group:
-        name: "{{ item }}"
-        state: present
-      loop: "{{ user_groups }}"
-
-    - name: Enable sudo for "wheel" group
-      ansible.builtin.copy:
-        content: "%wheel ALL=(ALL) ALL"
-        dest: "/etc/sudoers.d/10-wheel"
-        owner: root
-        group: root
-        mode: 0644
-        force: true
-
-    - name: Add user
-      ansible.builtin.user:
-        name: "{{ user_name }}"
-        password: "{{ user_pass | password_hash('sha512', 'supersecretsalt') }}"
-        create_home: true
-        shell: "/bin/{{ user_shell }}"
-        group: "{{ user_name }}"
-        groups: "{{ user_groups }}"
-        generate_ssh_key: true
diff --git a/books/void_wayland.yaml b/books/void_wayland.yaml
deleted file mode 100644
index 9e6cfcf..0000000
--- a/books/void_wayland.yaml
+++ /dev/null
@@ -1,174 +0,0 @@
-- name: Install void wayland environment
-  hosts: target_system
-  become: true
-  tags: wayland
-  tasks:
-    - name: Install intel wayland drivers
-      community.general.xbps:
-        name:
-          - mesa-dri
-        state: present
-
-    - name: Install wayland packages
-      community.general.xbps:
-        name:
-          - dbus
-          - seatd
-          - turnstile
-        state: present
-      # notify: installed-wayland -> TODO: Use handler? Currently using task below
-
-    - name: Activate wayland services
-      ansible.builtin.file:
-        force: "yes"
-        src: "/etc/sv/{{ item }}"
-        dest: "/etc/runit/runsvdir/default/{{ item }}"
-        state: link
-      with_items: [dbus, turnstiled, seatd]
-
-    - name: Set user service directory to $HOME/.local/state/service
-      ansible.builtin.copy:
-        src: runit.conf
-        dest: /etc/turnstile/backend/runit.conf
-        mode: 0644
-
-    - name: Install wlr desktop portals
-      community.general.xbps:
-        name:
-          - xdg-desktop-portal
-          - xdg-desktop-portal-wlr
-        state: present
-      tags: desktop-portal
-
-    - name: Install qt5 and qt6 wayland libraries
-      community.general.xbps:
-        name:
-          - qt5-wayland
-          - qt6-wayland
-        state: present
-      tags: qt-wayland
-
-- name: Set up display manager
-  hosts: target_system
-  become: true
-  vars:
-    greeter_user: _greeter
-  tags:
-    - wayland
-    - greetd
-    - tuigreet
-  tasks:
-    - name: Ensure user group for greeter exists
-      ansible.builtin.group:
-        name: "{{ greeter_user }}"
-        state: present
-
-    - name: Install greetd and tuigreet
-      community.general.xbps:
-        name:
-          - greetd
-          - tuigreet
-        state: present
-
-    - name: Set up tuigreet config for greetd
-      ansible.builtin.template:
-        src: greetd-config.toml.j2
-        dest: "/etc/greetd/config.toml"
-        owner: root
-        group: root
-        mode: 0644
-        force: true
-
-    - name: Activate greetd service
-      ansible.builtin.file:
-        src: "/etc/sv/greetd"
-        dest: "/etc/runit/runsvdir/default/greetd"
-        state: link
-
-- name: Install audio and video for wayland
-  hosts: target_system
-  become: true
-  tags: audio
-  vars:
-    audio_groups: [audio, video]
-  tasks:
-    - name: Ensure user group for audio/video exists
-      ansible.builtin.group:
-        name: "{{ item }}"
-        state: present
-      loop: "{{ audio_groups }}"
-
-    - name: Put user in audio group
-      ansible.builtin.user:
-        name: "{{ user_name }}"
-        groups: "{{ audio_groups }}"
-        append: true
-
-    - name: Install pipewire
-      community.general.xbps:
-        name:
-          - pipewire
-        state: present
-      notify: installed-pipewire
-
-    - name: Install pipewire bluetooth
-      community.general.xbps:
-        name:
-          - libspa-bluetooth
-        state: present
-      tags: bluetooth
-
-  handlers:
-    - name: Set up wireplumber to auto start
-      ansible.builtin.file:
-        dest: "/etc/pipewire/pipewire.conf.d"
-        state: directory
-      listen: installed-pipewire
-
-      # FIXME: Does not work automatically for some reason?
-    - name: Set up wireplumber to auto start
-      ansible.builtin.file:
-        force: "yes"
-        src: "/usr/share/examples/wireplumber/10-wireplumber.conf"
-        dest: "/etc/pipewire/pipewire.conf.d/10-wireplumber.conf"
-        state: link
-      listen: installed-pipewire
-
-    - name: Enable pipewire-pulse interface
-      ansible.builtin.file:
-        force: "yes"
-        src: "/usr/share/examples/pipewire/20-pipewire-pulse.conf"
-        dest: "/etc/pipewire/pipewire.conf.d/20-pipewire-pulse.conf"
-        state: link
-      listen: installed-pipewire
-
-      ## TODO: Enable its start in river init script
-      #
-      # TODO: Find way to install and enable pipewire-roc-sink module (and enable ~/.config/pipewire/pipewire.conf.d/roc-sink.conf)
-
-
-- name: Allow user to manage system power
-  hosts: target_system
-  become: true
-  tags:
-    - power
-  tasks:
-    - name: Ensure user group "power" exists
-      ansible.builtin.group:
-        name: power
-        state: present
-
-    - name: Put user in power group
-      ansible.builtin.user:
-        name: "{{ user_name }}"
-        groups: [power]
-        append: true
-
-    - name: Enable power management for power group
-      ansible.builtin.copy:
-        content: "%power ALL=(ALL) NOPASSWD: /usr/bin/halt, /usr/bin/poweroff, /usr/bin/reboot, /usr/bin/shutdown, /usr/bin/zzz, /usr/bin/ZZZ"
-        dest: "/etc/sudoers.d/20-power"
-        owner: root
-        group: root
-        mode: 0644
-        force: true
diff --git a/play.yaml b/play.yaml
index f4ae7cd..c2dcdba 100644
--- a/play.yaml
+++ b/play.yaml
@@ -1,6 +1,9 @@
 ---
-- name: Import host tasks
-  ansible.builtin.import_playbook: books/host.yaml
+- name: Prepare host system
+  hosts: hostsystem
+  become: true
+  roles:
+    - host
 
 # Get python onto void - otherwise we can not interact through ansible
 - name: Bootstrap void python
@@ -12,17 +15,35 @@
       register: python_install
       changed_when: "'installed successfully' in python_install.stdout"
 
-- name: Import chroot void base tasks
-  ansible.builtin.import_playbook: books/void_base.yaml
+- name: Set up target system
+  hosts: target_system
+  become: true
+  roles:
+    - role: base
+      tags: base
+    - role: user
+      tags: user
+    - role: network
+      tags: network
+    - role: backup
+      tags: backup
+    - role: keyd
+      tags: keyd
 
-- name: Import chroot void network tasks
-  ansible.builtin.import_playbook: books/void_network.yaml
+    - role: wayland
+      tags: wayland
+    - role: display_manager
+      tags: display_manager
+    - role: pipewire
+      tags: pipewire
+    - role: bluetooth
+      tags: bluetooth
+    - role: power
+      tags: power
+    - role: gnupg
+      tags: gnupg
 
-- name: Import chroot void wayland tasks
-  ansible.builtin.import_playbook: books/void_wayland.yaml
-
-- name: Import chroot user management tasks
-  ansible.builtin.import_playbook: books/void_user.yaml
-
-- name: Import chroot void customizations
-  ansible.builtin.import_playbook: books/void_packages.yaml
+    - role: fonts
+      tags: fonts
+    - role: packages
+      tags: packages
diff --git a/books/files/snapper-snap-script b/roles/backup/files/snapper-snap-script
similarity index 100%
rename from books/files/snapper-snap-script
rename to roles/backup/files/snapper-snap-script
diff --git a/roles/backup/tasks/main.yaml b/roles/backup/tasks/main.yaml
new file mode 100644
index 0000000..a7039cf
--- /dev/null
+++ b/roles/backup/tasks/main.yaml
@@ -0,0 +1,5 @@
+- name: Set up snapper snapshots
+  import_tasks: snapper.yaml
+  tags:
+    - btrfs
+    - snapshots
diff --git a/roles/backup/tasks/snapper.yaml b/roles/backup/tasks/snapper.yaml
new file mode 100644
index 0000000..d551e3f
--- /dev/null
+++ b/roles/backup/tasks/snapper.yaml
@@ -0,0 +1,67 @@
+- name: Install snapper
+  community.general.xbps:
+    name:
+      - snapper
+    state: present
+
+# https://wiki.archlinux.org/title/Snapper#updatedb
+- name: Disable updatedb indexing for snapshot directories
+  ansible.builtin.copy:
+    content: 'PRUNENAMES = ".snapshots"'
+    dest: "/etc/updatedb.conf"
+    owner: root
+    group: root
+    mode: 0644
+    force: true
+
+- name: Ensure snapper configs directory exists
+  ansible.builtin.file:
+    dest: "/etc/snapper/configs"
+    state: directory
+    recurse: true
+
+- name: Ensure root /.snapshots directory exists
+  ansible.builtin.file:
+    dest: "/.snapshots"
+    state: directory
+    mode: 0755
+
+- name: Create root backup configuration
+  ansible.builtin.template:
+    src: snapper-configurations/root.j2
+    dest: "/etc/snapper/configs/root"
+    mode: 0640
+    force: true # ensure contents are always exact
+
+- name: Ensure home /.snapshots directory exists
+  ansible.builtin.file:
+    dest: "/home/.snapshots"
+    state: directory
+    mode: 0755
+
+- name: Create homedir backup configuration
+  ansible.builtin.template:
+    src: snapper-configurations/home.j2
+    dest: "/etc/snapper/configs/home"
+    mode: 0640
+    force: true
+
+- name: Add snap manual safety command
+  ansible.builtin.copy:
+    src: snapper-snap-script
+    dest: "/usr/bin/snap"
+    owner: root
+    group: root
+    mode: 0755
+
+# For now we never activate the snapper daemon
+# Does not work without elogind?
+# Using snooze (i.e. cron) enabled recurring
+# backup tasks instead.
+# - name: Activate snapper service
+#   ansible.builtin.file:
+#     force: "yes"
+#     src: "/etc/sv/snapperd"
+#     dest: "/etc/runit/runsvdir/default/snapperd"
+#     state: link
+#   tags: never
diff --git a/books/templates/snapper-configurations/home.j2 b/roles/backup/templates/snapper-configurations/home.j2
similarity index 100%
rename from books/templates/snapper-configurations/home.j2
rename to roles/backup/templates/snapper-configurations/home.j2
diff --git a/books/templates/snapper-configurations/root.j2 b/roles/backup/templates/snapper-configurations/root.j2
similarity index 100%
rename from books/templates/snapper-configurations/root.j2
rename to roles/backup/templates/snapper-configurations/root.j2
diff --git a/roles/backup/vars/main.yaml b/roles/backup/vars/main.yaml
new file mode 100644
index 0000000..e69de29
diff --git a/roles/base/handlers/main.yaml b/roles/base/handlers/main.yaml
new file mode 100644
index 0000000..514fd62
--- /dev/null
+++ b/roles/base/handlers/main.yaml
@@ -0,0 +1,31 @@
+- name: List kernel module dirs
+  ansible.builtin.find:
+    paths: "/usr/lib/modules"
+    file_type: directory
+  register: found_kernels
+  listen: installed-booster
+
+- name: Find kernels
+  ansible.builtin.set_fact:
+    kernel_list: "{{ found_kernels['files'] | map(attribute='path') | map('regex_replace', '^.*/(.*)$', '\\1') | list }}"
+  listen: installed-booster
+
+- name: Create booster initramfs
+  ansible.builtin.command:
+    argv:
+      - booster
+      - --verbose
+      - build
+      - --kernel-version={{ item }}
+      - "{{ fname }}-{{ item }}.img"
+    creates: "{{ fname }}-{{ item }}.img"
+  loop: "{{ kernel_list }}"
+  listen: installed-booster
+
+- name: Regenerate locales
+  ansible.builtin.command:
+    argv:
+      - xbps-reconfigure
+      - --force
+      - libc-locales
+  listen: glibc-locales-changed
diff --git a/roles/base/tasks/main.yaml b/roles/base/tasks/main.yaml
new file mode 100644
index 0000000..b175181
--- /dev/null
+++ b/roles/base/tasks/main.yaml
@@ -0,0 +1,122 @@
+# Prefer booster to dracut so make sure to never install it
+- name: Ignore dracut
+  ansible.builtin.lineinfile:
+    path: /etc/xbps.d/ignore-dracut.conf
+    line: ignorepkg=dracut
+    state: present
+    create: true # create file if absent
+  tags:
+    - packages
+    - dracut
+
+- name: Update xbps and system
+  community.general.xbps:
+    name:
+      - xbps
+    state: latest
+    update_cache: true
+    upgrade: true
+  tags:
+    - packages
+    - update
+
+- name: Install Base system
+  community.general.xbps:
+    name:
+      - base-system
+    state: present
+  tags:
+    - packages
+
+- name: Get rid of temporary container metapackage
+  community.general.xbps:
+    name:
+      - base-container-full
+    state: absent
+  tags:
+    - packages
+
+- name: Install booster
+  community.general.xbps:
+    name: booster
+    state: present
+  notify: installed-booster
+  tags:
+    - packages
+    - booster
+
+- name: Set hostname
+  ansible.builtin.template:
+    src: hostname.j2
+    dest: /etc/hostname
+  tags: hostname
+
+- name: Set timezone
+  ansible.builtin.file:
+    path: /etc/localtime
+    src: /usr/share/zoneinfo/{{ timezone }}
+    state: link
+  tags: timezone
+
+- name: Install glibc
+  tags: glibc
+  block:
+  - name: Check if glibc locales exist
+    ansible.builtin.stat:
+      path: /etc/default/libc-locales
+    register: libc_locales_file
+
+  - name: Set correct glibc locales
+    ansible.builtin.lineinfile:
+      path: /etc/default/libc-locales
+      regexp: "^{{ item }}"
+      line: "{{ item }}"
+      state: present
+      create: true
+    loop: "{{ locales_enabled }}"
+    when: libc_locales_file.stat.exists
+    notify: glibc-locales-changed
+
+- name: Set up chrony for NTP management
+  community.general.xbps:
+    name:
+      - chrony
+    state: present
+  tags: chrony
+
+- name: Activate chronyd service
+  ansible.builtin.file:
+    src: "/etc/sv/chronyd"
+    dest: "/etc/runit/runsvdir/default/chronyd"
+    state: link
+  tags: chrony
+
+- name: Activate acpid service
+  ansible.builtin.file:
+    src: "/etc/sv/acpid"
+    dest: "/etc/runit/runsvdir/default/acpid"
+    state: link
+  tags: acpid
+
+- name: Set up snooze as cron daemon
+  tags:
+    - cron
+    - snooze
+  block:
+    - name: Install snooze
+      community.general.xbps:
+        name:
+          - snooze
+        state: present
+
+    - name: Activate snooze cron services
+      ansible.builtin.file:
+        force: "yes"
+        src: "/etc/sv/{{ item }}"
+        dest: "/etc/runit/runsvdir/default/{{ item }}"
+        state: link
+      loop:
+        - snooze-hourly
+        - snooze-daily
+        - snooze-weekly
+        - snooze-monthly
diff --git a/books/templates/hostname.j2 b/roles/base/templates/hostname.j2
similarity index 100%
rename from books/templates/hostname.j2
rename to roles/base/templates/hostname.j2
diff --git a/roles/base/vars/main.yaml b/roles/base/vars/main.yaml
new file mode 100644
index 0000000..01f3915
--- /dev/null
+++ b/roles/base/vars/main.yaml
@@ -0,0 +1,6 @@
+---
+booster_initramfs_name: /boot/booster-void
+host_name: voider
+timezone: Europe/Berlin
+locales_enabled:
+  - en_US.UTF-8 UTF-8
diff --git a/roles/bluetooth/tasks/main.yaml b/roles/bluetooth/tasks/main.yaml
new file mode 100644
index 0000000..eb3e94e
--- /dev/null
+++ b/roles/bluetooth/tasks/main.yaml
@@ -0,0 +1,16 @@
+- name: Install bluetooth packages
+  community.general.xbps:
+    name:
+      - bluez
+    state: "{{ desired_package_state }}"
+  tags: packages
+
+- name: Activate bluetooth service
+  ansible.builtin.file:
+    force: "yes"
+    src: "/etc/sv/{{ item }}"
+    dest: "/etc/runit/runsvdir/default/{{ item }}"
+    state: link
+  with_items:
+    - bluetoothd
+    - dbus
diff --git a/roles/display_manager/tasks/main.yaml b/roles/display_manager/tasks/main.yaml
new file mode 100644
index 0000000..5ca7090
--- /dev/null
+++ b/roles/display_manager/tasks/main.yaml
@@ -0,0 +1,29 @@
+---
+- name: Ensure user group for greeter exists
+  ansible.builtin.group:
+    name: "{{ greeter_user }}"
+    state: present
+
+- name: Install greetd and tuigreet
+  community.general.xbps:
+    name:
+      - greetd
+      - tuigreet
+    state: present
+  tags:
+    - packages
+
+- name: Set up tuigreet config for greetd
+  ansible.builtin.template:
+    src: greetd-config.toml.j2
+    dest: "/etc/greetd/config.toml"
+    owner: root
+    group: root
+    mode: 0644
+    force: true
+
+- name: Activate greetd service
+  ansible.builtin.file:
+    src: "/etc/sv/greetd"
+    dest: "/etc/runit/runsvdir/default/greetd"
+    state: link
diff --git a/books/templates/greetd-config.toml.j2 b/roles/display_manager/templates/greetd-config.toml.j2
similarity index 100%
rename from books/templates/greetd-config.toml.j2
rename to roles/display_manager/templates/greetd-config.toml.j2
diff --git a/roles/display_manager/vars/main.yaml b/roles/display_manager/vars/main.yaml
new file mode 100644
index 0000000..90abdad
--- /dev/null
+++ b/roles/display_manager/vars/main.yaml
@@ -0,0 +1,2 @@
+---
+greeter_user: _greeter
diff --git a/roles/fonts/handlers/main.yaml b/roles/fonts/handlers/main.yaml
new file mode 100644
index 0000000..93a1b49
--- /dev/null
+++ b/roles/fonts/handlers/main.yaml
@@ -0,0 +1,9 @@
+---
+- name: Regenerate fontconfig
+  ansible.builtin.command:
+    argv:
+      - xbps-reconfigure
+      - --force
+      - fontconfig
+  listen: installed-fonts
+
diff --git a/roles/fonts/tasks/main.yaml b/roles/fonts/tasks/main.yaml
new file mode 100644
index 0000000..c436b5c
--- /dev/null
+++ b/roles/fonts/tasks/main.yaml
@@ -0,0 +1,8 @@
+---
+- name: Install many fonts
+  community.general.xbps:
+    name: "{{ fonts }}"
+    state: "{{ desired_package_state }}"
+  notify: installed-fonts
+  when: fonts
+
diff --git a/roles/gnupg/tasks/main.yaml b/roles/gnupg/tasks/main.yaml
new file mode 100644
index 0000000..84c172e
--- /dev/null
+++ b/roles/gnupg/tasks/main.yaml
@@ -0,0 +1,18 @@
+- name: Install gnupg and smartcard addon
+  community.general.xbps:
+    name:
+      - gnupg
+      - gnupg2-scdaemon
+    state: "{{ desired_package_state }}"
+  tags: packages
+
+- name: Ensure user group plugdev exist
+  ansible.builtin.group:
+    name: plugdev
+    state: present
+
+- name: Put user in plugdev group
+  ansible.builtin.user:
+    name: "{{ user_name }}"
+    groups: [plugdev]
+    append: true
diff --git a/roles/host/defaults/main.yml b/roles/host/defaults/main.yml
new file mode 100644
index 0000000..272d121
--- /dev/null
+++ b/roles/host/defaults/main.yml
@@ -0,0 +1,3 @@
+---
+mount_dir: /mnt/void
+tarball_url: "https://repo-default.voidlinux.org/live/current/void-x86_64-ROOTFS-20250202.tar.xz"
diff --git a/books/host.yaml b/roles/host/tasks/main.yaml
similarity index 67%
rename from books/host.yaml
rename to roles/host/tasks/main.yaml
index 6b4b92f..ac6391c 100644
--- a/books/host.yaml
+++ b/roles/host/tasks/main.yaml
@@ -26,18 +26,11 @@
  #        - { fstype: 'ext4',  src: '/dev/mapper/{{ vgname }}-root', path: '{{ mount_dir }}/' }
  #         # TODO: should take opts from fstab. Definitely needs 'boot' type option
  #        - { fstype: 'vfat',  src: '/dev/disk/by-uuid/{{ boot_dev_uuid }}', path: '{{ mount_dir }}/boot' }
- 
-- name: Create voidlinux guest
-  hosts: hostsystem
-  become: true
-  vars:
-    mount_dir: /mnt/void
-    tarball_url: "https://repo-default.voidlinux.org/live/current/void-x86_64-ROOTFS-20250202.tar.xz"
-  tasks:
-    - name: Unpack rootfs
-      ansible.builtin.unarchive:
-        remote_src: yes # we already downloaded it to the 'remote' system
-        src: "{{ tarball_url }}"
-        dest: "{{ mount_dir }}"
-        # ONLY run if this file does not exist (could use any rootfs file to check)
-        creates: "{{ mount_dir }}/etc/os-release"
+
+- name: Unpack rootfs
+  ansible.builtin.unarchive:
+    remote_src: yes # we already downloaded it to the 'remote' system
+    src: "{{ tarball_url }}"
+    dest: "{{ mount_dir }}"
+    # ONLY run if this file does not exist (could use any rootfs file to check)
+    creates: "{{ mount_dir }}/etc/os-release"
diff --git a/roles/keyd/files/default.conf b/roles/keyd/files/default.conf
new file mode 100644
index 0000000..9b88a03
--- /dev/null
+++ b/roles/keyd/files/default.conf
@@ -0,0 +1,41 @@
+# Makes capslock to control/escape
+# insert to paste
+# right alt to enable German Umlaute (äÄöÖüÜ),
+# sharp s (ß), and the Euro sign (€).
+# Needs compose key to be set in xkb to work correctly:
+# $ setxkbmap -option "compose:menu"
+
+[ids]
+
+*
+
+[main]
+
+capslock = overload(control, esc)
+insert = S-insert
+rightalt = layer(dia)
+shift = layer(shift)
+rightshift = layer(shift)
+
+[shift:S]
+
+rightalt = layer(shiftedDia)
+
+[dia]
+
+shift = layer(shiftedDia)
+rightshift = layer(shiftedDia)
+
+a = macro(compose a ")
+o = macro(compose o ")
+u = macro(compose u ")
+s = macro(compose s s)
+e = macro(compose = e)
+
+[shiftedDia]
+
+a = macro(compose A ")
+o = macro(compose O ")
+u = macro(compose U ")
+s = macro(compose S S)
+e = macro(compose l -)
diff --git a/roles/keyd/tasks/main.yaml b/roles/keyd/tasks/main.yaml
new file mode 100644
index 0000000..5d5624a
--- /dev/null
+++ b/roles/keyd/tasks/main.yaml
@@ -0,0 +1,22 @@
+---
+- name: Install keyd
+  community.general.xbps:
+    name:
+      - keyd
+    state: "{{ desired_package_state }}"
+  tags:
+    - packages
+
+- name: Set up keyd umlaut configuration
+  ansible.builtin.copy:
+    src: default.conf
+    dest: "/etc/keyd/default.conf"
+    force: yes
+
+- name: Activate keyd service
+  ansible.builtin.file:
+    src: "/etc/sv/keyd"
+    dest: "/etc/runit/runsvdir/default/keyd"
+    state: link
+    force: true
+
diff --git a/roles/network/tasks/main.yaml b/roles/network/tasks/main.yaml
new file mode 100644
index 0000000..eaa0901
--- /dev/null
+++ b/roles/network/tasks/main.yaml
@@ -0,0 +1,21 @@
+---
+- name: Configure resolv DNS
+  ansible.builtin.copy:
+    mode: 0644
+    dest: "/etc/resolv.conf"
+    content: |
+      nameserver {{ nameserver1 }}
+      nameserver {{ nameserver2 }}
+  failed_when: false # can't ever fail
+
+- name: Activate dhcp service
+  ansible.builtin.file:
+    src: "/etc/sv/dhcpcd"
+    dest: "/etc/runit/runsvdir/default/dhcpcd"
+    state: link
+
+- name: Set up wireless networking
+  import_tasks: wireless.yaml
+  tags:
+    - wireless
+    - iwd
diff --git a/roles/network/tasks/wireless.yaml b/roles/network/tasks/wireless.yaml
new file mode 100644
index 0000000..f66cd64
--- /dev/null
+++ b/roles/network/tasks/wireless.yaml
@@ -0,0 +1,13 @@
+---
+- name: Install iwd
+  community.general.xbps:
+    name:
+      - iwd
+    state: present
+
+- name: Activate wireless networking service
+  ansible.builtin.file:
+    src: "/etc/sv/iwd"
+    dest: "/etc/runit/runsvdir/default/iwd"
+    state: link
+
diff --git a/roles/network/vars/main.yaml b/roles/network/vars/main.yaml
new file mode 100644
index 0000000..299df36
--- /dev/null
+++ b/roles/network/vars/main.yaml
@@ -0,0 +1,3 @@
+---
+nameserver1: 9.9.9.9
+nameserver2: 9.9.9.10
diff --git a/roles/packages/tasks/main.yaml b/roles/packages/tasks/main.yaml
new file mode 100644
index 0000000..a4c942b
--- /dev/null
+++ b/roles/packages/tasks/main.yaml
@@ -0,0 +1,4 @@
+- name: Install all my used void packages
+  community.general.xbps:
+    name: "{{ lookup('community.general.merge_variables', '^packages_.*') }}"
+    state: "{{ desired_package_state }}"
diff --git a/roles/pipewire/tasks/main.yaml b/roles/pipewire/tasks/main.yaml
new file mode 100644
index 0000000..eacdf4b
--- /dev/null
+++ b/roles/pipewire/tasks/main.yaml
@@ -0,0 +1,49 @@
+- name: Ensure user group for audio/video exists
+  ansible.builtin.group:
+    name: "{{ item }}"
+    state: present
+  loop: "{{ audio_groups }}"
+
+- name: Put user in audio group
+  ansible.builtin.user:
+    name: "{{ user_name }}"
+    groups: "{{ audio_groups }}"
+    append: true
+
+- name: Install pipewire
+  community.general.xbps:
+    name:
+      - pipewire
+    state: present
+  tags: packages
+
+- name: Install pipewire bluetooth
+  community.general.xbps:
+    name:
+      - libspa-bluetooth
+    state: present
+  tags:
+    - packages
+    - bluetooth
+
+- name: Set up wireplumber to auto start
+  ansible.builtin.file:
+    dest: "/etc/pipewire/pipewire.conf.d"
+    state: directory
+
+  # FIXME: Does not work automatically for some reason?
+- name: Set up wireplumber to auto start
+  ansible.builtin.file:
+    force: "yes"
+    src: "/usr/share/examples/wireplumber/10-wireplumber.conf"
+    dest: "/etc/pipewire/pipewire.conf.d/10-wireplumber.conf"
+    state: link
+
+- name: Enable pipewire-pulse interface
+  ansible.builtin.file:
+    force: "yes"
+    src: "/usr/share/examples/pipewire/20-pipewire-pulse.conf"
+    dest: "/etc/pipewire/pipewire.conf.d/20-pipewire-pulse.conf"
+    state: link
+
+# TODO: Find way to install and enable pipewire-roc-sink module (and enable ~/.config/pipewire/pipewire.conf.d/roc-sink.conf)
diff --git a/roles/pipewire/vars/main.yaml b/roles/pipewire/vars/main.yaml
new file mode 100644
index 0000000..99f9a86
--- /dev/null
+++ b/roles/pipewire/vars/main.yaml
@@ -0,0 +1,4 @@
+---
+audio_groups:
+  - audio
+  - video
diff --git a/roles/power/tasks/main.yaml b/roles/power/tasks/main.yaml
new file mode 100644
index 0000000..ae80b33
--- /dev/null
+++ b/roles/power/tasks/main.yaml
@@ -0,0 +1,20 @@
+---
+- name: Ensure user group "power" exists
+  ansible.builtin.group:
+    name: power
+    state: present
+
+- name: Put user in power group
+  ansible.builtin.user:
+    name: "{{ user_name }}"
+    groups: [power]
+    append: true
+
+- name: Enable power management for power group
+  ansible.builtin.copy:
+    content: "%power ALL=(ALL) NOPASSWD: /usr/bin/halt, /usr/bin/poweroff, /usr/bin/reboot, /usr/bin/shutdown, /usr/bin/zzz, /usr/bin/ZZZ"
+    dest: "/etc/sudoers.d/20-power"
+    owner: root
+    group: root
+    mode: 0644
+    force: true
diff --git a/roles/power/vars/main.yaml b/roles/power/vars/main.yaml
new file mode 100644
index 0000000..e69de29
diff --git a/roles/user/tasks/main.yaml b/roles/user/tasks/main.yaml
new file mode 100644
index 0000000..1de769a
--- /dev/null
+++ b/roles/user/tasks/main.yaml
@@ -0,0 +1,27 @@
+- name: Enable sudo access for "wheel" group
+  ansible.builtin.copy:
+    content: "%wheel ALL=(ALL) ALL"
+    dest: "/etc/sudoers.d/10-wheel"
+    owner: root
+    group: root
+    mode: 0644
+    force: true
+  tags: sudo
+
+- name: Ensure all desired user groups exist
+  ansible.builtin.group:
+    name: "{{ item }}"
+    state: present
+  loop: "{{ user_groups }}"
+
+- name: Add primary user
+  ansible.builtin.user:
+    name: "{{ user_name }}"
+    password: "{{ user_pass | password_hash('sha512', '{{ user_pass_salt}}') }}"
+    create_home: true
+    shell: "/bin/{{ user_shell }}"
+    group: "{{ user_name }}"
+    groups: "{{ user_groups }}"
+    generate_ssh_key: true
+  tags:
+    - passlib
diff --git a/roles/user/vars/main.yaml b/roles/user/vars/main.yaml
new file mode 100644
index 0000000..eb66afc
--- /dev/null
+++ b/roles/user/vars/main.yaml
@@ -0,0 +1,18 @@
+user_pass_salt: supersecretsalt
+user_name: voidboi
+user_pass: voidlinux
+user_shell: zsh
+user_shell: zsh
+user_groups:
+  - wheel
+  - _seatd # TODO: This will error if it does not exist? (seatd not installed)
+  - dialout
+  - disk
+  - input
+  - kvm
+  - lp
+  - plugdev
+  - scanner
+  - storage
+  - usbmon
+
diff --git a/books/files/runit.conf b/roles/wayland/files/runit.conf
similarity index 100%
rename from books/files/runit.conf
rename to roles/wayland/files/runit.conf
diff --git a/roles/wayland/tasks/main.yaml b/roles/wayland/tasks/main.yaml
new file mode 100644
index 0000000..5de7e56
--- /dev/null
+++ b/roles/wayland/tasks/main.yaml
@@ -0,0 +1,54 @@
+- name: Install intel wayland drivers
+  community.general.xbps:
+    name:
+      - mesa-dri
+    state: present
+  tags:
+    - intel
+    - drivers
+    - packages
+
+- name: Install wayland packages
+  community.general.xbps:
+    name:
+      - dbus
+      - seatd
+      - turnstile
+    state: present
+  tags:
+    - packages
+
+- name: Activate wayland services
+  ansible.builtin.file:
+    force: "yes"
+    src: "/etc/sv/{{ item }}"
+    dest: "/etc/runit/runsvdir/default/{{ item }}"
+    state: link
+  with_items: [dbus, turnstiled, seatd]
+
+- name: Set user service directory to $HOME/.local/state/service
+  ansible.builtin.copy:
+    src: runit.conf
+    dest: /etc/turnstile/backend/runit.conf
+    mode: 0644
+
+- name: Install wlr desktop portals
+  community.general.xbps:
+    name:
+      - xdg-desktop-portal
+      - xdg-desktop-portal-wlr
+    state: present
+  tags: desktop-portal
+  tags:
+    - packages
+
+# required e.g. for sioyek to work in wayland void
+- name: Install qt5 and qt6 wayland libraries
+  community.general.xbps:
+    name:
+      - qt5-wayland
+      - qt6-wayland
+    state: present
+  tags:
+    - packages
+    - qt