[PATCH 6/8] nixos: collapse playbook into a tag-gated role entry

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

Same shape as terraform.yml and guestfs.yml: one play, hosts: all
with connection: local so every task executes on the controller
regardless of the inventory host it is iterating over, role-level
dispatch via tag-gated include_tasks in main.yml, and
localhost-only phases wrapped in a single delegate_to + run_once
block per task file so they execute exactly once even when the
play runs across multiple hosts. libvirt_provision keeps its
per-inventory-host iteration and sits behind a when-filter in
main.yml so it skips the localhost iteration. The eight-play
structure duplicated hosts and vars_files across phases and
diverged from the rest of the codebase; collapsing it makes the
invocation model consistent with every other bringup role.

Generated-by: Claude AI
Signed-off-by: Daniel Gomez <[email protected]>
(cherry picked from commit a16cb8cf13b206b1bb059ee98af7797ffbe3476b)
---
 playbooks/nixos.yml                              | 102 +----------------
 playbooks/roles/nixos/tasks/console.yml          |   2 +
 playbooks/roles/nixos/tasks/destroy.yml          | 138 ++++++++++++-----------
 playbooks/roles/nixos/tasks/generate_configs.yml | 108 +++++++++---------
 playbooks/roles/nixos/tasks/install_deps.yml     |  80 ++++++-------
 playbooks/roles/nixos/tasks/libvirt_build.yml    |  80 ++++++-------
 playbooks/roles/nixos/tasks/libvirt_network.yml  |  28 +++--
 playbooks/roles/nixos/tasks/main.yml             |  63 +++++++++++
 playbooks/roles/nixos/tasks/ssh_access.yml       |  90 ++++++++-------
 9 files changed, 342 insertions(+), 349 deletions(-)

diff --git a/playbooks/nixos.yml b/playbooks/nixos.yml
index c1adebbf..a51cf345 100644
--- a/playbooks/nixos.yml
+++ b/playbooks/nixos.yml
@@ -1,102 +1,8 @@
 ---
-# 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: Run install_deps tasks
-      ansible.builtin.import_role:
-        name: nixos
-        tasks_from: install_deps
-
-- name: Generate NixOS configurations
-  hosts: localhost
-  gather_facts: true
-  vars_files:
-    - "{{ playbook_dir }}/../extra_vars.yaml"
-  tags: generate-configs
-  tasks:
-    - name: Run generate_configs tasks
-      ansible.builtin.import_role:
-        name: nixos
-        tasks_from: generate_configs
-
-- name: Build and deploy NixOS VMs
-  hosts: localhost
-  gather_facts: true
-  vars_files:
-    - "{{ playbook_dir }}/../extra_vars.yaml"
-  tags: build-vms
-  tasks:
-    - name: Run libvirt_build tasks
-      ansible.builtin.import_role:
-        name: nixos
-        tasks_from: libvirt_build
-
-- name: Ensure default libvirt network is available
-  hosts: localhost
-  gather_facts: true
-  vars_files:
-    - "{{ playbook_dir }}/../extra_vars.yaml"
-  tags: bringup
-  tasks:
-    - name: Run libvirt_network tasks
-      ansible.builtin.import_role:
-        name: nixos
-        tasks_from: libvirt_network
-
-- name: Provision NixOS VMs with libvirt
-  hosts: baseline,dev
+- name: Manage NixOS VMs with libvirt
+  hosts: all
   gather_facts: false
   vars_files:
     - "{{ playbook_dir }}/../extra_vars.yaml"
-  tags: bringup
-  tasks:
-    - name: Run libvirt_provision tasks
-      ansible.builtin.import_role:
-        name: nixos
-        tasks_from: libvirt_provision
-
-- name: Setup SSH access for NixOS VMs
-  hosts: localhost
-  gather_facts: true
-  vars_files:
-    - "{{ playbook_dir }}/../extra_vars.yaml"
-  tags: bringup
-  tasks:
-    - name: Run ssh_access tasks
-      ansible.builtin.import_role:
-        name: nixos
-        tasks_from: ssh_access
-
-- name: Show VM access information
-  hosts: localhost
-  gather_facts: false
-  vars_files:
-    - "{{ playbook_dir }}/../extra_vars.yaml"
-  tags: console
-  tasks:
-    - name: Run console tasks
-      ansible.builtin.import_role:
-        name: nixos
-        tasks_from: console
-
-- name: Destroy NixOS VMs
-  hosts: localhost
-  gather_facts: true
-  vars_files:
-    - "{{ playbook_dir }}/../extra_vars.yaml"
-  tags: [destroy, never]
-  tasks:
-    - name: Run destroy tasks
-      ansible.builtin.import_role:
-        name: nixos
-        tasks_from: destroy
+  roles:
+    - role: nixos
diff --git a/playbooks/roles/nixos/tasks/console.yml b/playbooks/roles/nixos/tasks/console.yml
index 149bacd9..516028be 100644
--- a/playbooks/roles/nixos/tasks/console.yml
+++ b/playbooks/roles/nixos/tasks/console.yml
@@ -4,6 +4,8 @@
 # Final status banner printed at the end of a successful bringup.
 
 - name: Display VM access information
+  delegate_to: localhost
+  run_once: true
   ansible.builtin.debug:
     msg: |
       NixOS VMs are running and accessible via libvirt.
