[PATCH v2 03/10] bootlinux: add direct-boot build path

Daniel Gomez <[email protected]> Fri, 12 Jun 2026 12:55:15 +0200
Newsgroups dev.linux.lists.kdevops
Message-ID <[email protected]>
From: Daniel Gomez <[email protected]>

Build the kernel out-of-tree on the controller into a destdir and
stage the resulting image plus modules for backends that boot via
QEMU's -kernel argument. Lets a guest run a kdevops-built kernel
without an in-guest distro toolchain.

The image is copied straight into the destdir rather than via `make
install`: the kernel's scripts/install.sh searches for an
installkernel hook and on most distros finds /sbin/installkernel,
which rebuilds the initramfs and runs depmod -- side effects the
controller does not want.

Generated-by: Claude AI
Signed-off-by: Daniel Gomez <[email protected]>
---
 .gitignore                                         |   5 +
 MAINTAINERS                                        |   8 +
 kconfigs/Kconfig.kdevops                           |  15 ++
 playbooks/bootlinux.yml                            |  25 +++
 .../roles/bootlinux/tasks/build/direct-boot.yml    | 207 +++++++++++++++++++++
 playbooks/roles/bootlinux/tasks/config.yml         |   1 +
 workflows/linux/Kconfig                            |  69 +++++++
 workflows/linux/Makefile                           |  12 +-
 8 files changed, 341 insertions(+), 1 deletion(-)

diff --git a/.gitignore b/.gitignore
index 89c2898e..396872d4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -111,6 +111,11 @@ archive/
 # NixOS generated files
 nixos/generated/
 
+# Controller-side data path (KDEVOPS_CONTROLLER_DATA_PATH default).
+# Local kernel checkouts, out-of-tree build trees, install destdirs,
+# workflow scratch space.
+data/
+
 # Dyanmic cloud kconfig files
 terraform/datacrunch/kconfigs/Kconfig.compute.generated
 terraform/datacrunch/kconfigs/Kconfig.images.generated
diff --git a/MAINTAINERS b/MAINTAINERS
index 65bf5716..5b9a1f23 100644
--- a/MAINTAINERS
+++ b/MAINTAINERS
@@ -79,6 +79,14 @@ F:	playbooks/roles/ansible_cfg
 F:	scripts/ansible_av.py
 F:	scripts/ansible.Makefile
 
+BOOTLINUX DIRECT BOOT
+M:	Daniel Gomez <[email protected]>
+L:	[email protected]
+S:	Maintained
+T:	git https://github.com/linux-kdevops/kdevops.git
+F:	playbooks/roles/bootlinux/tasks/build/direct-boot.yml
+N:	BOOTLINUX_DIRECT_BOOT
+
 KDEVOPS CI
 M:	Daniel Gomez <[email protected]>
 L:	[email protected]
diff --git a/kconfigs/Kconfig.kdevops b/kconfigs/Kconfig.kdevops
index 4872fdc3..fee93f68 100644
--- a/kconfigs/Kconfig.kdevops
+++ b/kconfigs/Kconfig.kdevops
@@ -78,6 +78,21 @@ config KDEVOPS_CUSTOM_SSH_KEXALGORITHMS
 	  Some distributions, such as older distributions, may require a custom
 	  ssh configuration entry for the KexAlgorithms parameter.
 
+config KDEVOPS_CONTROLLER_DATA_PATH
+	string "Controller-side data path"
+	output yaml
+	default "$(TOPDIR_PATH)/data"
+	help
+	  Absolute path on the control node where kdevops places
+	  controller-side artefacts: source trees for local builds
+	  (bootlinux controller mode clones Linux under here),
+	  out-of-tree build directories, install destdirs, and
+	  workflow scratch space. This is the controller-side parallel
+	  of data_path, which is the target-guest view of the same
+	  concept. Defaults to a data/ sibling of the kdevops checkout
+	  so kdevops does not assume a distro-wide /data mount on the
+	  control host.
+
 config KDEVOPS_MAKE_VERBOSE
 	bool "Enable verbose make output (V=1)"
 	default n
