[PATCH v2 4/4] bootlinux: detect 9P configuration changes and trigger power cycle

Daniel Gomez <[email protected]> Mon, 01 Dec 2025 20:14:41 +0100
Newsgroups dev.linux.lists.kdevops
Message-ID <20251201-custom-linux-9p-tree-and-mount-point-v2-4-c720a83cab91@samsung.com>
From: Daniel Gomez <[email protected]>

When kernel tree configuration changes, the 9P filesystem cannot be
reconfigured with simple remounting because QEMU caches the virtio-9p
fsdev path at VM start time. This affects mount point changes and host
path changes where guests see stale cached data from the previous
configuration.

Add detection for both scenarios by checking the current mount point
against target_linux_dir_path and parsing the libvirt domain XML to
compare the fsdev source path against bootlinux_9p_host_path. When
either mismatch is detected, remove the old fstab entry and execute a
full VM power cycle with libvirt domain undefine and redefine to force
QEMU to regenerate its command line with the updated 9P configuration.

The power cycle uses graceful shutdown with a 120-second timeout. If
shutdown fails, the playbook pauses and prompts the user to manually
shut down the VM before continuing. The undefine step tries --nvram
first for UEFI VMs and falls back to plain undefine for legacy BIOS.

Generated-by: Claude AI
Signed-off-by: Daniel Gomez <[email protected]>
---
 playbooks/roles/bootlinux/tasks/main.yml           |  91 ++++++++++++
 playbooks/roles/bootlinux/tasks/power-cycle-vm.yml | 165 +++++++++++++++++++++
 2 files changed, 256 insertions(+)

diff --git a/playbooks/roles/bootlinux/tasks/main.yml b/playbooks/roles/bootlinux/tasks/main.yml
index 8fcb9e47..f1f50efc 100644
--- a/playbooks/roles/bootlinux/tasks/main.yml
+++ b/playbooks/roles/bootlinux/tasks/main.yml
@@ -195,6 +195,97 @@
   when:
     - not workflow_linux_packaged|bool
 
+- name: Check if 9p mount tag is already mounted elsewhere
+  become: true
+  become_flags: "su - -c"
+  become_method: sudo
+  ansible.builtin.shell: "mount | grep '{{ bootlinux_9p_mount_tag }}' | awk '{print $3}'"
+  register: existing_9p_mount
+  changed_when: false
+  failed_when: false
+  tags: ["data_partition", "9p_mount"]
+  when:
+    - bootlinux_9p|bool
+
+- name: Get running VM domain XML to check 9P fsdev path
+  delegate_to: localhost
+  community.libvirt.virt:
+    command: get_xml
+    name: "{{ inventory_hostname }}"
+  register: vm_domain_xml
+  failed_when: false
+  tags: ["data_partition", "9p_mount"]
+  when:
+    - bootlinux_9p|bool
+
+- name: Extract current 9P source path from domain XML
+  ansible.builtin.set_fact:
+    qemu_fsdev_path: >-
+      {{
+        vm_domain_xml.get_xml |
+        default('') |
+        regex_search("<source dir=['\"]([^'\"]+)['\"]", '\1') |
+        default([''], true) |
+        first
+      }}
+  tags: ["data_partition", "9p_mount"]
+  when:
+    - bootlinux_9p|bool
+    - vm_domain_xml.get_xml is defined
+
+- name: Set 9p_needs_power_cycle fact
+  ansible.builtin.set_fact:
+    bootlinux_9p_needs_power_cycle: >-
+      {{
+        (existing_9p_mount.stdout is defined and
+         existing_9p_mount.stdout != "" and
+         existing_9p_mount.stdout != target_linux_dir_path) or
+        (qemu_fsdev_path | default('') != '' and
+         qemu_fsdev_path != bootlinux_9p_host_path)
+      }}
+  tags: ["data_partition", "9p_mount"]
+  when:
+    - bootlinux_9p|bool
+
+- name: Debug 9P path mismatch detection
+  ansible.builtin.debug:
+    msg: |
+      9P Path Check Results:
+      - Guest mount point: {{ existing_9p_mount.stdout | default('not mounted') }}
+      - Expected mount point: {{ target_linux_dir_path }}
+      - Libvirt 9P source path: {{ qemu_fsdev_path | default('not found') }}
+      - Expected host path: {{ bootlinux_9p_host_path }}
+      - Needs power cycle: {{ bootlinux_9p_needs_power_cycle | default(false) }}
+  tags: ["data_partition", "9p_mount"]
+  when:
+    - bootlinux_9p|bool
+  vars:
+    output_verbosity: 1
+
+- name: Remove old 9P mount from fstab before power cycle
+  become: true
+  become_flags: "su - -c"
+  become_method: sudo
+  ansible.posix.mount:
+    name: "{{ existing_9p_mount.stdout }}"
+    src: "{{ bootlinux_9p_mount_tag }}"
+    fstype: "9p"
+    state: "absent"
+  when:
+    - bootlinux_9p|bool
+    - bootlinux_9p_needs_power_cycle | default(false) | bool
+    - existing_9p_mount.stdout is defined
+    - existing_9p_mount.stdout != ""
+  tags: ["data_partition", "9p_mount"]
+
+- name: Power cycle VM if 9p host path changed (QEMU caches fsdev path)
+  ansible.builtin.include_tasks:
+    file: "{{ role_path }}/tasks/power-cycle-vm.yml"
+  when:
+    - bootlinux_9p|bool
+    - bootlinux_9p_needs_power_cycle | default(false) | bool
+  tags: ["data_partition", "9p_mount"]
+
 - name: Mount bootlinux 9p on each target node
   become: true
   become_flags: "su - -c"
