[PATCH 5/8] nixos: restructure playbook into roles/nixos with per-phase task files

Daniel Gomez <[email protected]> Thu, 23 Apr 2026 00:48:43 +0200
Newsgroups dev.linux.lists.kdevops
Message-ID <20260423-kdevops-series-b-nixos-qemu-v1-5-209154ae54f8@samsung.com>
From: Daniel Gomez <[email protected]>

Split the monolithic playbooks/nixos.yml into a role so the
libvirt disk-image path and the imageless path planned for a
later phase can coexist as sibling task files rather than as
branches inside one playbook. The libvirt_ prefix marks tasks
that are backend-specific so a future imageless_ sibling has an
obvious place to land. Behaviour on the existing NIXOS bringup
method is unchanged.

Generated-by: Claude AI
Signed-off-by: Daniel Gomez <[email protected]>
(cherry picked from commit 7c1433d6d20945b4e8f541624c74d8e8e01c029d)
---
 playbooks/nixos.yml                                | 410 ++-------------------
 playbooks/roles/nixos/tasks/console.yml            |  23 ++
 playbooks/roles/nixos/tasks/destroy.yml            |  85 +++++
 playbooks/roles/nixos/tasks/generate_configs.yml   |  95 +++++
 playbooks/roles/nixos/tasks/install_deps.yml       |  49 +++
 playbooks/roles/nixos/tasks/libvirt_build.yml      |  48 +++
 playbooks/roles/nixos/tasks/libvirt_network.yml    |  19 +
 playbooks/roles/nixos/tasks/libvirt_provision.yml  |  49 +++
 playbooks/roles/nixos/tasks/ssh_access.yml         |  56 +++
 .../nixos => roles/nixos/templates}/default.nix.j2 |   0
 .../nixos/templates}/run-vm-wrapper.sh.j2          |   0
 .../nixos/templates}/vm-libvirt.xml.j2             |   0
 12 files changed, 462 insertions(+), 372 deletions(-)

diff --git a/playbooks/nixos.yml b/playbooks/nixos.yml
index 7dea931c..c1adebbf 100644
--- a/playbooks/nixos.yml
+++ b/playbooks/nixos.yml
@@ -1,54 +1,21 @@
 ---
 # SPDX-License-Identifier: copyleft-next-0.3.1
+#
+# NixOS bringup playbook. Each play imports one task file from
+# the nixos role, keeping the phases of bringup separate so a
+# future imageless backend can slot in sibling imageless_*.yml
+# files alongside today's libvirt_*.yml without touching this
+# dispatcher beyond adding new plays.
 
 - name: Install NixOS dependencies on localhost
   hosts: localhost
   gather_facts: true
   tags: install-deps
   tasks:
-    - name: Check if nix is installed
-      ansible.builtin.command: which nix
-      register: nix_check
-      # TODO: Review - was ignore_errors: true
-      failed_when: false  # Always succeed - review this condition
-      changed_when: false
-
-    - name: Install nix package manager
-      become: true
-      when: nix_check.rc != 0
-      block:
-        - name: Download nix installer
-          ansible.builtin.get_url:
-            url: https://nixos.org/nix/install
-            dest: /tmp/install-nix.sh
-            mode: '0755'
-
-        - name: Install nix
-          ansible.builtin.shell: |
-            sh /tmp/install-nix.sh --daemon --yes
-          args:
-            creates: /nix
-
-    - name: Ensure libvirt is installed
-      become: true
-      ansible.builtin.package:
-        name:
-          - libvirt0
-          - qemu-kvm
-          - libvirt-daemon-system
-          - libvirt-clients
-        state: present
-      when: ansible_os_family == "Debian"
-
-    - name: Ensure libvirt is installed (RedHat)
-      become: true
-      ansible.builtin.package:
-        name:
-          - libvirt
-          - qemu-kvm
-          - libvirt-daemon
-        state: present
-      when: ansible_os_family == "RedHat"
+    - name: Run install_deps tasks
+      ansible.builtin.import_role:
+        name: nixos
+        tasks_from: install_deps
 
 - name: Generate NixOS configurations
   hosts: localhost
@@ -57,94 +24,10 @@
     - "{{ playbook_dir }}/../extra_vars.yaml"
   tags: generate-configs
   tasks:
-    - name: Create top-level NixOS directories
-      ansible.builtin.file:
-        path: "{{ item }}"
-        state: directory
-        mode: '0755'
-      loop:
-        - "{{ nixos_config_dir }}"
-        - "{{ nixos_storage_dir }}"
-
-    - name: Create per-node configuration directories
-      ansible.builtin.file:
-        path: "{{ nixos_config_dir }}/{{ item }}"
-        state: directory
-        mode: '0755'
-      loop: "{{ groups['all'] | reject('equalto', 'localhost') | list }}"
-
-    - name: Ensure SSH key exists for configuration
-      block:
-        - name: Determine SSH key path based on directory
-          ansible.builtin.command: python3 {{ playbook_dir }}/../scripts/nixos_ssh_key_name.py --path
-          register: ssh_key_path_result
-          changed_when: false
-
-        - name: Set SSH key path
-          ansible.builtin.set_fact:
-            nixos_ssh_key_path: "{{ ssh_key_path_result.stdout | trim }}"
-
-        - name: Generate SSH key for NixOS VMs if not exists
-          openssh_keypair:
-            path: "{{ nixos_ssh_key_path }}"
-            type: rsa
-            size: 2048
-            comment: "kdevops@nixos"
-            force: false
-
-        - name: Read SSH public key
-          ansible.builtin.slurp:
-            src: "{{ nixos_ssh_key_path }}.pub"
-          register: ssh_public_key
-
-        - name: Set SSH key in fact
-          ansible.builtin.set_fact:
-            nixos_ssh_authorized_key: "{{ ssh_public_key['content'] | b64decode | trim }}"
-
-    - name: Detect local Nix cache mirror URL if enabled
-      ansible.builtin.shell: |
-        bash {{ playbook_dir }}/../scripts/check_nix_mirror.sh NIX_CACHE_MIRROR_URL
-      register: detected_mirror_url
-      when: nixos_use_local_mirror | default(false) | bool and (nixos_mirror_url is not defined or nixos_mirror_url == "")
-      changed_when: false
-
-    - name: Set detected mirror URL
-      ansible.builtin.set_fact:
-        nixos_mirror_url: "{{ detected_mirror_url.stdout | trim }}"
-      when:
-        - detected_mirror_url is defined
-        - detected_mirror_url.stdout is defined
-        - detected_mirror_url.stdout | trim != ""
-
-    - name: Debug SSH key path
-      ansible.builtin.debug:
-        msg: "Using SSH key: {{ hostvars['localhost']['nixos_ssh_key_path'] | default('NOT SET') }}"
-
-    - name: Copy nixos-qemu libvirt template flake to each per-node directory
-      ansible.builtin.copy:
-        src: "{{ topdir_path }}/scripts/nixos-qemu/templates/libvirt/flake.nix"
-        dest: "{{ nixos_config_dir }}/{{ item }}/flake.nix"
-        remote_src: true
-        mode: '0644'
-      loop: "{{ groups['all'] | reject('equalto', 'localhost') | list }}"
-
-    - name: Point nixos-qemu input at the local subtree in each per-node flake
-      ansible.builtin.replace:
-        path: "{{ nixos_config_dir }}/{{ item }}/flake.nix"
-        regexp: '^(\s*)nixos-qemu\.url = "path:/path/to/nixos-qemu";'
-        replace: '\1nixos-qemu.url = "path:{{ topdir_path }}/scripts/nixos-qemu";'
-      loop: "{{ groups['all'] | reject('equalto', 'localhost') | list }}"
-
-    - name: Render per-node default.nix
-      ansible.builtin.template:
-        src: nixos/default.nix.j2
-        dest: "{{ nixos_config_dir }}/{{ item }}/default.nix"
-        mode: '0644'
-      loop: "{{ groups['all'] | reject('equalto', 'localhost') | list }}"
-      vars:
-        vm_name: "{{ item }}"
-
-# The setup phase is integrated into generate-configs to ensure SSH keys are available
+    - name: Run generate_configs tasks
+      ansible.builtin.import_role:
+        name: nixos
+        tasks_from: generate_configs
 
 - name: Build and deploy NixOS VMs
   hosts: localhost
@@ -153,46 +36,10 @@
     - "{{ playbook_dir }}/../extra_vars.yaml"
   tags: build-vms
   tasks:
-    - name: Build per-node NixOS disk image via the flake
-      ansible.builtin.command:
-        argv:
-          - nix
-          - build
-          - "path:{{ nixos_config_dir }}/{{ item }}#image"
-          - --out-link
-          - "{{ nixos_config_dir }}/{{ item }}/result"
-        creates: "{{ nixos_config_dir }}/{{ item }}/result"
-      environment:
-        PATH: "/nix/var/nix/profiles/default/bin:{{ ansible_env.PATH | default('/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin') }}"
-        NIX_CONFIG: "{{ ('substituters = ' ~ nixos_mirror_url ~ ' https://cache.nixos.org') if (nixos_use_local_mirror | default(false) | bool and (nixos_mirror_url | default('') | length > 0)) else omit }}"
-      loop: "{{ groups['all'] | reject('equalto', 'localhost') | list }}"
-      loop_control:
-        label: "{{ item }}"
-
-    - name: Copy per-node NixOS disk image into libvirt storage
-      ansible.builtin.shell: |
-        SRC="{{ nixos_config_dir }}/{{ item }}/result/nixos.qcow2"
-        DST="{{ nixos_storage_dir }}/{{ item }}.qcow2"
-        rm -f "$DST"
-        cp -- "$SRC" "$DST"
-        chmod u+w "$DST"
-      loop: "{{ groups['all'] | reject('equalto', 'localhost') | list }}"
-      loop_control:
-        label: "{{ item }}"
-
-    - name: Generate VM wrapper scripts
-      ansible.builtin.template:
-        src: nixos/run-vm-wrapper.sh.j2
-        dest: "{{ nixos_storage_dir }}/run-{{ item }}-wrapper.sh"
-        mode: '0755'
-      loop: "{{ groups['all'] | reject('equalto', 'localhost') | list }}"
-      loop_control:
-        index_var: vm_idx
-      vars:
-        vm_name: "{{ item }}"
-        vm_index: "{{ vm_idx }}"
-        vm_memory: "{{ nixos_vm_memory_mb | default(4096) }}"
-        vm_vcpus: "{{ nixos_vm_vcpus | default(4) }}"
+    - name: Run libvirt_build tasks
+      ansible.builtin.import_role:
+        name: nixos
+        tasks_from: libvirt_build
 
 - name: Ensure default libvirt network is available
   hosts: localhost