diff --git a/playbooks/bootlinux.yml b/playbooks/bootlinux.yml
index b2844e4d..35d53882 100644
--- a/playbooks/bootlinux.yml
+++ b/playbooks/bootlinux.yml
@@ -1,5 +1,30 @@
 ---
 # SPDX-License-Identifier: copyleft-next-0.3.1
+- name: Bootlinux — controller-side preparation
+  hosts: localhost
+  connection: local
+  gather_facts: true
+  tasks:
+    - name: Import optional extra_args file
+      ansible.builtin.include_vars: "{{ item }}"
+      failed_when: false
+      with_first_found:
+        - files:
+            - "../extra_vars.yml"
+            - "../extra_vars.yaml"
+            - "../extra_vars.json"
+          skip: true
+      tags: [always]
+
+    - name: Build the kernel on the controller into a destdir
+      ansible.builtin.include_role:
+        name: bootlinux
+        tasks_from: build/direct-boot.yml
+        apply:
+          tags: [bootlinux_direct_boot_build]
+      tags: [never, bootlinux_direct_boot_build]
+      when: bootlinux_direct_boot | default(false) | bool
+
 - name: Bootlinux
   hosts: all
   roles:
diff --git a/playbooks/roles/bootlinux/tasks/build/direct-boot.yml b/playbooks/roles/bootlinux/tasks/build/direct-boot.yml
new file mode 100644
index 00000000..39263570
--- /dev/null
+++ b/playbooks/roles/bootlinux/tasks/build/direct-boot.yml
@@ -0,0 +1,207 @@
+---
+# SPDX-License-Identifier: copyleft-next-0.3.1
+#
+# Out-of-tree kernel build into a controller-side destdir for QEMU's
+# -kernel direct boot path. See linuxboot.rst in QEMU's tree.
+
+- name: Compose the direct-boot build environment
+  ansible.builtin.set_fact:
+    bootlinux_direct_boot_build_environment: "{{ bootlinux_build_environment | combine({'LOCALVERSION': target_linux_localversion | default('', true)}) }}"
+  run_once: true
+  delegate_to: localhost
+  tags: [always]
+
+- name: Install kernel build dependencies on the controller
+  ansible.builtin.import_tasks: "{{ role_path }}/tasks/install-deps/main.yml"
+  run_once: true
+  delegate_to: localhost
+
+- name: Wipe the controller build directory when a clean build was requested
+  ansible.builtin.file:
+    path: "{{ bootlinux_direct_boot_builddir }}"
+    state: absent
+  when:
+    - bootlinux_clean_before_build | default(false) | bool
+  run_once: true
+  delegate_to: localhost
+  tags: [build-linux]
+
+- name: Ensure the controller build and install directories exist
+  ansible.builtin.file:
+    path: "{{ item }}"
+    state: directory
+    mode: "0755"
+  loop:
+    - "{{ bootlinux_direct_boot_builddir }}"
+    - "{{ bootlinux_direct_boot_destdir }}"
+    - "{{ bootlinux_direct_boot_destdir }}/boot"
+  run_once: true
+  delegate_to: localhost
+  tags: [always]
+
+# If the tree exists (manual clone, worktree, symlink, prior bringup),
+# build against its current HEAD. No fetch, no checkout.
+- name: Detect a pre-existing kernel tree under bootlinux_direct_boot_tree_path
+  ansible.builtin.stat:
+    path: "{{ bootlinux_direct_boot_tree_path }}/.git"
+  register: bootlinux_direct_boot_tree_git
+  run_once: true
+  delegate_to: localhost
+  tags: [clone, always]
+
+- name: Note that the controller tree is user-managed (skip clone/checkout)
+  ansible.builtin.debug:
+    msg: |
+      {{ bootlinux_direct_boot_tree_path }} already contains a git
+      checkout. kdevops will not clone, fetch, or checkout — building
+      against whatever HEAD is currently in that tree.
+  when:
+    - bootlinux_direct_boot_tree_git.stat.exists | default(false) | bool
+  run_once: true
+  delegate_to: localhost
+  tags: [clone, always]
+
+- name: Git clone kernel on the controller
+  ansible.builtin.git:
+    repo: "{{ target_linux_git }}"
+    dest: "{{ bootlinux_direct_boot_tree_path }}"
+    version: "{{ target_linux_ref }}"
+    depth: "{{ target_linux_shallow_depth | default(omit) }}"
+  when:
+    - not bootlinux_direct_boot_tree_git.stat.exists | default(false) | bool
+  run_once: true
+  delegate_to: localhost
+  tags: [clone]
+
+- name: Select the .config for the controller build
+  ansible.builtin.include_tasks: "{{ role_path }}/tasks/config.yml"
+  tags: [build-linux]
+
+- name: Stage the resolved .config into the out-of-tree build directory
+  ansible.builtin.copy:
+    src: "{{ role_path }}/templates/{{ linux_config }}"
+    dest: "{{ bootlinux_direct_boot_builddir }}/.config"
+    mode: "0644"
+    force: false
+  run_once: true
+  delegate_to: localhost
+  tags: [build-linux]
+
+- name: Resolve .config against the tree with olddefconfig
+  community.general.make:
+    chdir: "{{ bootlinux_direct_boot_tree_path }}"
+    target: olddefconfig
+    params:
+      O: "{{ bootlinux_direct_boot_builddir }}"
+  environment: "{{ bootlinux_direct_boot_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_direct_boot_tree_path }}"
+    target: syncconfig
+    params:
+      O: "{{ bootlinux_direct_boot_builddir }}"
+  environment: "{{ bootlinux_direct_boot_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_direct_boot_tree_path }}"
+    target: kernelrelease
+    params:
+      O: "{{ bootlinux_direct_boot_builddir }}"
+  environment: "{{ bootlinux_direct_boot_build_environment }}"
+  register: bootlinux_direct_boot_kernelrelease_make
+  changed_when: false
+  run_once: true
+  delegate_to: localhost
+  tags: [build-linux]
+
+- name: Record the expected kernel release
+  ansible.builtin.set_fact:
+    bootlinux_direct_boot_kernel_release: "{{ bootlinux_direct_boot_kernelrelease_make.stdout_lines[-1] }}"
+  run_once: true
+  delegate_to: localhost
+  tags: [build-linux]
+
+- name: Get nproc on the controller
+  ansible.builtin.command: nproc
+  register: bootlinux_direct_boot_nproc
+  changed_when: false
+  run_once: true
+  delegate_to: localhost
+  tags: [build-linux]
+
+- name: Build kernel on the controller
+  community.general.make:
+    jobs: "{{ bootlinux_direct_boot_nproc.stdout }}"
+    chdir: "{{ bootlinux_direct_boot_tree_path }}"
+    params: "{{ bootlinux_make_params | combine({'O': bootlinux_direct_boot_builddir}) }}"
+  environment: "{{ bootlinux_direct_boot_build_environment }}"
+  run_once: true
+  delegate_to: localhost
+  tags: [build-linux]
+
+# `make install` runs /sbin/installkernel against the controller's
+# /boot and may need root. Copy the image into the destdir by hand.
+- name: Resolve the built kernel image path
+  community.general.make:
+    chdir: "{{ bootlinux_direct_boot_tree_path }}"
+    target: image_name
+    params:
+      O: "{{ bootlinux_direct_boot_builddir }}"
+  environment: "{{ bootlinux_direct_boot_build_environment }}"
+  register: bootlinux_direct_boot_image_name_make
+  changed_when: false
+  run_once: true
+  delegate_to: localhost
+  tags: [install-linux]
+
+- name: Install the kernel image into <destdir>/boot
+  ansible.builtin.copy:
+    src: "{{ bootlinux_direct_boot_builddir }}/{{ bootlinux_direct_boot_image_name_make.stdout_lines[-1] | trim }}"
+    dest: "{{ bootlinux_direct_boot_destdir }}/boot/vmlinuz-{{ bootlinux_direct_boot_kernel_release }}"
+    mode: "0644"
+    remote_src: true
+  run_once: true
+  delegate_to: localhost
+  tags: [install-linux]
+
+- name: Install kernel modules into <destdir>/lib/modules
+  community.general.make:
+    chdir: "{{ bootlinux_direct_boot_tree_path }}"
+    target: modules_install
+    params:
+      O: "{{ bootlinux_direct_boot_builddir }}"
+      INSTALL_MOD_PATH: "{{ bootlinux_direct_boot_destdir }}"
+  environment: "{{ bootlinux_direct_boot_build_environment }}"
+  run_once: true
+  delegate_to: localhost
+  tags: [install-linux]
+
+# modules_install creates build/ but not source/. Tools that chase
+# include/linux/kconfig.h (bpftrace, perf probe, libbpf-tools, any
+# kbuild module build) need source/. Dangling if the tree isn't
+# reachable from the guest is no worse than the prior state.
+- name: Add canonical "source" symlink alongside "build" in <destdir>/lib/modules/<ver>/
+  ansible.builtin.file:
+    src: "{{ bootlinux_direct_boot_tree_path }}"
+    dest: "{{ bootlinux_direct_boot_destdir }}/lib/modules/{{ bootlinux_direct_boot_kernel_release }}/source"
+    state: link
+    force: true
+  run_once: true
+  delegate_to: localhost
+  tags: [install-linux]
+
+- name: Publish controller-install facts for downstream roles
+  ansible.builtin.set_fact:
+    bootlinux_direct_boot_kernel_image: "{{ bootlinux_direct_boot_destdir }}/boot/vmlinuz-{{ bootlinux_direct_boot_kernel_release }}"
+    bootlinux_direct_boot_modules_dir: "{{ bootlinux_direct_boot_destdir }}/lib/modules/{{ bootlinux_direct_boot_kernel_release }}"
+  run_once: true
+  delegate_to: localhost
+  tags: [install-linux]
diff --git a/playbooks/roles/bootlinux/tasks/config.yml b/playbooks/roles/bootlinux/tasks/config.yml
index 6d892744..a54db514 100644
--- a/playbooks/roles/bootlinux/tasks/config.yml
+++ b/playbooks/roles/bootlinux/tasks/config.yml
@@ -39,6 +39,7 @@
   when:
     - bootlinux_clean_before_build|default(false)|bool
     - not bootlinux_9p|bool