diff --git a/playbooks/roles/bootlinux/tasks/power-cycle-vm.yml b/playbooks/roles/bootlinux/tasks/power-cycle-vm.yml
new file mode 100644
index 00000000..b7e4116c
--- /dev/null
+++ b/playbooks/roles/bootlinux/tasks/power-cycle-vm.yml
@@ -0,0 +1,165 @@
+---
+# Power cycle VM to clear 9P cache and reload virtio device configuration
+# This is necessary when the 9P host path changes because:
+# - Simple remount doesn't clear virtio device state
+# - Restart doesn't properly clear 9P caches
+# - Full shutdown + start is required
+
+- name: Shutdown VM to clear 9P cache
+  community.libvirt.virt:
+    name: "{{ ansible_hostname }}"
+    state: shutdown
+    uri: "{{ libvirt_uri }}"
+  delegate_to: localhost
+  become: true
+  become_method: ansible.builtin.sudo
+  become_flags: "su - -c"
+  tags: ["data_partition", "9p_mount", "vm_power_cycle"]
+
+- name: Wait for SSH to become unreachable (VM shutting down)
+  ansible.builtin.wait_for:
+    host: "{{ inventory_hostname }}"
+    port: 22
+    state: stopped
+    timeout: 120
+    delay: 2
+  delegate_to: localhost
+  register: shutdown_wait_result
+  ignore_errors: true
+  tags: ["data_partition", "9p_mount", "vm_power_cycle"]
+
+- name: Give VM a moment to fully transition to shut off state
+  ansible.builtin.pause:
+    seconds: 3
+  delegate_to: localhost
+  tags: ["data_partition", "9p_mount", "vm_power_cycle"]
+
+- name: Check if VM actually shut down
+  community.libvirt.virt:
+    name: "{{ ansible_hostname }}"
+    command: status
+    uri: "{{ libvirt_uri }}"
+  register: vm_state_after_shutdown
+  delegate_to: localhost
+  become: true
+  become_method: ansible.builtin.sudo
+  become_flags: "su - -c"
+  tags: ["data_partition", "9p_mount", "vm_power_cycle"]
+
+- name: Debug VM state after shutdown check
+  delegate_to: localhost
+  ansible.builtin.debug:
+    msg: "VM {{ ansible_hostname }} state after shutdown: {{ vm_state_after_shutdown.status }}"
+  tags: ["data_partition", "9p_mount", "vm_power_cycle"]
+
+- name: Notify user if graceful shutdown failed
+  delegate_to: localhost
+  ansible.builtin.pause:
+    prompt: |
+
+      ============================================================
+      WARNING: VM {{ ansible_hostname }} did not shut down gracefully
+      ============================================================
+
+      Current VM state: {{ vm_state_after_shutdown.status }}
+
+      The VM needs to be shut down to reload the 9P filesystem configuration.
+      Graceful shutdown (virsh shutdown) timed out after 120 seconds.
+
+      Please manually shut down the VM using one of these methods:
+
+      1. PREFERRED - Investigate why shutdown failed:
+         sudo virsh console {{ ansible_hostname }}
+         (Check if system is hung, then try clean shutdown from inside VM)
+
+      2. Force shutdown (may cause data loss):
+         sudo virsh destroy {{ ansible_hostname }}
+
+      3. Try graceful shutdown again:
+         sudo virsh shutdown {{ ansible_hostname }}
+         (Wait and check: sudo virsh domstate {{ ansible_hostname }})
+
+      After the VM shows "shut off" state, press ENTER to continue.
+      The playbook will then redefine and restart the VM with the new 9P path.
+
+      To check VM state: sudo virsh domstate {{ ansible_hostname }}
+
+      ============================================================
+  when:
+    - vm_state_after_shutdown.status != "shutdown"
+  tags: ["data_partition", "9p_mount", "vm_power_cycle"]
+
+- name: Verify VM is shut off before continuing
+  community.libvirt.virt:
+    name: "{{ ansible_hostname }}"
+    command: status
+    uri: "{{ libvirt_uri }}"
+  register: vm_state_verified
+  failed_when: vm_state_verified.status != "shutdown"
+  delegate_to: localhost
+  become: true
+  become_method: ansible.builtin.sudo
+  become_flags: "su - -c"
+  tags: ["data_partition", "9p_mount", "vm_power_cycle"]
+
+# Try undefine with --nvram first (for UEFI VMs), fall back to plain undefine
+- name: Undefine VM to clear cached device configuration (UEFI)
+  community.libvirt.virt:
+    name: "{{ ansible_hostname }}"
+    command: undefine
+    flags: "--nvram"
+    uri: "{{ libvirt_uri }}"
+  delegate_to: localhost
+  become: true
+  become_method: ansible.builtin.sudo
+  become_flags: "su - -c"
+  register: undefine_nvram_result
+  failed_when: false
+  tags: ["data_partition", "9p_mount", "vm_power_cycle"]
+
+- name: Undefine VM to clear cached device configuration (non-UEFI fallback)
+  community.libvirt.virt:
+    name: "{{ ansible_hostname }}"
+    command: undefine
+    uri: "{{ libvirt_uri }}"
+  delegate_to: localhost
+  become: true
+  become_method: ansible.builtin.sudo
+  become_flags: "su - -c"
+  when: undefine_nvram_result.failed | default(false)
+  failed_when: false
+  tags: ["data_partition", "9p_mount", "vm_power_cycle"]
+
+- name: Redefine VM with updated 9P host path from XML
+  community.libvirt.virt:
+    command: define
+    xml: "{{ lookup('file', topdir_path + '/guestfs/' + ansible_hostname + '/' + ansible_hostname + '.xml') }}"
+    uri: "{{ libvirt_uri }}"
+  delegate_to: localhost
+  become: true
+  become_method: ansible.builtin.sudo
+  become_flags: "su - -c"
+  failed_when: false
+  tags: ["data_partition", "9p_mount", "vm_power_cycle"]
+
+- name: Start VM with fresh 9P device configuration
+  community.libvirt.virt:
+    name: "{{ ansible_hostname }}"
+    state: running
+    uri: "{{ libvirt_uri }}"
+  delegate_to: localhost
+  become: true
+  become_method: ansible.builtin.sudo
+  become_flags: "su - -c"
+  failed_when: false
+  tags: ["data_partition", "9p_mount", "vm_power_cycle"]
+
+- name: Wait for VM to become reachable after restart
+  ansible.builtin.wait_for_connection:
+    timeout: 120
+    delay: 5
+  tags: ["data_partition", "9p_mount", "vm_power_cycle"]
+
+- name: Gather facts after VM restart
+  ansible.builtin.setup:
+  tags: ["data_partition", "9p_mount", "vm_power_cycle"]

-- 
2.52.0