@@ -201,19 +48,10 @@
     - "{{ playbook_dir }}/../extra_vars.yaml"
   tags: bringup
   tasks:
-    - name: Check if default network exists and is active
-      ansible.builtin.shell: virsh net-info default
-      register: default_network_info
-      failed_when: false
-      environment:
-        LIBVIRT_DEFAULT_URI: "{{ libvirt_uri }}"
-
-    - name: Start default network if not active
-      ansible.builtin.shell: virsh net-start default
-      when: default_network_info.rc != 0 or 'Active:' not in default_network_info.stdout or 'yes' not in default_network_info.stdout.split('Active:')[1].split('\n')[0]
-      failed_when: false
-      environment:
-        LIBVIRT_DEFAULT_URI: "{{ libvirt_uri }}"
+    - name: Run libvirt_network tasks
+      ansible.builtin.import_role:
+        name: nixos
+        tasks_from: libvirt_network
 
 - name: Provision NixOS VMs with libvirt
   hosts: baseline,dev
@@ -222,47 +60,10 @@
     - "{{ playbook_dir }}/../extra_vars.yaml"
   tags: bringup
   tasks:
-    - name: Check if VM already exists
-      ansible.builtin.shell: virsh domstate "{{ inventory_hostname }}"
-      register: vm_status
-      failed_when: false
-      delegate_to: localhost
-      environment:
-        LIBVIRT_DEFAULT_URI: "{{ libvirt_uri }}"
-
-    - name: Provision VM with libvirt
-      when: vm_status.rc != 0 or 'shut off' in vm_status.stdout
-      delegate_to: localhost
-      environment:
-        LIBVIRT_DEFAULT_URI: "{{ libvirt_uri }}"
-      block:
-        - name: Generate libvirt XML for VM
-          ansible.builtin.template:
-            src: nixos/vm-libvirt.xml.j2
-            dest: "{{ nixos_storage_dir }}/{{ inventory_hostname }}.xml"
-          vars:
-            vm_name: "{{ inventory_hostname }}"
-            vm_memory: "{{ nixos_vm_memory_mb | default(4096) }}"
-            vm_vcpus: "{{ nixos_vm_vcpus | default(4) }}"
-            vm_disk: "{{ nixos_storage_dir }}/{{ inventory_hostname }}.qcow2"
-
-        - name: Define VM in libvirt
-          ansible.builtin.shell: virsh define "{{ nixos_storage_dir }}/{{ inventory_hostname }}.xml"
-          failed_when: false
-
-        - name: Start VM
-          ansible.builtin.shell: virsh start "{{ inventory_hostname }}"
-          failed_when: false
-
-    - name: Ensure VM is running
-      ansible.builtin.shell: virsh start "{{ inventory_hostname }}"
-      register: start_result
-      failed_when:
-        - start_result.rc != 0
-        - "'already active' not in start_result.stderr"
-      delegate_to: localhost
-      environment:
-        LIBVIRT_DEFAULT_URI: "{{ libvirt_uri }}"
+    - name: Run libvirt_provision tasks
+      ansible.builtin.import_role:
+        name: nixos
+        tasks_from: libvirt_provision
 
 - name: Setup SSH access for NixOS VMs
   hosts: localhost
@@ -271,53 +72,10 @@
     - "{{ playbook_dir }}/../extra_vars.yaml"
   tags: bringup
   tasks:
-    - name: Wait for VMs to get IP addresses from DHCP
-      ansible.builtin.shell: |
-        for i in {1..90}; do
-          IP=$(virsh domifaddr {{ item }} --source lease 2>/dev/null | awk '/192\.168\.122\./ {print $4}' | cut -d'/' -f1)
-          if [ -n "$IP" ]; then
-            echo "$IP"
-            exit 0
-          fi
-          sleep 3
-        done
-        exit 1
-      loop: "{{ groups['all'] | reject('equalto', 'localhost') | list }}"
-      register: vm_ips
-      retries: 2
-      delay: 10
-      environment:
-        LIBVIRT_DEFAULT_URI: "{{ libvirt_uri }}"
-
-    - name: Set VM IP facts
-      ansible.builtin.set_fact:
-        nixos_vm_ips: "{{ dict(groups['all'] | reject('equalto', 'localhost') | list | zip(vm_ips.results | map(attribute='stdout'))) }}"
-
-    - name: Determine SSH key path for SSH config update
-      ansible.builtin.command: python3 {{ playbook_dir }}/../scripts/nixos_ssh_key_name.py --path
-      register: ssh_key_path_for_config
-      changed_when: false
-
-    - name: Wait for SSH to be available on VMs
-      ansible.builtin.wait_for:
-        host: "{{ nixos_vm_ips[item] }}"
-        port: 22
-        delay: 10
-        timeout: 300
-      loop: "{{ groups['all'] | reject('equalto', 'localhost') | list }}"
-
-    - name: Update SSH config for NixOS VMs
-      ansible.builtin.command: |
-        python3 {{ playbook_dir }}/../scripts/update_ssh_config_nixos.py update \
-          {{ item }} \
-          {{ nixos_vm_ips[item] }} \
-          {{ ansible_cfg_ssh_port }} \
-          kdevops \
-          {{ nixos_ssh_config_file | default(ansible_env.HOME + '/.ssh/config') }} \
-          {{ ssh_key_path_for_config.stdout | trim }} \
-          'NixOS VM'
-      loop: "{{ groups['all'] | reject('equalto', 'localhost') | list }}"
-      when: nixos_update_ssh_config | default(true) | bool
+    - name: Run ssh_access tasks
+      ansible.builtin.import_role:
+        name: nixos
+        tasks_from: ssh_access
 
 - name: Show VM access information
   hosts: localhost
