[PATCH 05/12] workflows/linux: add controller build mode

Daniel Gomez <[email protected]> Thu, 23 Apr 2026 13:30:57 +0200
Newsgroups dev.linux.lists.kdevops
Message-ID <20260423-kdevops-series-c-qemu-system-units-v1-5-b7bab3225a36@samsung.com>
From: Daniel Gomez <[email protected]>

Build modes 9p, builder, and targets all assume the kernel is
consumed by a guest that runs its own bootloader or imports
modules at runtime. A qsu backend booting an imageless NixOS
guest has neither: it needs the kernel image as a plain file on
the controller (to pass as -kernel) and the modules tree as a
directory on the controller (to share into the guest over
virtiofs), both produced before any VM starts.

Add a fourth mode that clones, builds with O=<builddir>, and
runs make install + make modules_install into a local destdir
under KDEVOPS_CONTROLLER_DATA_PATH. The role skips the target-
host phases (create_data_partition, reboot, post-build uname
check) and ends the play as soon as the controller finishes
building, since there is no target-side rollout. The workflow
Makefile narrows the bootlinux limit to localhost under
controller mode so gather_facts does not try to reach guests
that do not exist yet.

Extend the resolved build environment with LOCALVERSION set to
target_linux_localversion (empty by default). With
CONFIG_LOCALVERSION_AUTO=n, scripts/setlocalversion still
appends a short scm marker when LOCALVERSION is unset, producing
releases like 7.0.0+; an empty LOCALVERSION silences the marker
and keeps the release stable across rebuilds so downstream qsu
env files and module tree paths do not churn.

Invalidate the rebuild cache on .config content changes: compare
SHA-256 of builddir/.config against the config-<release>
snapshot make install leaves next to vmlinuz, and rebuild when
they differ. A release-only probe would miss =m->=y flips and
other pure content edits to the seed config.

Default to this mode under QEMU_SYSTEM_UNITS so a user selecting
the qsu backend does not have to re-derive the build-mode
decision from scratch.

Generated-by: Claude AI
Signed-off-by: Daniel Gomez <[email protected]>
---
 .../roles/bootlinux/tasks/build/controller.yml     | 207 +++++++++++++++++++++
 playbooks/roles/bootlinux/tasks/main.yml           |  12 ++
 workflows/linux/Kconfig                            |  55 ++++++
 workflows/linux/Makefile                           |   7 +
 4 files changed, 281 insertions(+)