+    - not bootlinux_direct_boot|default(false)|bool
 
 - name: Set the .config file for building the test kernel
   ansible.builtin.set_fact:
diff --git a/workflows/linux/Kconfig b/workflows/linux/Kconfig
index 51dcb0f0..4f8694d9 100644
--- a/workflows/linux/Kconfig
+++ b/workflows/linux/Kconfig
@@ -92,8 +92,77 @@ config BOOTLINUX_BUILDER
 	  This choice is best when the test runners are resource-
 	  limited or vastly different than the controller host.
 
+config BOOTLINUX_DIRECT_BOOT
+	bool "Direct kernel boot (destdir, no in-guest install)"
+	output yaml
+	help
+	  Choosing this option builds the test kernel out-of-tree on
+	  the Ansible controller and installs the image plus modules
+	  into a local destdir. Nothing is copied onto any guest
+	  filesystem; a consumer feeds the kernel to QEMU via the
+	  -kernel direct boot path (QEMU's term, see
+	  docs/system/linuxboot.rst upstream) and virtiofs-shares
+	  the modules tree at boot.
+
+	  The wired consumer today is kdevops's nixosfi backend (imageless NixOS): its
+	  systemd template units run each guest as a systemd-machined
+	  machine and invoke qemu-system-* with -kernel pointing at
+	  this destdir.
+	  Pairs with the nixos-flake backends.imageless module,
+	  which describes a NixOS closure that boots without a disk
+	  image and expects the kernel to arrive via QEMU's direct
+	  boot path.
+
+	  No playbook-side cache: Make's source-level dependency
+	  tracking is the cache. A no-op rebuild walks the dep graph
+	  and exits in seconds; a real source change rebuilds only
+	  what actually changed.
+
 endchoice
 