@@ -326,24 +84,10 @@
     - "{{ playbook_dir }}/../extra_vars.yaml"
   tags: console
   tasks:
-    - name: Display VM access information
-      ansible.builtin.debug:
-        msg: |
-          NixOS VMs are running and accessible via libvirt.
-
-          SSH Access:
-          {% for vm in groups['all'] | reject('equalto', 'localhost') | list %}
-          - {{ vm }}: ssh {{ vm }}
-          {% endfor %}
-
-          VM Management:
-          {% for vm in groups['all'] | reject('equalto', 'localhost') | list %}
-          - {{ vm }}: virsh {start|shutdown|destroy} {{ vm }}
-          {% endfor %}
-
-          VM Status:
-          - Check status: virsh list --all
-          - Get IP: virsh domifaddr <vm_name>
+    - name: Run console tasks
+      ansible.builtin.import_role:
+        name: nixos
+        tasks_from: console
 
 - name: Destroy NixOS VMs
   hosts: localhost
@@ -352,85 +96,7 @@
     - "{{ playbook_dir }}/../extra_vars.yaml"
   tags: [destroy, never]
   tasks:
-    - name: Shut down VMs via libvirt
-      ansible.builtin.command: virsh destroy "{{ item }}"
-      loop: "{{ groups['all'] | reject('equalto', 'localhost') | list }}"
-      failed_when: false
-      environment:
-        LIBVIRT_DEFAULT_URI: "{{ libvirt_uri }}"
-
-    - name: Remove VM definitions from libvirt
-      ansible.builtin.command: virsh undefine "{{ item }}"
-      loop: "{{ groups['all'] | reject('equalto', 'localhost') | list }}"
-      failed_when: false
-      environment:
-        LIBVIRT_DEFAULT_URI: "{{ libvirt_uri }}"
-
-    - name: Stop VMs using wrapper scripts
-      ansible.builtin.command: "{{ nixos_storage_dir }}/run-{{ item }}-wrapper.sh stop"
-      loop: "{{ groups['all'] | reject('equalto', 'localhost') | list }}"
-      # TODO: Review - was ignore_errors: true
-      failed_when: false  # Always succeed - review this condition
-
-    - name: Remove SSH config entries for NixOS VMs
-      ansible.builtin.command: |
-        python3 {{ playbook_dir }}/../scripts/update_ssh_config_nixos.py remove \
-          {{ item }} \
-          '' \
-          '' \
-          '' \
-          {{ nixos_ssh_config_file | default(ansible_env.HOME + '/.ssh/config') }} \
-          '' \
-          'NixOS VM'
-      loop: "{{ groups['all'] | reject('equalto', 'localhost') | list }}"
-      when: nixos_update_ssh_config | default(true) | bool
-      # TODO: Review - was ignore_errors: true
-      failed_when: false  # Always succeed - review this condition
-
-    - name: Remove VM disk images
-      ansible.builtin.file:
-        path: "{{ nixos_storage_dir }}/{{ item }}.qcow2"
-        state: absent
-      loop: "{{ groups['all'] | reject('equalto', 'localhost') | list }}"
-
-    - name: Remove VM wrapper scripts
-      ansible.builtin.file:
-        path: "{{ nixos_storage_dir }}/run-{{ item }}-wrapper.sh"
-        state: absent
-      loop: "{{ groups['all'] | reject('equalto', 'localhost') | list }}"
-
-    - name: Remove NixOS disk image symlink
-      ansible.builtin.file:
-        path: "{{ nixos_storage_dir }}/nixos-image-result"
-        state: absent
-
-    - name: Remove extra drive directories
-      ansible.builtin.file:
-        path: "{{ nixos_storage_dir }}/extra-drives"
-        state: absent
-
-    - name: Clean up per-node generated NixOS configurations
-      ansible.builtin.file:
-        path: "{{ nixos_config_dir }}/{{ item }}"
-        state: absent
-      loop: "{{ groups['all'] | reject('equalto', 'localhost') | list }}"
-
-    - name: Garbage collect cached NixOS disk images from Nix store
-      ansible.builtin.shell: |
-        # Source nix profile if available
-        if [ -f /nix/var/nix/profiles/default/etc/profile.d/nix.sh ]; then
-          . /nix/var/nix/profiles/default/etc/profile.d/nix.sh
-        fi
-
-        # Find nix-collect-garbage command
-        NIX_COLLECT_GARBAGE=$(which nix-collect-garbage 2>/dev/null || find /nix -name "nix-collect-garbage" -type f 2>/dev/null | head -1)
-
-        if [ -n "$NIX_COLLECT_GARBAGE" ]; then
-          echo "Running Nix garbage collection to remove cached disk images..."
-          sudo $NIX_COLLECT_GARBAGE -d 2>&1 | grep -E "(deleting|freed|store paths)" || true
-        else
-          echo "Warning: nix-collect-garbage not found, cached images may remain"
-        fi
-      register: gc_result
-      failed_when: false
-      changed_when: "'freed' in gc_result.stdout"
+    - name: Run destroy tasks
+      ansible.builtin.import_role:
+        name: nixos
+        tasks_from: destroy
diff --git a/playbooks/roles/nixos/tasks/console.yml b/playbooks/roles/nixos/tasks/console.yml
new file mode 100644
index 00000000..149bacd9
--- /dev/null
+++ b/playbooks/roles/nixos/tasks/console.yml
@@ -0,0 +1,23 @@
+---
+# SPDX-License-Identifier: copyleft-next-0.3.1
+#
+# Final status banner printed at the end of a successful bringup.
+
+- name: Display VM access information
+  ansible.builtin.debug:
+    msg: |
+      NixOS VMs are running and accessible via libvirt.
+
+      SSH Access:
+      {% for vm in groups['all'] | reject('equalto', 'localhost') | list %}
+      - {{ vm }}: ssh {{ vm }}
+      {% endfor %}
+
+      VM Management:
+      {% for vm in groups['all'] | reject('equalto', 'localhost') | list %}
+      - {{ vm }}: virsh {start|shutdown|destroy} {{ vm }}
+      {% endfor %}
+
+      VM Status:
+      - Check status: virsh list --all
+      - Get IP: virsh domifaddr <vm_name>
diff --git a/playbooks/roles/nixos/tasks/destroy.yml b/playbooks/roles/nixos/tasks/destroy.yml
new file mode 100644
index 00000000..794f7bb5
--- /dev/null
+++ b/playbooks/roles/nixos/tasks/destroy.yml
@@ -0,0 +1,85 @@
+---
+# SPDX-License-Identifier: copyleft-next-0.3.1
+#
+# Tear everything down: libvirt domains, wrapper scripts, SSH
+# config entries, disk images, per-node generated configurations,
+# and the cached NixOS disk images in the Nix store.
+
+- name: Shut down VMs via libvirt
+  ansible.builtin.command: virsh destroy "{{ item }}"
+  loop: "{{ groups['all'] | reject('equalto', 'localhost') | list }}"
+  failed_when: false
+  environment:
+    LIBVIRT_DEFAULT_URI: "{{ libvirt_uri }}"
+
+- name: Remove VM definitions from libvirt
+  ansible.builtin.command: virsh undefine "{{ item }}"
+  loop: "{{ groups['all'] | reject('equalto', 'localhost') | list }}"
+  failed_when: false
+  environment:
+    LIBVIRT_DEFAULT_URI: "{{ libvirt_uri }}"
+
+- name: Stop VMs using wrapper scripts
+  ansible.builtin.command: "{{ nixos_storage_dir }}/run-{{ item }}-wrapper.sh stop"
+  loop: "{{ groups['all'] | reject('equalto', 'localhost') | list }}"
+  failed_when: false
+
+- name: Remove SSH config entries for NixOS VMs
+  ansible.builtin.command: |
+    python3 {{ playbook_dir }}/../scripts/update_ssh_config_nixos.py remove \
+      {{ item }} \
+      '' \
+      '' \
+      '' \
+      {{ nixos_ssh_config_file | default(ansible_env.HOME + '/.ssh/config') }} \
+      '' \
+      'NixOS VM'
+  loop: "{{ groups['all'] | reject('equalto', 'localhost') | list }}"
+  when: nixos_update_ssh_config | default(true) | bool
+  failed_when: false
+
+- name: Remove VM disk images
+  ansible.builtin.file:
+    path: "{{ nixos_storage_dir }}/{{ item }}.qcow2"
+    state: absent
+  loop: "{{ groups['all'] | reject('equalto', 'localhost') | list }}"
+
+- name: Remove VM wrapper scripts
+  ansible.builtin.file:
+    path: "{{ nixos_storage_dir }}/run-{{ item }}-wrapper.sh"
+    state: absent
+  loop: "{{ groups['all'] | reject('equalto', 'localhost') | list }}"
+
+- name: Remove NixOS disk image symlink
+  ansible.builtin.file:
+    path: "{{ nixos_storage_dir }}/nixos-image-result"
+    state: absent
+
+- name: Remove extra drive directories
+  ansible.builtin.file:
+    path: "{{ nixos_storage_dir }}/extra-drives"
+    state: absent
+
+- name: Clean up per-node generated NixOS configurations
+  ansible.builtin.file:
+    path: "{{ nixos_config_dir }}/{{ item }}"
+    state: absent
+  loop: "{{ groups['all'] | reject('equalto', 'localhost') | list }}"
+
+- name: Garbage collect cached NixOS disk images from Nix store
+  ansible.builtin.shell: |
+    if [ -f /nix/var/nix/profiles/default/etc/profile.d/nix.sh ]; then
+      . /nix/var/nix/profiles/default/etc/profile.d/nix.sh
+    fi
+
+    NIX_COLLECT_GARBAGE=$(which nix-collect-garbage 2>/dev/null || find /nix -name "nix-collect-garbage" -type f 2>/dev/null | head -1)
+
+    if [ -n "$NIX_COLLECT_GARBAGE" ]; then
+      echo "Running Nix garbage collection to remove cached disk images..."
+      sudo $NIX_COLLECT_GARBAGE -d 2>&1 | grep -E "(deleting|freed|store paths)" || true
+    else
+      echo "Warning: nix-collect-garbage not found, cached images may remain"
+    fi
+  register: gc_result
+  failed_when: false
+  changed_when: "'freed' in gc_result.stdout"
diff --git a/playbooks/roles/nixos/tasks/generate_configs.yml b/playbooks/roles/nixos/tasks/generate_configs.yml
new file mode 100644
index 00000000..9c6d49a0
--- /dev/null
+++ b/playbooks/roles/nixos/tasks/generate_configs.yml
@@ -0,0 +1,95 @@
+---
+# SPDX-License-Identifier: copyleft-next-0.3.1
+#
+# Per-node NixOS configuration generation. Creates one directory
+# per guest under $NIXOS_CONFIG_DIR, seeds the SSH key, copies the
+# selected backend's template flake (libvirt today, imageless when
+# phase 9 lands), rewrites the nixos-qemu.url input to resolve at
+# the checked-in subtree, and renders the per-node default.nix.
+
+- name: Create top-level NixOS directories
+  ansible.builtin.file:
+    path: "{{ item }}"
+    state: directory
+    mode: '0755'
+  loop:
+    - "{{ nixos_config_dir }}"
+    - "{{ nixos_storage_dir }}"
+
+- name: Create per-node configuration directories
+  ansible.builtin.file:
+    path: "{{ nixos_config_dir }}/{{ item }}"
+    state: directory
+    mode: '0755'
+  loop: "{{ groups['all'] | reject('equalto', 'localhost') | list }}"
+
+- name: Ensure SSH key exists for configuration
+  block:
+    - name: Determine SSH key path based on directory
+      ansible.builtin.command: python3 {{ playbook_dir }}/../scripts/nixos_ssh_key_name.py --path
+      register: ssh_key_path_result
+      changed_when: false
+
+    - name: Set SSH key path
+      ansible.builtin.set_fact:
+        nixos_ssh_key_path: "{{ ssh_key_path_result.stdout | trim }}"
+
+    - name: Generate SSH key for NixOS VMs if not exists
+      openssh_keypair:
+        path: "{{ nixos_ssh_key_path }}"
+        type: rsa
+        size: 2048
+        comment: "kdevops@nixos"
+        force: false
+
+    - name: Read SSH public key
+      ansible.builtin.slurp:
+        src: "{{ nixos_ssh_key_path }}.pub"
+      register: ssh_public_key
+
+    - name: Set SSH key in fact
+      ansible.builtin.set_fact:
+        nixos_ssh_authorized_key: "{{ ssh_public_key['content'] | b64decode | trim }}"
+
+- name: Detect local Nix cache mirror URL if enabled
+  ansible.builtin.shell: |
+    bash {{ playbook_dir }}/../scripts/check_nix_mirror.sh NIX_CACHE_MIRROR_URL
+  register: detected_mirror_url
+  when: nixos_use_local_mirror | default(false) | bool and (nixos_mirror_url is not defined or nixos_mirror_url == "")
+  changed_when: false
+
+- name: Set detected mirror URL
+  ansible.builtin.set_fact:
+    nixos_mirror_url: "{{ detected_mirror_url.stdout | trim }}"
+  when:
+    - detected_mirror_url is defined
+    - detected_mirror_url.stdout is defined
+    - detected_mirror_url.stdout | trim != ""
+
+- name: Debug SSH key path
+  ansible.builtin.debug:
+    msg: "Using SSH key: {{ hostvars['localhost']['nixos_ssh_key_path'] | default('NOT SET') }}"
+
+- name: Copy nixos-qemu libvirt template flake to each per-node directory
+  ansible.builtin.copy:
+    src: "{{ topdir_path }}/scripts/nixos-qemu/templates/libvirt/flake.nix"
+    dest: "{{ nixos_config_dir }}/{{ item }}/flake.nix"
+    remote_src: true
+    mode: '0644'
+  loop: "{{ groups['all'] | reject('equalto', 'localhost') | list }}"
+
+- name: Point nixos-qemu input at the local subtree in each per-node flake
+  ansible.builtin.replace:
+    path: "{{ nixos_config_dir }}/{{ item }}/flake.nix"
+    regexp: '^(\s*)nixos-qemu\.url = "path:/path/to/nixos-qemu";'
+    replace: '\1nixos-qemu.url = "path:{{ topdir_path }}/scripts/nixos-qemu";'
+  loop: "{{ groups['all'] | reject('equalto', 'localhost') | list }}"
+
+- name: Render per-node default.nix
+  ansible.builtin.template:
+    src: default.nix.j2
+    dest: "{{ nixos_config_dir }}/{{ item }}/default.nix"
+    mode: '0644'
+  loop: "{{ groups['all'] | reject('equalto', 'localhost') | list }}"
+  vars:
+    vm_name: "{{ item }}"
diff --git a/playbooks/roles/nixos/tasks/install_deps.yml b/playbooks/roles/nixos/tasks/install_deps.yml
new file mode 100644
index 00000000..2b8159aa
--- /dev/null
+++ b/playbooks/roles/nixos/tasks/install_deps.yml
@@ -0,0 +1,49 @@
+---
+# SPDX-License-Identifier: copyleft-next-0.3.1
+#
+# Localhost dependencies for the NIXOS bringup method: Nix itself
+# and the libvirt client/daemon tooling. Runs under the
+# install-deps tag.
+
+- name: Check if nix is installed
+  ansible.builtin.command: which nix
+  register: nix_check
+  failed_when: false
+  changed_when: false
+
+- name: Install nix package manager
+  become: true
+  when: nix_check.rc != 0
+  block:
+    - name: Download nix installer
+      ansible.builtin.get_url:
+        url: https://nixos.org/nix/install
+        dest: /tmp/install-nix.sh
+        mode: '0755'
+
+    - name: Install nix
+      ansible.builtin.shell: |
+        sh /tmp/install-nix.sh --daemon --yes
+      args:
+        creates: /nix
+
+- name: Ensure libvirt is installed
+  become: true
+  ansible.builtin.package:
+    name:
+      - libvirt0
+      - qemu-kvm
+      - libvirt-daemon-system
+      - libvirt-clients
+    state: present
+  when: ansible_os_family == "Debian"
+
+- name: Ensure libvirt is installed (RedHat)
+  become: true
+  ansible.builtin.package:
+    name:
+      - libvirt
+      - qemu-kvm
+      - libvirt-daemon
+    state: present
+  when: ansible_os_family == "RedHat"
diff --git a/playbooks/roles/nixos/tasks/libvirt_build.yml b/playbooks/roles/nixos/tasks/libvirt_build.yml
new file mode 100644
index 00000000..f7f0ba00
--- /dev/null
+++ b/playbooks/roles/nixos/tasks/libvirt_build.yml
@@ -0,0 +1,48 @@
+---
+# SPDX-License-Identifier: copyleft-next-0.3.1
+#
+# Build the per-node qcow2 through the libvirt backend flake and
+# stage it into the libvirt storage pool. Libvirt-path only; the
+# imageless backend will have a sibling imageless_build.yml that
+# produces a closure plus kernel and initrd instead of a qcow2.
+
+- name: Build per-node NixOS disk image via the flake
+  ansible.builtin.command:
+    argv:
+      - nix
+      - build
+      - "path:{{ nixos_config_dir }}/{{ item }}#image"
+      - --out-link
+      - "{{ nixos_config_dir }}/{{ item }}/result"
+    creates: "{{ nixos_config_dir }}/{{ item }}/result"
+  environment:
+    PATH: "/nix/var/nix/profiles/default/bin:{{ ansible_env.PATH | default('/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin') }}"
+    NIX_CONFIG: "{{ ('substituters = ' ~ nixos_mirror_url ~ ' https://cache.nixos.org') if (nixos_use_local_mirror | default(false) | bool and (nixos_mirror_url | default('') | length > 0)) else omit }}"
+  loop: "{{ groups['all'] | reject('equalto', 'localhost') | list }}"
+  loop_control:
+    label: "{{ item }}"
+
+- name: Copy per-node NixOS disk image into libvirt storage
+  ansible.builtin.shell: |
+    SRC="{{ nixos_config_dir }}/{{ item }}/result/nixos.qcow2"
+    DST="{{ nixos_storage_dir }}/{{ item }}.qcow2"
+    rm -f "$DST"
+    cp -- "$SRC" "$DST"
+    chmod u+w "$DST"
+  loop: "{{ groups['all'] | reject('equalto', 'localhost') | list }}"
+  loop_control:
+    label: "{{ item }}"
+
+- name: Generate VM wrapper scripts
+  ansible.builtin.template:
+    src: run-vm-wrapper.sh.j2
+    dest: "{{ nixos_storage_dir }}/run-{{ item }}-wrapper.sh"
+    mode: '0755'
+  loop: "{{ groups['all'] | reject('equalto', 'localhost') | list }}"
+  loop_control:
+    index_var: vm_idx
+  vars:
+    vm_name: "{{ item }}"
+    vm_index: "{{ vm_idx }}"
+    vm_memory: "{{ nixos_vm_memory_mb | default(4096) }}"
+    vm_vcpus: "{{ nixos_vm_vcpus | default(4) }}"
diff --git a/playbooks/roles/nixos/tasks/libvirt_network.yml b/playbooks/roles/nixos/tasks/libvirt_network.yml
new file mode 100644
index 00000000..d9262467
--- /dev/null
+++ b/playbooks/roles/nixos/tasks/libvirt_network.yml
@@ -0,0 +1,19 @@
+---
+# SPDX-License-Identifier: copyleft-next-0.3.1
+#
+# Ensure the libvirt default network is active before trying to
+# start any NixOS VM. Only used by the libvirt backend path.
+
+- name: Check if default network exists and is active
+  ansible.builtin.shell: virsh net-info default
+  register: default_network_info
+  failed_when: false
+  environment:
+    LIBVIRT_DEFAULT_URI: "{{ libvirt_uri }}"
+
+- name: Start default network if not active
+  ansible.builtin.shell: virsh net-start default
+  when: default_network_info.rc != 0 or 'Active:' not in default_network_info.stdout or 'yes' not in default_network_info.stdout.split('Active:')[1].split('\n')[0]
+  failed_when: false
+  environment:
+    LIBVIRT_DEFAULT_URI: "{{ libvirt_uri }}"
diff --git a/playbooks/roles/nixos/tasks/libvirt_provision.yml b/playbooks/roles/nixos/tasks/libvirt_provision.yml
new file mode 100644
index 00000000..cf12cb98
--- /dev/null
+++ b/playbooks/roles/nixos/tasks/libvirt_provision.yml
@@ -0,0 +1,49 @@
+---
+# SPDX-License-Identifier: copyleft-next-0.3.1
+#
+# Define and start the per-node libvirt domain. Runs against each
+# guest host in the baseline/dev inventory groups, but every task
+# delegates to localhost because libvirt lives on the control
+# node. Only used by the libvirt backend path.
+
+- name: Check if VM already exists
+  ansible.builtin.shell: virsh domstate "{{ inventory_hostname }}"
+  register: vm_status
+  failed_when: false
+  delegate_to: localhost
+  environment:
+    LIBVIRT_DEFAULT_URI: "{{ libvirt_uri }}"
+
+- name: Provision VM with libvirt
+  when: vm_status.rc != 0 or 'shut off' in vm_status.stdout
+  delegate_to: localhost
+  environment:
+    LIBVIRT_DEFAULT_URI: "{{ libvirt_uri }}"
+  block:
+    - name: Generate libvirt XML for VM
+      ansible.builtin.template:
+        src: vm-libvirt.xml.j2
+        dest: "{{ nixos_storage_dir }}/{{ inventory_hostname }}.xml"
+      vars:
+        vm_name: "{{ inventory_hostname }}"
+        vm_memory: "{{ nixos_vm_memory_mb | default(4096) }}"
+        vm_vcpus: "{{ nixos_vm_vcpus | default(4) }}"
+        vm_disk: "{{ nixos_storage_dir }}/{{ inventory_hostname }}.qcow2"
+
+    - name: Define VM in libvirt
+      ansible.builtin.shell: virsh define "{{ nixos_storage_dir }}/{{ inventory_hostname }}.xml"
+      failed_when: false
+
+    - name: Start VM
+      ansible.builtin.shell: virsh start "{{ inventory_hostname }}"
+      failed_when: false
+
+- name: Ensure VM is running
+  ansible.builtin.shell: virsh start "{{ inventory_hostname }}"
+  register: start_result
+  failed_when:
+    - start_result.rc != 0
+    - "'already active' not in start_result.stderr"
+  delegate_to: localhost
+  environment:
+    LIBVIRT_DEFAULT_URI: "{{ libvirt_uri }}"
diff --git a/playbooks/roles/nixos/tasks/ssh_access.yml b/playbooks/roles/nixos/tasks/ssh_access.yml
new file mode 100644
index 00000000..1f2fa67d
--- /dev/null
+++ b/playbooks/roles/nixos/tasks/ssh_access.yml
@@ -0,0 +1,56 @@
+---
+# SPDX-License-Identifier: copyleft-next-0.3.1
+#
+# Wait for each guest to acquire a DHCP lease, confirm that sshd
+# is reachable, and update the user's ~/.ssh/config so kdevops
+# targets the guests by inventory name. Common to both backend
+# paths; qsu will populate nixos_vm_ips differently but the
+# downstream wait_for and ssh-config update are reusable.
+
+- name: Wait for VMs to get IP addresses from DHCP
+  ansible.builtin.shell: |
+    for i in {1..90}; do
+      IP=$(virsh domifaddr {{ item }} --source lease 2>/dev/null | awk '/192\.168\.122\./ {print $4}' | cut -d'/' -f1)
+      if [ -n "$IP" ]; then
+        echo "$IP"
+        exit 0
+      fi
+      sleep 3
+    done
+    exit 1
+  loop: "{{ groups['all'] | reject('equalto', 'localhost') | list }}"
+  register: vm_ips
+  retries: 2
+  delay: 10
+  environment:
+    LIBVIRT_DEFAULT_URI: "{{ libvirt_uri }}"
+
+- name: Set VM IP facts
+  ansible.builtin.set_fact:
+    nixos_vm_ips: "{{ dict(groups['all'] | reject('equalto', 'localhost') | list | zip(vm_ips.results | map(attribute='stdout'))) }}"
+
+- name: Determine SSH key path for SSH config update
+  ansible.builtin.command: python3 {{ playbook_dir }}/../scripts/nixos_ssh_key_name.py --path
+  register: ssh_key_path_for_config
+  changed_when: false
+
+- name: Wait for SSH to be available on VMs
+  ansible.builtin.wait_for:
+    host: "{{ nixos_vm_ips[item] }}"
+    port: 22
+    delay: 10
+    timeout: 300
+  loop: "{{ groups['all'] | reject('equalto', 'localhost') | list }}"
+
+- name: Update SSH config for NixOS VMs
+  ansible.builtin.command: |
+    python3 {{ playbook_dir }}/../scripts/update_ssh_config_nixos.py update \
+      {{ item }} \
+      {{ nixos_vm_ips[item] }} \
+      {{ ansible_cfg_ssh_port }} \
+      kdevops \
+      {{ nixos_ssh_config_file | default(ansible_env.HOME + '/.ssh/config') }} \
+      {{ ssh_key_path_for_config.stdout | trim }} \
+      'NixOS VM'
+  loop: "{{ groups['all'] | reject('equalto', 'localhost') | list }}"
+  when: nixos_update_ssh_config | default(true) | bool
diff --git a/playbooks/templates/nixos/default.nix.j2 b/playbooks/roles/nixos/templates/default.nix.j2
similarity index 100%
rename from playbooks/templates/nixos/default.nix.j2
rename to playbooks/roles/nixos/templates/default.nix.j2
diff --git a/playbooks/templates/nixos/run-vm-wrapper.sh.j2 b/playbooks/roles/nixos/templates/run-vm-wrapper.sh.j2
similarity index 100%
rename from playbooks/templates/nixos/run-vm-wrapper.sh.j2
rename to playbooks/roles/nixos/templates/run-vm-wrapper.sh.j2
diff --git a/playbooks/templates/nixos/vm-libvirt.xml.j2 b/playbooks/roles/nixos/templates/vm-libvirt.xml.j2
similarity index 100%
rename from playbooks/templates/nixos/vm-libvirt.xml.j2
rename to playbooks/roles/nixos/templates/vm-libvirt.xml.j2

-- 
2.53.0