diff --git a/playbooks/roles/bootlinux/tasks/build/controller.yml b/playbooks/roles/bootlinux/tasks/build/controller.yml
new file mode 100644
index 00000000..a3d5999f
--- /dev/null
+++ b/playbooks/roles/bootlinux/tasks/build/controller.yml
@@ -0,0 +1,207 @@
+---
+# SPDX-License-Identifier: copyleft-next-0.3.1
+#
+# Out-of-tree kernel build with make install + make modules_install
+# on the Ansible controller. Leaves a vmlinuz at
+# <destdir>/boot/vmlinuz-<release> and a modules tree under
+# <destdir>/lib/modules/<release>/ that a downstream bringup
+# backend consumes in place (for example through -kernel and a
+# virtiofs share of the modules directory).
+#
+# Idempotency strategy: always clone, copy the .config, and run
+# olddefconfig so the build tree reflects the intended config,
+# then ask make what the kernel release for that config would be
+# and check whether the matching vmlinuz + modules tree already
+# live under destdir. If they do, skip make + make install + make
+# modules_install; otherwise run the full pipeline. This way a
+# seed .config swap (for example CONFIG_LOCALVERSION_AUTO flip, a
+# new CONFIG_LOCALVERSION, a new git HEAD) invalidates the cache
+# on its own, without asking the operator to rm destdir first.
+
+- name: Compose the controller-mode build environment
+  ansible.builtin.set_fact:
+    bootlinux_controller_build_environment: "{{ bootlinux_build_environment | combine({'LOCALVERSION': target_linux_localversion | default('', true)}) }}"
+  run_once: true
+  delegate_to: localhost
+
+- name: Install dependencies to build the Linux kernel
+  delegate_to: localhost
+  run_once: true
+  ansible.builtin.import_tasks:
+    file: install-deps/main.yml
+
+- name: Ensure the controller build and install directories exist
+  ansible.builtin.file:
+    path: "{{ item }}"
+    state: directory
+    mode: '0755'
+  loop:
+    - "{{ bootlinux_controller_builddir }}"
+    - "{{ bootlinux_controller_destdir }}"
+  run_once: true
+  delegate_to: localhost
+
+- name: Git clone {{ target_linux_tree }} on the control node
+  ansible.builtin.git:
+    repo: "{{ target_linux_git }}"
+    dest: "{{ bootlinux_controller_tree_path }}"
+    version: "{{ target_linux_ref }}"
+    depth: "{{ target_linux_shallow_depth | default(omit) }}"
+    force: "{{ target_linux_force_checkout | default(false) }}"
+  run_once: true
+  delegate_to: localhost
+  tags: ["clone"]
+
+- name: Copy configuration for {{ target_linux_tree }} to the build directory
+  ansible.builtin.copy:
+    src: "{{ role_path }}/templates/{{ linux_config }}"
+    dest: "{{ bootlinux_controller_builddir }}/.config"
+    mode: '0644'
+  run_once: true
+  delegate_to: localhost
+
+- name: Resolve .config against the tree with olddefconfig
+  community.general.make:
+    chdir: "{{ bootlinux_controller_tree_path }}"
+    target: olddefconfig
+    params:
+      O: "{{ bootlinux_controller_builddir }}"
+  environment: "{{ bootlinux_controller_build_environment }}"
+  run_once: true
+  delegate_to: localhost
+  tags: ["build-linux"]
+
+- name: Sync include/config/auto.conf to the refreshed .config
+  community.general.make:
+    chdir: "{{ bootlinux_controller_tree_path }}"
+    target: syncconfig
+    params:
+      O: "{{ bootlinux_controller_builddir }}"
+  environment: "{{ bootlinux_controller_build_environment }}"
+  run_once: true
+  delegate_to: localhost
+  tags: ["build-linux"]
+
+- name: Compute the expected kernel release from the resolved build tree
+  community.general.make:
+    chdir: "{{ bootlinux_controller_tree_path }}"
+    target: kernelrelease
+    params:
+      O: "{{ bootlinux_controller_builddir }}"
+  environment: "{{ bootlinux_controller_build_environment }}"
+  register: bootlinux_controller_kernelrelease_make
+  changed_when: false
+  run_once: true
+  delegate_to: localhost
+
+- name: Record the expected kernel release
+  ansible.builtin.set_fact:
+    bootlinux_controller_kernel_release: "{{ bootlinux_controller_kernelrelease_make.stdout_lines[-1] }}"
+  run_once: true
+  delegate_to: localhost
+
+- name: Probe destdir for a kernel image matching the expected release
+  ansible.builtin.stat:
+    path: "{{ bootlinux_controller_destdir }}/boot/vmlinuz-{{ bootlinux_controller_kernel_release }}"
+  register: bootlinux_controller_vmlinuz_stat
+  run_once: true
+  delegate_to: localhost
+
+- name: Probe destdir for the modules tree matching the expected release
+  ansible.builtin.stat:
+    path: "{{ bootlinux_controller_destdir }}/lib/modules/{{ bootlinux_controller_kernel_release }}/modules.dep"
+  register: bootlinux_controller_modules_dep
+  run_once: true
+  delegate_to: localhost
+
+- name: Checksum the resolved build .config
+  ansible.builtin.stat:
+    path: "{{ bootlinux_controller_builddir }}/.config"
+    checksum_algorithm: sha256
+  register: bootlinux_controller_builddir_config_stat
+  run_once: true
+  delegate_to: localhost
+
+- name: Checksum the installed config-<release> snapshot
+  ansible.builtin.stat:
+    path: "{{ bootlinux_controller_destdir }}/boot/config-{{ bootlinux_controller_kernel_release }}"
+    checksum_algorithm: sha256
+  register: bootlinux_controller_destdir_config_stat
+  run_once: true
+  delegate_to: localhost
+
+- name: Flag the controller build as already cached
+  ansible.builtin.set_fact:
+    bootlinux_controller_already_built: >-
+      {{ bootlinux_controller_vmlinuz_stat.stat.exists
+         and bootlinux_controller_modules_dep.stat.exists
+         and bootlinux_controller_destdir_config_stat.stat.exists
+         and bootlinux_controller_builddir_config_stat.stat.checksum ==
+             bootlinux_controller_destdir_config_stat.stat.checksum }}
+  run_once: true
+  delegate_to: localhost
+
+- name: Report the cached controller build we will reuse
+  ansible.builtin.debug:
+    msg: "Reusing existing controller build: vmlinuz-{{ bootlinux_controller_kernel_release }}"
+  when:
+    - bootlinux_controller_already_built | bool
+  run_once: true
+  delegate_to: localhost
+
+- name: Get nproc on the control node
+  ansible.builtin.command: nproc
+  register: nproc_controller
+  changed_when: false
+  run_once: true
+  delegate_to: localhost
+  tags: ["build-linux"]
+  when:
+    - not bootlinux_controller_already_built | bool
+
+- name: Build {{ target_linux_tree }} on the control node using {{ nproc_controller.stdout | default('nproc') }} threads
+  community.general.make:
+    jobs: "{{ nproc_controller.stdout }}"
+    chdir: "{{ bootlinux_controller_tree_path }}"
+    params: "{{ bootlinux_make_params | combine({'O': bootlinux_controller_builddir}) }}"
+  environment: "{{ bootlinux_controller_build_environment }}"
+  run_once: true
+  delegate_to: localhost
+  tags: ["build-linux"]
+  when:
+    - not bootlinux_controller_already_built | bool
+
+- name: Install the kernel image into <destdir>/boot
+  community.general.make:
+    chdir: "{{ bootlinux_controller_tree_path }}"
+    target: install
+    params:
+      O: "{{ bootlinux_controller_builddir }}"
+      INSTALL_PATH: "{{ bootlinux_controller_destdir }}/boot"
+  environment: "{{ bootlinux_controller_build_environment }}"
+  run_once: true
+  delegate_to: localhost
+  tags: ["install-linux"]
+  when:
+    - not bootlinux_controller_already_built | bool
+
+- name: Install kernel modules into <destdir>/lib/modules
+  community.general.make:
+    chdir: "{{ bootlinux_controller_tree_path }}"
+    target: modules_install
+    params:
+      O: "{{ bootlinux_controller_builddir }}"
+      INSTALL_MOD_PATH: "{{ bootlinux_controller_destdir }}"
+  environment: "{{ bootlinux_controller_build_environment }}"
+  run_once: true
+  delegate_to: localhost
+  tags: ["install-linux"]
+  when:
+    - not bootlinux_controller_already_built | bool
+
+- name: Publish controller-install facts for downstream roles
+  ansible.builtin.set_fact:
+    bootlinux_controller_kernel_image: "{{ bootlinux_controller_destdir }}/boot/vmlinuz-{{ bootlinux_controller_kernel_release }}"
+    bootlinux_controller_modules_dir: "{{ bootlinux_controller_destdir }}/lib/modules/{{ bootlinux_controller_kernel_release }}"
+  run_once: true
+  delegate_to: localhost
diff --git a/playbooks/roles/bootlinux/tasks/main.yml b/playbooks/roles/bootlinux/tasks/main.yml
index da42612e..7700fe8e 100644
--- a/playbooks/roles/bootlinux/tasks/main.yml
+++ b/playbooks/roles/bootlinux/tasks/main.yml
@@ -194,6 +194,7 @@
     name: create_data_partition
   when:
     - not workflow_linux_packaged|bool