diff --git a/playbooks/roles/nixos/tasks/destroy.yml b/playbooks/roles/nixos/tasks/destroy.yml
index 794f7bb5..8943e197 100644
--- a/playbooks/roles/nixos/tasks/destroy.yml
+++ b/playbooks/roles/nixos/tasks/destroy.yml
@@ -5,81 +5,85 @@
 # 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: Localhost destroy
+  delegate_to: localhost
+  run_once: true
+  block:
+    - 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: 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: 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 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 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 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 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: 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: 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
+    - 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)
+        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"
+        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
index 9c6d49a0..22d7df3a 100644
--- a/playbooks/roles/nixos/tasks/generate_configs.yml
+++ b/playbooks/roles/nixos/tasks/generate_configs.yml
@@ -7,24 +7,26 @@
 # 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: Localhost generate-configs
+  delegate_to: localhost
+  run_once: true
+  block:
+    - 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: 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
@@ -51,45 +53,45 @@
       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: 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: 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: Debug SSH key path
+      ansible.builtin.debug:
+        msg: "Using SSH key: {{ 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: 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: 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 }}"
+    - 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
index 2b8159aa..98a517ea 100644
--- a/playbooks/roles/nixos/tasks/install_deps.yml
+++ b/playbooks/roles/nixos/tasks/install_deps.yml
@@ -5,45 +5,49 @@
 # 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
+- name: Localhost install-deps
+  delegate_to: localhost
+  run_once: true
   block:
-    - name: Download nix installer
-      ansible.builtin.get_url:
-        url: https://nixos.org/nix/install
-        dest: /tmp/install-nix.sh
-        mode: '0755'
+    - 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: 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
+      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: 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
index f7f0ba00..23a7c317 100644
--- a/playbooks/roles/nixos/tasks/libvirt_build.yml
+++ b/playbooks/roles/nixos/tasks/libvirt_build.yml
@@ -6,43 +6,47 @@
 # 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: Localhost libvirt build
+  delegate_to: localhost
+  run_once: true
+  block:
+    - 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: 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) }}"
+    - 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
index d9262467..a354d8f6 100644
--- a/playbooks/roles/nixos/tasks/libvirt_network.yml
+++ b/playbooks/roles/nixos/tasks/libvirt_network.yml
@@ -4,16 +4,20 @@
 # 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: Localhost libvirt network
+  delegate_to: localhost
+  run_once: true
+  block:
+    - 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: 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/main.yml b/playbooks/roles/nixos/tasks/main.yml
new file mode 100644
index 00000000..1ebf63e6
--- /dev/null
+++ b/playbooks/roles/nixos/tasks/main.yml
@@ -0,0 +1,63 @@
+---
+# nixos role entry point. Orchestrates install-deps,
+# generate-configs, libvirt build, libvirt network, per-VM libvirt
+# provision, ssh_access, console, and destroy via tag-gated
+# include_tasks. Localhost-only phases run with delegate_to +
+# run_once inside their task files so --limit localhost is not
+# required from the caller.
+
+- name: Gather localhost facts
+  ansible.builtin.setup:
+  delegate_to: localhost
+  run_once: true
+  tags: always
+
+- name: Include install_deps tasks
+  ansible.builtin.import_tasks:
+    file: "{{ role_path }}/tasks/install_deps.yml"
+  tags:
+    - install-deps
+
+- name: Include generate_configs tasks
+  ansible.builtin.import_tasks:
+    file: "{{ role_path }}/tasks/generate_configs.yml"
+  tags:
+    - generate-configs
+
+- name: Include libvirt_build tasks
+  ansible.builtin.import_tasks:
+    file: "{{ role_path }}/tasks/libvirt_build.yml"
+  tags:
+    - build-vms
+
+- name: Include libvirt_network tasks
+  ansible.builtin.import_tasks:
+    file: "{{ role_path }}/tasks/libvirt_network.yml"
+  tags:
+    - bringup
+
+- name: Include libvirt_provision tasks
+  ansible.builtin.import_tasks:
+    file: "{{ role_path }}/tasks/libvirt_provision.yml"
+  tags:
+    - bringup
+  when: inventory_hostname != 'localhost'
+
+- name: Include ssh_access tasks
+  ansible.builtin.import_tasks:
+    file: "{{ role_path }}/tasks/ssh_access.yml"
+  tags:
+    - bringup
+
+- name: Include console tasks
+  ansible.builtin.import_tasks:
+    file: "{{ role_path }}/tasks/console.yml"
+  tags:
+    - console
+
+- name: Include destroy tasks
+  ansible.builtin.import_tasks:
+    file: "{{ role_path }}/tasks/destroy.yml"
+  tags:
+    - destroy
+    - never
diff --git a/playbooks/roles/nixos/tasks/ssh_access.yml b/playbooks/roles/nixos/tasks/ssh_access.yml
index 1f2fa67d..43cf3a37 100644
--- a/playbooks/roles/nixos/tasks/ssh_access.yml
+++ b/playbooks/roles/nixos/tasks/ssh_access.yml
@@ -7,50 +7,54 @@
 # 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: Localhost ssh_access
+  delegate_to: localhost
+  run_once: true
+  block:
+    - 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: 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: 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: 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: 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

-- 
2.53.0