+if BOOTLINUX_DIRECT_BOOT
+
+config BOOTLINUX_DIRECT_BOOT_TREE_PATH
+	string "Linux source tree path on the controller"
+	output yaml
+	default "{{ kdevops_controller_data_path }}/linux"
+	help
+	  Absolute path on the controller where the Linux source
+	  tree lives. Defaults to a linux/ subdirectory of
+	  KDEVOPS_CONTROLLER_DATA_PATH so it sits alongside the
+	  other controller-side artefacts kdevops produces.
+
+	  kdevops manages the tree only when this path is empty: the
+	  initial bringup clones the configured tree at the configured
+	  ref into the path, then leaves it alone. If the path
+	  already contains a git checkout — manually cloned, a git
+	  worktree, a symlink to another checkout, or a previous
+	  kdevops bringup — kdevops detects it and skips clone,
+	  fetch, and checkout entirely.
+
+config BOOTLINUX_DIRECT_BOOT_BUILDDIR
+	string "Out-of-tree controller-side kernel build directory"
+	output yaml
+	default "{{ kdevops_controller_data_path }}/linux-build"
+	help
+	  Absolute path on the controller for the out-of-tree kernel
+	  build (passed to make as O=...). Kept separate from
+	  BOOTLINUX_DIRECT_BOOT_TREE_PATH so the source tree stays clean.
+
+config BOOTLINUX_DIRECT_BOOT_DESTDIR
+	string "Controller-side kernel install destdir"
+	output yaml
+	default "{{ kdevops_controller_data_path }}/linux-destdir"
+	help
+	  Absolute path on the controller that receives the kernel
+	  image (under boot/) and modules tree (under lib/modules/)
+	  from make install + make modules_install. The nixosfi backend reads
+	  vmlinuz-* from boot/ for its QEMU -kernel argv and
+	  virtiofs-shares lib/modules into the imageless nixos-flake
+	  closure at runtime.
+
+endif # BOOTLINUX_DIRECT_BOOT
+
 if BOOTLINUX_9P
 
 menu "Modify default 9p configuration"