+    - not bootlinux_controller|default(false)|bool
 
 - name: Mount bootlinux 9p on each target node
   become: true
@@ -243,6 +244,17 @@
   when:
     - bootlinux_builder|bool
 
+- name: Build the Linux kernel on the controller with destdir install
+  ansible.builtin.include_tasks:
+    file: "{{ role_path }}/tasks/build/controller.yml"
+  when:
+    - bootlinux_controller|bool
+
+- name: End the play after controller mode has built and installed to destdir
+  ansible.builtin.meta: end_play
+  when:
+    - bootlinux_controller|bool
+
 - name: Run uname before
   ansible.builtin.command: "uname -r"
   register: uname_cmd_before
diff --git a/workflows/linux/Kconfig b/workflows/linux/Kconfig
index b86e73d1..54910203 100644
--- a/workflows/linux/Kconfig
+++ b/workflows/linux/Kconfig
@@ -36,6 +36,7 @@ endif # HAVE_SUPPORTS_PURE_IOMAP
 
 choice
 	prompt "Kernel build location"
+	default BOOTLINUX_CONTROLLER if QEMU_SYSTEM_UNITS
 	default BOOTLINUX_TARGETS if !LIBVIRT || GUESTFS_LACKS_9P
 	default BOOTLINUX_9P if LIBVIRT && !GUESTFS_LACKS_9P
 	help
@@ -91,6 +92,25 @@ config BOOTLINUX_BUILDER
 	  This choice is best when the test runners are resource-
 	  limited or vastly different than the controller host.
 
+config BOOTLINUX_CONTROLLER
+	bool "Controller node (destdir install)"
+	output yaml
+	help
+	  Choosing this option clones and builds the test kernel on
+	  the Ansible controller node using an out-of-tree build
+	  directory, then runs make install and make modules_install
+	  into a local destdir on the controller. The kernel image
+	  and modules tree are not copied to any target node; a
+	  bringup backend that boots with an external kernel (for
+	  example qemu-system-units with the nixos-qemu imageless
+	  module) consumes them in place, typically by passing the
+	  kernel image as -kernel and sharing the modules tree with
+	  the guest over virtiofs.
+
+	  This choice is best when the target node runs with an
+	  external kernel supplied by the controller rather than an
+	  in-guest bootloader.
+
 endchoice
 
 if BOOTLINUX_9P
@@ -151,6 +171,41 @@ endmenu
 
 endif # BOOTLINUX_9P
 
+if BOOTLINUX_CONTROLLER
+
+config BOOTLINUX_CONTROLLER_TREE_PATH
+	string "Linux source tree path on the controller"
+	output yaml
+	default "{{ kdevops_controller_data_path }}/linux"
+	help
+	  Absolute path on the control node where the Linux source
+	  tree is cloned and made available to make. Defaults to a
+	  linux/ subdirectory of KDEVOPS_CONTROLLER_DATA_PATH so it
+	  sits alongside the other controller-side artefacts
+	  kdevops produces (build/, destdir/, and future per-workflow
+	  scratch directories).
+
+config BOOTLINUX_CONTROLLER_BUILDDIR
+	string "Out-of-tree build directory"
+	output yaml
+	default "{{ kdevops_controller_data_path }}/linux-build"
+	help
+	  Absolute path to the kernel out-of-tree build directory,
+	  passed to make as O=. Defaults to a sibling of the linux
+	  source tree under KDEVOPS_CONTROLLER_DATA_PATH.
+
+config BOOTLINUX_CONTROLLER_DESTDIR
+	string "Install destdir"
+	output yaml
+	default "{{ kdevops_controller_data_path }}/linux-destdir"
+	help
+	  Absolute path where make install and make modules_install
+	  place the kernel image and modules tree. Consumers locate
+	  the kernel at <destdir>/boot/vmlinuz-<release> and the
+	  matching module tree at <destdir>/lib/modules/<release>.
+
+endif # BOOTLINUX_CONTROLLER
+
 choice
 	prompt "Compiler to use to build the kernel"
 	default BOOTLINUX_COMPILER_GCC
diff --git a/workflows/linux/Makefile b/workflows/linux/Makefile
index a6b8f2ea..6198af01 100644
--- a/workflows/linux/Makefile
+++ b/workflows/linux/Makefile
@@ -79,6 +79,13 @@ ifeq (y,$(CONFIG_KDEVOPS_SETUP_NFSD))
 BOOTLINUX_LIMIT := baseline:dev:nfsd
 endif
 
+# Controller mode builds and installs on the control node before any
+# target VM exists, so it must run against localhost instead of reaching
+# baseline:dev guests that have no sshd yet.
+ifeq (y,$(CONFIG_BOOTLINUX_CONTROLLER))
+BOOTLINUX_LIMIT := localhost
+endif
+
 PHONY +=  linux-help-menu
 linux-help-menu:
 	@echo "Linux git kernel development options"

-- 
2.53.0