diff --git a/workflows/linux/Makefile b/workflows/linux/Makefile
index a6b8f2ea..38167634 100644
--- a/workflows/linux/Makefile
+++ b/workflows/linux/Makefile
@@ -106,7 +106,9 @@ linux-help-end:
 LINUX_HELP_EXTRA :=
 
 PHONY += linux
-ifeq (y,$(CONFIG_KDEVOPS_BASELINE_AND_DEV))
+ifeq (y,$(CONFIG_BOOTLINUX_DIRECT_BOOT))
+linux: linux-direct-boot
+else ifeq (y,$(CONFIG_KDEVOPS_BASELINE_AND_DEV))
 ifeq (y,$(CONFIG_BOOTLINUX_AB_DIFFERENT_REF))
 linux: linux-baseline linux-dev
 else
@@ -158,6 +160,14 @@ linux-mount:
 		--tags vars,9p_mount \
 		--extra-vars="$(BOOTLINUX_ARGS)" $(LIMIT_HOSTS)
 
+PHONY += linux-direct-boot
+linux-direct-boot:
+	$(Q)ansible-playbook \
+		--connection=local --inventory localhost, \
+		$(KDEVOPS_PLAYBOOKS_DIR)/bootlinux.yml \
+		--tags bootlinux_direct_boot_build \
+		--extra-vars="$(BOOTLINUX_ARGS)"
+
 PHONY += linux-deploy
 linux-deploy:
 	$(Q)ansible-playbook \

-- 
2.54.0