diff --git a/.github/actions/vm-setup/action.yml b/.github/actions/vm-setup/action.yml
new file mode 100644
index 00000000..b78c0e31
--- /dev/null
+++ b/.github/actions/vm-setup/action.yml
@@ -0,0 +1,105 @@
+name: VM test setup
+description: >
+ Enable KVM, install QEMU/OVMF/swtpm and ISO tooling, fetch the pinned
+ LMDE ISO (cached), and build the dev ISO with the working-tree
+ installer. Leaves fixtures/lmde-7-dev.iso ready for run_scenario.py.
+
+inputs:
+ iso-name:
+ description: Base ISO filename under tests/integration/fixtures
+ default: lmde-7-cinnamon-64bit.iso
+ iso-url:
+ description: Download URL for the base ISO
+ default: https://mirrors.kernel.org/linuxmint/debian/lmde-7-cinnamon-64bit.iso
+
+runs:
+ using: composite
+ steps:
+ - name: Free up disk space
+ shell: bash
+ run: |
+ # The PXE scenario unsquashfs's the full desktop rootfs (~8GB) to
+ # merge our code in; reclaim space the runner image spends on toolsets
+ # we don't use so it fits. Best-effort, never fails the job.
+ df -h / | tail -1
+ sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc \
+ /usr/local/.ghcup /usr/local/share/powershell || true
+ df -h / | tail -1
+
+ - name: Enable KVM for the runner user
+ shell: bash
+ run: |
+ echo "=== KVM diagnostics ==="
+ echo "cpu virt flags: $(grep -c -E 'vmx|svm' /proc/cpuinfo) cores"
+ ls -l /dev/kvm 2>&1 || echo "/dev/kvm absent"
+ # The udev rule persists the permission; the direct chmod applies
+ # it synchronously so there is no race with the next step (the
+ # udev trigger is asynchronous and `test -w` can run before it).
+ echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
+ | sudo tee /etc/udev/rules.d/99-kvm4all.rules
+ sudo udevadm control --reload-rules
+ sudo udevadm trigger --name-match=kvm || true
+ sudo chmod 0666 /dev/kvm
+ ls -l /dev/kvm
+ test -w /dev/kvm && echo "KVM is writable" \
+ || { echo "KVM NOT available — hosted runner lacks hardware virtualization"; exit 1; }
+
+ - name: Install QEMU, OVMF, swtpm and ISO tooling
+ shell: bash
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y --no-install-recommends \
+ qemu-system-x86 qemu-utils ovmf swtpm swtpm-tools \
+ squashfs-tools xorriso openssh-client
+
+ - name: Install Python deps for the harness
+ shell: bash
+ run: pip install pyyaml
+
+ # UEFI PXE needs an EFI boot binary (OVMF can't run a raw iPXE script the
+ # way the BIOS NIC option ROM does). Build ipxe.efi once, with an embedded
+ # script that chainloads the per-run boot.ipxe over TFTP — so the EFI
+ # binary itself is static and cacheable while the real boot script stays
+ # dynamic. Bump the cache key when the embedded script changes.
+ - name: Cache iPXE EFI binary
+ id: ipxe-cache
+ uses: actions/cache@v5
+ with:
+ path: tests/integration/fixtures/ipxe.efi
+ key: ipxe-efi-chain-v1
+
+ - name: Build iPXE EFI for UEFI netboot
+ if: steps.ipxe-cache.outputs.cache-hit != 'true'
+ shell: bash
+ run: |
+ sudo apt-get install -y --no-install-recommends \
+ build-essential liblzma-dev git
+ git clone --depth 1 https://github.com/ipxe/ipxe.git /tmp/ipxe
+ printf '#!ipxe\ndhcp\nchain tftp://${next-server}/boot.ipxe\n' \
+ > /tmp/embed.ipxe
+ make -C /tmp/ipxe/src -j"$(nproc)" bin-x86_64-efi/ipxe.efi \
+ EMBED=/tmp/embed.ipxe
+ mkdir -p tests/integration/fixtures
+ cp /tmp/ipxe/src/bin-x86_64-efi/ipxe.efi \
+ tests/integration/fixtures/ipxe.efi
+
+ - name: Cache the base ISO
+ id: iso-cache
+ uses: actions/cache@v5
+ with:
+ path: tests/integration/fixtures/${{ inputs.iso-name }}
+ key: lmde-iso-${{ inputs.iso-name }}
+
+ - name: Download and verify the base ISO
+ if: steps.iso-cache.outputs.cache-hit != 'true'
+ shell: bash
+ run: |
+ mkdir -p tests/integration/fixtures
+ cd tests/integration/fixtures
+ curl -sLO https://mirrors.kernel.org/linuxmint/debian/sha256sum.txt
+ curl -sLo "${{ inputs.iso-name }}" "${{ inputs.iso-url }}"
+ sha256sum -c <(grep "${{ inputs.iso-name }}" sha256sum.txt)
+
+ - name: Build the dev ISO
+ shell: bash
+ run: python tests/integration/harness/make_test_iso.py
diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml
new file mode 100644
index 00000000..3f169df6
--- /dev/null
+++ b/.github/workflows/integration-tests.yml
@@ -0,0 +1,90 @@
+name: integration tests
+
+# Boots real VMs and performs full unattended installs — slow and
+# KVM-dependent, so it runs nightly and on demand rather than per-push.
+on:
+ schedule:
+ - cron: "0 4 * * *" # 04:00 UTC nightly
+ workflow_dispatch:
+ inputs:
+ scenarios:
+ description: "Space-separated scenario names (default: all)"
+ required: false
+ default: ""
+
+# A newer run supersedes an in-flight one on the same ref (these are
+# expensive; don't pile them up).
+concurrency:
+ group: integration-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ # Fast gate: the failure-mode scenarios abort in ~2 min and need no
+ # successful install, so they run first and block the slow matrix.
+ failure-modes:
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ steps:
+ - uses: actions/checkout@v6
+ - uses: ./.github/actions/vm-setup
+ - name: Run failure-mode scenarios
+ run: |
+ for s in fail-malformed-yaml fail-no-disk-match fail-bios-grub-on-uefi fail-snapshots-on-ext4; do
+ python tests/integration/harness/run_scenario.py \
+ "tests/integration/scenarios/$s.yaml" \
+ --junit "tests/integration/.work/$s.xml"
+ done
+ - uses: actions/upload-artifact@v7
+ if: always()
+ with:
+ name: failure-mode-results
+ path: tests/integration/.work/*.xml
+
+ install-scenarios:
+ needs: failure-modes
+ runs-on: ubuntu-latest
+ timeout-minutes: 45
+ strategy:
+ fail-fast: false
+ matrix:
+ scenario: [bios-simple, uefi-simple, uefi-lvm, uefi-lvm-luks, bios-multi-disk, pxe-simple, pxe-uefi-simple, netboot-ipv6-simple, custom-uefi, custom-btrfs, static-network, uefi-luks-prompt, raid1-bios, raid5-lvm-bios, flatpak]
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Skip if not selected
+ id: gate
+ run: |
+ sel="${{ github.event.inputs.scenarios }}"
+ if [ -n "$sel" ] && ! echo "$sel" | grep -qw "${{ matrix.scenario }}"; then
+ echo "run=false" >> "$GITHUB_OUTPUT"
+ else
+ echo "run=true" >> "$GITHUB_OUTPUT"
+ fi
+
+ - uses: ./.github/actions/vm-setup
+ if: steps.gate.outputs.run == 'true'
+
+ - name: Run ${{ matrix.scenario }}
+ if: steps.gate.outputs.run == 'true'
+ run: |
+ python tests/integration/harness/run_scenario.py \
+ "tests/integration/scenarios/${{ matrix.scenario }}.yaml" \
+ --keep \
+ --junit "tests/integration/.work/${{ matrix.scenario }}.xml"
+
+ - name: Collect serial log on failure
+ if: failure() && steps.gate.outputs.run == 'true'
+ run: |
+ mkdir -p artifacts
+ cp "tests/integration/.work/${{ matrix.scenario }}/${{ matrix.scenario }}-serial.log" \
+ "artifacts/${{ matrix.scenario }}-serial.log" || true
+ cp tests/integration/.work/*.xml artifacts/ || true
+
+ - uses: actions/upload-artifact@v7
+ if: always() && steps.gate.outputs.run == 'true'
+ with:
+ name: ${{ matrix.scenario }}-results
+ path: |
+ tests/integration/.work/${{ matrix.scenario }}.xml
+ artifacts/
+ if-no-files-found: ignore
diff --git a/.github/workflows/nfs-tests.yml b/.github/workflows/nfs-tests.yml
new file mode 100644
index 00000000..68f47ec3
--- /dev/null
+++ b/.github/workflows/nfs-tests.yml
@@ -0,0 +1,34 @@
+name: nfs tests
+
+# Stands up a real NFS server on the runner and fetches an answer file through
+# the installer's actual nfs:// transport over the full matrix of protocol
+# version (v3, v4.2) x address form (IPv4, IPv6, hostname). Cheap enough
+# (~2 min) to run per push for fast feedback.
+on:
+ push:
+ branches: [automated-install]
+ pull_request:
+ workflow_dispatch:
+
+jobs:
+ nfs:
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Set up Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: "3.12"
+
+ - name: Install test dependencies
+ run: pip install -r tests/requirements-test.txt
+
+ - name: Provision NFS server (v3 + v4.2, IPv4/IPv6/hostname)
+ run: bash tests/nfs/setup-nfs-server.sh
+
+ - name: Run real NFS transport tests
+ # mount(2) needs root, so run pytest under sudo with the same Python
+ # (its site-packages already hold the installed test deps).
+ run: sudo env LI_NFS_TEST=1 "$(command -v python)" -m pytest tests/nfs -v
diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml
new file mode 100644
index 00000000..71ec303f
--- /dev/null
+++ b/.github/workflows/unit-tests.yml
@@ -0,0 +1,28 @@
+name: unit tests
+
+on:
+ push:
+ branches: [master, automated-install]
+ pull_request:
+ workflow_dispatch:
+
+jobs:
+ pytest:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ python-version: ["3.11", "3.12", "3.13"]
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v6
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: Install test dependencies
+ run: pip install -r tests/requirements-test.txt
+
+ - name: Run unit tests
+ run: pytest tests/unit -v
diff --git a/.gitignore b/.gitignore
index 9cef1259..7db4c813 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,4 +3,11 @@ debian/*debhelper*
debian/files
debian/*substvar*
*interface.ui~
-usr/share/locale/*
\ No newline at end of file
+usr/share/locale/*
+__pycache__/
+*.pyc
+.pytest_cache/
+.venv/
+tests/integration/fixtures/*.iso
+tests/integration/fixtures/sha256sum.txt
+tests/integration/.work/
\ No newline at end of file
diff --git a/COPYING b/COPYING
new file mode 100644
index 00000000..9efa6fbc
--- /dev/null
+++ b/COPYING
@@ -0,0 +1,338 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 2, June 1991
+
+ Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
+
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The licenses for most software are designed to take away your
+freedom to share and change it. By contrast, the GNU General Public
+License is intended to guarantee your freedom to share and change free
+software--to make sure the software is free for all its users. This
+General Public License applies to most of the Free Software
+Foundation's software and to any other program whose authors commit to
+using it. (Some other Free Software Foundation software is covered by
+the GNU Lesser General Public License instead.) You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+this service if you wish), that you receive source code or can get it
+if you want it, that you can change the software or use pieces of it
+in new free programs; and that you know you can do these things.
+
+ To protect your rights, we need to make restrictions that forbid
+anyone to deny you these rights or to ask you to surrender the rights.
+These restrictions translate to certain responsibilities for you if you
+distribute copies of the software, or if you modify it.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must give the recipients all the rights that
+you have. You must make sure that they, too, receive or can get the
+source code. And you must show them these terms so they know their
+rights.
+
+ We protect your rights with two steps: (1) copyright the software, and
+(2) offer you this license which gives you legal permission to copy,
+distribute and/or modify the software.
+
+ Also, for each author's protection and ours, we want to make certain
+that everyone understands that there is no warranty for this free
+software. If the software is modified by someone else and passed on, we
+want its recipients to know that what they have is not the original, so
+that any problems introduced by others will not reflect on the original
+authors' reputations.
+
+ Finally, any free program is threatened constantly by software
+patents. We wish to avoid the danger that redistributors of a free
+program will individually obtain patent licenses, in effect making the
+program proprietary. To prevent this, we have made it clear that any
+patent must be licensed for everyone's free use or not licensed at all.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ GNU GENERAL PUBLIC LICENSE
+ TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+ 0. This License applies to any program or other work which contains
+a notice placed by the copyright holder saying it may be distributed
+under the terms of this General Public License. The "Program", below,
+refers to any such program or work, and a "work based on the Program"
+means either the Program or any derivative work under copyright law:
+that is to say, a work containing the Program or a portion of it,
+either verbatim or with modifications and/or translated into another
+language. (Hereinafter, translation is included without limitation in
+the term "modification".) Each licensee is addressed as "you".
+
+Activities other than copying, distribution and modification are not
+covered by this License; they are outside its scope. The act of
+running the Program is not restricted, and the output from the Program
+is covered only if its contents constitute a work based on the
+Program (independent of having been made by running the Program).
+Whether that is true depends on what the Program does.
+
+ 1. You may copy and distribute verbatim copies of the Program's
+source code as you receive it, in any medium, provided that you
+conspicuously and appropriately publish on each copy an appropriate
+copyright notice and disclaimer of warranty; keep intact all the
+notices that refer to this License and to the absence of any warranty;
+and give any other recipients of the Program a copy of this License
+along with the Program.
+
+You may charge a fee for the physical act of transferring a copy, and
+you may at your option offer warranty protection in exchange for a fee.
+
+ 2. You may modify your copy or copies of the Program or any portion
+of it, thus forming a work based on the Program, and copy and
+distribute such modifications or work under the terms of Section 1
+above, provided that you also meet all of these conditions:
+
+ a) You must cause the modified files to carry prominent notices
+ stating that you changed the files and the date of any change.
+
+ b) You must cause any work that you distribute or publish, that in
+ whole or in part contains or is derived from the Program or any
+ part thereof, to be licensed as a whole at no charge to all third
+ parties under the terms of this License.
+
+ c) If the modified program normally reads commands interactively
+ when run, you must cause it, when started running for such
+ interactive use in the most ordinary way, to print or display an
+ announcement including an appropriate copyright notice and a
+ notice that there is no warranty (or else, saying that you provide
+ a warranty) and that users may redistribute the program under
+ these conditions, and telling the user how to view a copy of this
+ License. (Exception: if the Program itself is interactive but
+ does not normally print such an announcement, your work based on
+ the Program is not required to print an announcement.)
+
+These requirements apply to the modified work as a whole. If
+identifiable sections of that work are not derived from the Program,
+and can be reasonably considered independent and separate works in
+themselves, then this License, and its terms, do not apply to those
+sections when you distribute them as separate works. But when you
+distribute the same sections as part of a whole which is a work based
+on the Program, the distribution of the whole must be on the terms of
+this License, whose permissions for other licensees extend to the
+entire whole, and thus to each and every part regardless of who wrote it.
+
+Thus, it is not the intent of this section to claim rights or contest
+your rights to work written entirely by you; rather, the intent is to
+exercise the right to control the distribution of derivative or
+collective works based on the Program.
+
+In addition, mere aggregation of another work not based on the Program
+with the Program (or with a work based on the Program) on a volume of
+a storage or distribution medium does not bring the other work under
+the scope of this License.
+
+ 3. You may copy and distribute the Program (or a work based on it,
+under Section 2) in object code or executable form under the terms of
+Sections 1 and 2 above provided that you also do one of the following:
+
+ a) Accompany it with the complete corresponding machine-readable
+ source code, which must be distributed under the terms of Sections
+ 1 and 2 above on a medium customarily used for software interchange; or,
+
+ b) Accompany it with a written offer, valid for at least three
+ years, to give any third party, for a charge no more than your
+ cost of physically performing source distribution, a complete
+ machine-readable copy of the corresponding source code, to be
+ distributed under the terms of Sections 1 and 2 above on a medium
+ customarily used for software interchange; or,
+
+ c) Accompany it with the information you received as to the offer
+ to distribute corresponding source code. (This alternative is
+ allowed only for noncommercial distribution and only if you
+ received the program in object code or executable form with such
+ an offer, in accord with Subsection b above.)
+
+The source code for a work means the preferred form of the work for
+making modifications to it. For an executable work, complete source
+code means all the source code for all modules it contains, plus any
+associated interface definition files, plus the scripts used to
+control compilation and installation of the executable. However, as a
+special exception, the source code distributed need not include
+anything that is normally distributed (in either source or binary
+form) with the major components (compiler, kernel, and so on) of the
+operating system on which the executable runs, unless that component
+itself accompanies the executable.
+
+If distribution of executable or object code is made by offering
+access to copy from a designated place, then offering equivalent
+access to copy the source code from the same place counts as
+distribution of the source code, even though third parties are not
+compelled to copy the source along with the object code.
+
+ 4. You may not copy, modify, sublicense, or distribute the Program
+except as expressly provided under this License. Any attempt
+otherwise to copy, modify, sublicense or distribute the Program is
+void, and will automatically terminate your rights under this License.
+However, parties who have received copies, or rights, from you under
+this License will not have their licenses terminated so long as such
+parties remain in full compliance.
+
+ 5. You are not required to accept this License, since you have not
+signed it. However, nothing else grants you permission to modify or
+distribute the Program or its derivative works. These actions are
+prohibited by law if you do not accept this License. Therefore, by
+modifying or distributing the Program (or any work based on the
+Program), you indicate your acceptance of this License to do so, and
+all its terms and conditions for copying, distributing or modifying
+the Program or works based on it.
+
+ 6. Each time you redistribute the Program (or any work based on the
+Program), the recipient automatically receives a license from the
+original licensor to copy, distribute or modify the Program subject to
+these terms and conditions. You may not impose any further
+restrictions on the recipients' exercise of the rights granted herein.
+You are not responsible for enforcing compliance by third parties to
+this License.
+
+ 7. If, as a consequence of a court judgment or allegation of patent
+infringement or for any other reason (not limited to patent issues),
+conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot
+distribute so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you
+may not distribute the Program at all. For example, if a patent
+license would not permit royalty-free redistribution of the Program by
+all those who receive copies directly or indirectly through you, then
+the only way you could satisfy both it and this License would be to
+refrain entirely from distribution of the Program.
+
+If any portion of this section is held invalid or unenforceable under
+any particular circumstance, the balance of the section is intended to
+apply and the section as a whole is intended to apply in other
+circumstances.
+
+It is not the purpose of this section to induce you to infringe any
+patents or other property right claims or to contest validity of any
+such claims; this section has the sole purpose of protecting the
+integrity of the free software distribution system, which is
+implemented by public license practices. Many people have made
+generous contributions to the wide range of software distributed
+through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing
+to distribute software through any other system and a licensee cannot
+impose that choice.
+
+This section is intended to make thoroughly clear what is believed to
+be a consequence of the rest of this License.
+
+ 8. If the distribution and/or use of the Program is restricted in
+certain countries either by patents or by copyrighted interfaces, the
+original copyright holder who places the Program under this License
+may add an explicit geographical distribution limitation excluding
+those countries, so that distribution is permitted only in or among
+countries not thus excluded. In such case, this License incorporates
+the limitation as if written in the body of this License.
+
+ 9. The Free Software Foundation may publish revised and/or new versions
+of the General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+Each version is given a distinguishing version number. If the Program
+specifies a version number of this License which applies to it and "any
+later version", you have the option of following the terms and conditions
+either of that version or of any later version published by the Free
+Software Foundation. If the Program does not specify a version number of
+this License, you may choose any version ever published by the Free Software
+Foundation.
+
+ 10. If you wish to incorporate parts of the Program into other free
+programs whose distribution conditions are different, write to the author
+to ask for permission. For software which is copyrighted by the Free
+Software Foundation, write to the Free Software Foundation; we sometimes
+make exceptions for this. Our decision will be guided by the two goals
+of preserving the free status of all derivatives of our free software and
+of promoting the sharing and reuse of software generally.
+
+ NO WARRANTY
+
+ 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
+FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
+OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
+PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
+OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
+TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
+PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
+REPAIR OR CORRECTION.
+
+ 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
+REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
+INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
+OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
+TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
+YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
+PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGES.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+convey the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software; you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation; either version 2 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along
+ with this program; if not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+If the program is interactive, make it output a short notice like this
+when it starts in an interactive mode:
+
+ Gnomovision version 69, Copyright (C) year name of author
+ Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, the commands you use may
+be called something other than `show w' and `show c'; they could even be
+mouse-clicks or menu items--whatever suits your program.
+
+You should also get your employer (if you work as a programmer) or your
+school, if any, to sign a "copyright disclaimer" for the program, if
+necessary. Here is a sample; alter the names:
+
+ Yoyodyne, Inc., hereby disclaims all copyright interest in the program
+ `Gnomovision' (which makes passes at compilers) written by James Hacker.
+
+ , 1 April 1989
+ Moe Ghoul, President of Vice
+
+This General Public License does not permit incorporating your program into
+proprietary programs. If your program is a subroutine library, you may
+consider it more useful to permit linking proprietary applications with the
+library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License.
diff --git a/README.md b/README.md
new file mode 100644
index 00000000..4e598216
--- /dev/null
+++ b/README.md
@@ -0,0 +1,71 @@
+# live-installer
+
+The installer used by Linux Mint and LMDE. It runs as a GTK wizard for
+interactive desktop installs, and can also run **unattended** from a YAML
+answer file for labs, fleets, and OEM/imaging pipelines — covering disk
+selection and custom partition layouts (LVM, btrfs subvolumes, software RAID,
+LUKS with an optional first-boot passphrase prompt), static networking
+(IPv4/IPv6, VLANs, wifi, 802.1X/EAP), apt repositories, a custom CA trust
+store, an http(s) proxy, proprietary drivers, flatpak apps, Timeshift
+snapshots, multi-layout keyboards, and PXE/netboot delivery (local file,
+http(s), NFS, or TFTP).
+
+## Layout
+
+```
+usr/bin/ entry points (live-installer, oem-config, …)
+usr/lib/live-installer/ the installer
+ main.py GTK wizard (interactive front-end)
+ installer.py install engine (GUI-agnostic)
+ partitioning.py disk / partition handling
+ auto_installer.py unattended front-end (answer-file driven)
+ schema.py answer-file schema + validation
+ diskmatch.py stable-attribute disk selection
+ discovery.py netboot answer-file auto-discovery (mac/serial/uuid)
+ netconfig.py network: section -> NetworkManager keyfiles
+ pkgbackend.py package backend (apt today; swappable)
+ catrust.py ca_certs: section -> system trust store
+ snapshotbackend.py snapshots: section -> Timeshift (btrfs)
+ commandrunner.py single shell-out boundary (logging, secrets)
+docs/automated-install.md unattended-install user guide
+tests/ unit + integration tests, and TESTING.md
+```
+
+## Unattended installation
+
+Boot the live medium with an answer file on the kernel command line:
+
+```
+live-installer.auto=/cdrom/install.yaml
+```
+
+New to it? **[docs/getting-started.md](docs/getting-started.md)** is a
+hands-on walkthrough for trying it on a VM or spare machine. For the full
+reference — every answer-file key, delivery options, examples, and security
+notes — see **[docs/automated-install.md](docs/automated-install.md)**.
+A feature comparison against kickstart, preseed, and autoinstall is in
+**[docs/comparison.md](docs/comparison.md)**. The interactive GUI is
+unchanged when no answer file is supplied.
+
+For the mechanics of serial-console provisioning and encrypted (LUKS)
+boot — and the live-system quirks they work around — see
+**[docs/serial-console-and-luks.md](docs/serial-console-and-luks.md)**.
+For running the installer from a fully network-booted (PXE/iPXE) live
+system, see **[docs/pxe-netboot.md](docs/pxe-netboot.md)**.
+
+## Development & testing
+
+Unit tests need only Python; integration tests perform real installs in a
+VM. See **[tests/TESTING.md](tests/TESTING.md)** for the architecture, how
+to run and extend the suite, and the known traps.
+
+```sh
+# unit tests
+python3 -m venv .venv
+.venv/bin/pip install -r tests/requirements-test.txt
+.venv/bin/pytest tests/unit
+```
+
+## License
+
+GPL-2+. See [COPYING](COPYING) and `debian/copyright`.
diff --git a/debian/control b/debian/control
index 1659ca68..26d2ac05 100644
--- a/debian/control
+++ b/debian/control
@@ -12,6 +12,8 @@ Architecture: all
Depends: ${misc:Depends}, gir1.2-webkit2-4.1
, python3
, python3-distro
+ , python3-yaml
+ , python3-pydantic
, python3-parted, parted, gparted
, python3-gi-cairo
, python3-pil
diff --git a/debian/copyright b/debian/copyright
index f33719aa..8b438cad 100644
--- a/debian/copyright
+++ b/debian/copyright
@@ -1,5 +1,5 @@
Format: http://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
-Upstream-Name: livemaker
+Upstream-Name: live-installer
Upstream-Contact: Clement Lefebvre
Source:
diff --git a/docs/automated-install.md b/docs/automated-install.md
new file mode 100644
index 00000000..753570e7
--- /dev/null
+++ b/docs/automated-install.md
@@ -0,0 +1,906 @@
+# Automated (unattended) installation
+
+`live-installer` can run without any GUI interaction, driven by a YAML
+**answer file**. This is intended for labs, fleets, and OEM/imaging
+pipelines where the same install is performed on many machines.
+
+If no answer file is supplied, the installer behaves exactly as before —
+the GUI wizard. The automated path is additive; it changes nothing for
+interactive desktop users.
+
+New to this? [getting-started.md](getting-started.md) is a step-by-step
+walkthrough for trying it on a VM or spare machine. This page is the
+reference.
+
+- [Quick start](#quick-start)
+- [Triggering an unattended install](#triggering-an-unattended-install)
+- [Answer file reference](#answer-file-reference)
+- [Worked examples](#worked-examples)
+- [Failure handling](#failure-handling)
+- [Security notes](#security-notes)
+- [Limitations](#limitations-v1)
+
+## Quick start
+
+1. Write an answer file (`install.yaml`):
+
+ ```yaml
+ version: 1
+ locale: en_US.UTF-8
+ timezone: America/Toronto
+ users:
+ - name: admin
+ passwd: "$6$rounds=4096$abc$..." # openssl passwd -6
+ groups: [sudo]
+ storage:
+ target:
+ match:
+ first-non-removable: true
+ layout: simple
+ ```
+
+ Where this overlaps with cloud-init (identity, users, packages), it uses
+ the same keys and structure, so a cloud-init or Ubuntu autoinstall user
+ should find it familiar. It is not a drop-in for either
+ format.
+
+2. Put it on the install media (e.g. at `/cdrom/install.yaml`) **or** serve
+ it over HTTPS.
+
+3. Boot the live medium with the answer-file source on the kernel command
+ line:
+
+ ```
+ live-installer.auto=/cdrom/install.yaml
+ ```
+
+ The install runs unattended and the machine is ready to reboot when the
+ console prints `Automated installation complete`.
+
+## Triggering an unattended install
+
+The installer runs in automated mode when **either** is present:
+
+- the kernel command-line argument `live-installer.auto=`, or
+- the program is started with `--automated=`.
+
+`` is one of:
+
+| Source | Example | Notes |
+|---|---|---|
+| Local path | `/cdrom/install.yaml` | On the install media or any mounted filesystem. Zero infrastructure. |
+| HTTPS URL | `https://cfg.example.com/host.yaml` | For PXE / netboot. TLS required (see below). |
+| HTTP URL | `http://10.0.0.1/host.yaml` | Refused unless `live-installer.auto-insecure` is also on the cmdline (or `--insecure`). |
+| NFS URL | `nfs://10.0.0.1/srv/cfg/host.yaml` | The directory is mounted read-only and the file read from it. The protocol version is negotiated (typically v4.2 down to v3); pin it with a `?vers=` query (`nfs://host/srv/host.yaml?vers=3` or `?vers=4.2`) for a version-restricted filer. IPv4, IPv6 (bracketed literal), and hostnames all work. Cleartext, so refused unless `live-installer.auto-insecure` (or `--insecure`). |
+| TFTP URL | `tftp://10.0.0.1/host.yaml` | Fetched with a built-in TFTP read client (no extra tooling), for PXE setups that already run a TFTP server. Negotiates RFC 2347 options (`blksize`/`tsize`) and falls back to plain RFC 1350 against option-unaware servers. Intended for small files (answer file, keyfile) — use HTTP for large payloads. Cleartext, so refused unless `live-installer.auto-insecure` (or `--insecure`). |
+| Auto-discovery | `auto:https://cfg.example.com/` | One entry for a whole fleet: the installer finds its own file from this machine's identity. See [auto-discovery](#auto-discovery-for-netboot). |
+
+Answer files carry password hashes (and may reference key material), so
+cleartext transports (plain HTTP, NFS, TFTP) are refused by default. Use HTTPS,
+or opt in explicitly with `live-installer.auto-insecure` on a trusted
+network.
+
+IPv6 works everywhere a host appears: use a bracketed literal
+(`https://[2001:db8::1]/host.yaml`, `nfs://[2001:db8::1]/srv/host.yaml`) or
+a hostname that resolves to an AAAA record.
+
+### Per-machine answer files
+
+The source is a literal string, so a provisioning server can hand each
+machine its own file by templating the URL — for example keying on the
+SMBIOS serial in your PXE config:
+
+```
+live-installer.auto=https://cfg.example.com/by-serial/${serial}.yaml
+```
+
+### Auto-discovery for netboot
+
+Templating one PXE entry per machine does not scale. Instead, point every
+machine at the same base with `auto:` and let the installer find
+its own file from its identity:
+
+```
+live-installer.auto=auto:https://cfg.example.com/configs/
+```
+
+It then tries, in order, the first that fetches **and** validates:
+
+```
+/by-mac/.yaml # one per ethernet NIC, in interface order
+/by-serial/.yaml
+/by-uuid/.yaml
+/default.yaml # fleet-wide fallback
+```
+
+`` is lowercase colon-separated (e.g. `aa:bb:cc:00:11:22`); ``
+and `` come from SMBIOS (`/sys/class/dmi/id`). So one boot entry
+covers the whole fleet: give a few machines their own `by-serial` file and
+everything else falls through to `default.yaml`. The chosen source is
+logged. If none validate, the install aborts with the list of what was
+tried.
+
+Bare `auto` (no base) skips the network step and only checks the
+well-known local paths `/cdrom/auto-install.yaml` and
+`/run/live/medium/auto-install.yaml`, so a USB or remastered ISO still
+works offline.
+
+### Network boot (PXE / iPXE)
+
+The installer can run from a fully network-booted live system, with no
+install media: DHCP/TFTP/iPXE boot the kernel and initrd, live-boot
+fetches the root filesystem over HTTP, and the installer fetches its
+answer file over HTTP as above. This needs a netboot-capable image (a
+single squashfs with the installer, the kernel/initrd/manifests carried
+in the rootfs, and a NetworkManager-based live system), which a normal
+desktop ISO is not. The mechanics, the boot command line, the image
+requirements, and the traps are documented separately in
+[pxe-netboot.md](pxe-netboot.md).
+
+## Answer file reference
+
+The answer file is validated strictly: unknown keys, wrong types, and
+malformed YAML are hard errors. The installer never guesses — a bad file
+aborts before any disk is touched.
+
+### Top-level keys
+
+Keys that overlap with cloud-init use cloud-init's name and structure.
+
+| Key | Required | Description |
+|---|---|---|
+| `version` | yes | Schema version. Currently `1`. |
+| `locale` | yes | The primary locale, e.g. `en_US.UTF-8` — sets `LANG` (cloud-init style, top level). |
+| `additional_locales` | no | Extra locales to also generate (e.g. `[fr_CA.UTF-8]`), available even though `LANG` stays `locale`. |
+| `proxy` | no | System-wide http(s) proxy URL (e.g. `http://proxy.corp:3128`); used by apt during the install and by the installed system. |
+| `ca_certs` | no | CA certificates to add to the system trust store (cloud-init `ca_certs:` shape); see [ca_certs](#ca_certs). |
+| `timezone` | yes | IANA timezone, e.g. `America/Toronto` (top level). |
+| `users` | yes | At least one user; see [users](#users). |
+| `storage` | yes | Disk target and layout; see [storage](#storage). |
+| `hostname` | no | System hostname. Defaults to `mint` if omitted. |
+| `keyboard` | no | `model` (default `pc105`), `layout` (default `us`), `variant`, plus `additional_layouts` (list of `{layout, variant}`) and a `toggle` to switch them; see [keyboard and locales](#keyboard-and-locales). |
+| `network` | no | Static IP / DNS / VLAN / wifi config (netplan v2 subset); see [network](#network). DHCP on all NICs if omitted. |
+| `packages` | no | Flat list of packages to install (cloud-init style). |
+| `package_remove` | no | Flat list of packages to remove (extension; cloud-init has no declarative remove). |
+| `apt` | no | Apt repositories to add (cloud-init `apt:` shape); see [apt](#apt). |
+| `early_commands` | no | Shell commands run in the live environment before partitioning (`%pre`); see [early_commands](#early_commands). |
+| `late_commands` | no | Shell commands run in the target at end of install; see [late_commands](#late_commands). |
+| `kernel` | no | `cmdline_extra` and `serial_console`; see [kernel](#kernel). |
+| `oem` | no | `enabled`: leave the machine in OEM first-boot state. |
+| `drivers` | no | `install: true` installs recommended proprietary/DKMS drivers; see [drivers](#drivers). |
+| `flatpak` | no | Flatpak remotes to add and apps to install; see [flatpak](#flatpak). |
+| `snapshots` | no | Configure Timeshift snapshots on a btrfs root; see [snapshots](#snapshots). |
+| `on_failure` | no | Per-failure-mode policy; see [failure handling](#failure-handling). |
+| `logging` | no | `destination` (log file path, default `/var/log/live-installer-auto.log`) and `also_serial` (e.g. `ttyS0`; off by default). |
+
+### keyboard and locales
+
+`keyboard.layout`/`keyboard.variant` is the primary layout. Add more layouts
+to switch between with `additional_layouts`, and a `toggle` (an XKB switch
+option) to flip among them — the multi-layout case kickstart and autoinstall
+have long supported (e.g. an `en_CA` + `fr_CA` desktop):
+
+```yaml
+keyboard:
+ model: pc105
+ layout: us
+ additional_layouts:
+ - {layout: ca, variant: fr}
+ toggle: grp:alt_shift_toggle # e.g. Alt+Shift switches layout
+
+additional_locales: # generated alongside the primary `locale`
+ - fr_CA.UTF-8
+```
+
+The layouts are written to `/etc/default/keyboard` as the comma-joined
+`XKBLAYOUT`/`XKBVARIANT` lists (primary first) with `XKBOPTIONS` set to the
+toggle. `toggle` is only valid when `additional_layouts` is non-empty.
+`additional_locales` are `locale-gen`'d so they are available system-wide,
+but `LANG` stays the primary `locale`.
+
+### users
+
+Uses cloud-init key names. Each entry:
+
+| Field | Required | Description |
+|---|---|---|
+| `name` | yes | Username. Lowercase, starts with a letter/underscore, ≤ 32 chars. |
+| `passwd` | yes | A crypt(5) hash (`$6$…` sha512crypt, `$y$…` yescrypt, …). **Plaintext is rejected.** Generate with `openssl passwd -6` or `mkpasswd -m sha512crypt`. |
+| `gecos` | no | Full name (GECOS). |
+| `groups` | no | Supplementary groups. Put `sudo` here to grant admin (cloud-init idiom). |
+| `ssh_authorized_keys` | no | List of OpenSSH public keys installed to `~/.ssh/authorized_keys`. |
+| `autologin` | no | At most one user may set this. (Extension.) |
+| `ecryptfs_home` | no | Encrypt the home directory with eCryptfs. (Extension.) |
+
+The first user is the primary account created by the installer; any
+others are created during post-install.
+
+### storage
+
+```yaml
+storage:
+ target:
+ match: # select the disk by a STABLE attribute
+ by-id: "nvme-Samsung_SSD_980_PRO_*"
+ on_no_match: abort # only 'abort' is supported in v1
+ layout: lvm-on-luks # simple | lvm | lvm-on-luks
+ luks: # only with layout: lvm-on-luks
+ passphrase_source: keyfile # keyfile | prompt-on-first-boot (default); tpm2 reserved
+ keyfile: "https://cfg.example.com/keys/ws-01.key" # only for passphrase_source: keyfile
+```
+
+**Disk targeting never uses `/dev/sdX`.** Kernel device naming is not
+stable across NVMe/SATA/USB combinations, so the target is chosen by a
+match expression — at least one of:
+
+| Matcher | Matches |
+|---|---|
+| `by-id` | a glob against `/dev/disk/by-id/*` names |
+| `by-path` | a glob against `/dev/disk/by-path/*` names |
+| `model` | a glob against the disk model string |
+| `size-min` | disks at least this large (e.g. `500GB`, `1TB`) |
+| `first-non-removable` | the sole fixed (non-USB) disk. If a machine has more than one internal disk this is ambiguous and aborts — add `size-min`, `by-id`, or `by-path` to disambiguate. |
+
+All present matchers must agree. If **no** disk matches, or if **more
+than one** matches, the install aborts with the list of available disks
+— it never guesses which disk to erase.
+
+`on_no_match` currently accepts only `abort` (the fail-closed default). It
+is a field rather than implied behaviour so future values — e.g. `prompt`
+(ask on the console) or `first-internal-disk` (a documented relaxation) —
+can be added without a schema version bump.
+
+Layout presets: `simple` (single root + swap), `lvm` (LVM with root and
+swap logical volumes), `lvm-on-luks` (the same, on a LUKS2 container).
+
+For `lvm-on-luks`, `passphrase_source` selects how the encryption passphrase
+is established:
+
+- **`keyfile`** — read from a local path or a URL, using the same fetchers and
+ cleartext rules as the answer file itself (https by default; http, nfs, and
+ tftp only with `--insecure`). The URL is fetched verbatim: there is no `${...}`
+ templating (the config is data, not a program). For per-machine keyfiles,
+ let each machine fetch its own answer file via `auto:` discovery and put the
+ concrete keyfile URL in it. This is a fully unattended install.
+- **`prompt-on-first-boot`** (the default) — nothing secret in the answer
+ file. The installer formats LUKS with a random throwaway key and embeds it
+ in the initramfs so the **first** boot unlocks unattended; a one-shot
+ service then prompts the operator for the real passphrase (on the console,
+ including serial), adds it, removes the throwaway key, rebuilds the
+ initramfs, and reboots. Every subsequent boot prompts normally. This suits
+ image-now / set-the-passphrase-on-deployment workflows. The tradeoff: the
+ random throwaway key sits in the unencrypted `/boot` initramfs until that
+ first boot completes the rekey.
+- **`tpm2`** — reserved in the schema, not yet implemented (the driver aborts
+ with a clear error).
+
+Either way, the *installed* system prompts for the passphrase at every boot —
+on the serial console when a `kernel.serial_console` is set (see
+[serial-console-and-luks.md](serial-console-and-luks.md)).
+
+### Custom partition layouts
+
+For full control, `layout: custom` takes an explicit `partitions` list
+(and optionally an `lvm` list). The disk is wiped and the partitions are
+created in order.
+
+```yaml
+storage:
+ target:
+ match: {first-non-removable: true}
+ layout: custom
+ partitions:
+ - {size: 512MB, mount: /boot/efi, filesystem: vfat, flags: [esp]}
+ - {size: 2GB, mount: swap, filesystem: swap}
+ - {size: rest, lvm_pv: vg0} # this partition becomes an LVM PV
+ lvm:
+ - {vg: vg0, lv: root, size: 40GB, mount: /, filesystem: ext4}
+ - {vg: vg0, lv: home, size: rest, mount: /home, filesystem: ext4}
+```
+
+Each **partition** has a `size` (`512MB`/`40GB`/`1TB`, or `rest` for the
+remainder), and is one of: a mounted partition (`mount` + `filesystem`),
+an LVM physical volume (`lvm_pv: `), or a flag-only partition. `flags`
+may include `esp` (an EFI System Partition — must be `vfat` at `/boot/efi`)
+or `bios_grub` (the BIOS-boot partition for GPT). Swap is not a flag — it is
+a `filesystem: swap` partition (with `mount: swap`). Each **lvm**
+entry is a logical volume (`vg`, `lv`, `size`, `mount`, `filesystem`) on a
+VG backed by an `lvm_pv` partition.
+
+#### btrfs subvolumes
+
+A `btrfs` partition (or LVM logical volume) may carry a `subvolumes` list
+instead of a single `mount`. Each subvolume has a `name` (the on-disk
+subvolume name, e.g. `@`) and a `mount`. The filesystem is created once,
+the subvolumes are created in it, and each is mounted at its `mount` with
+`subvol=` — the snapshot-friendly layout Mint/Timeshift expect.
+
+```yaml
+ partitions:
+ - {size: 512MB, mount: /boot/efi, filesystem: vfat, flags: [esp]}
+ - {size: 2GB, mount: swap, filesystem: swap}
+ - size: rest
+ filesystem: btrfs
+ subvolumes:
+ - {name: "@", mount: /}
+ - {name: "@home", mount: /home}
+```
+
+A partition/LV with `subvolumes` must be `btrfs` and takes no top-level
+`mount`; subvolume mounts participate in the same uniqueness rules below.
+
+Rules, all enforced at validation: exactly one `/`; no duplicate mount
+points; at most one `rest` per disk and per VG; every `lvm_pv` VG must
+have logical volumes and vice versa; logical-volume names are unique within
+each VG; an `esp` partition must be `vfat` at `/boot/efi`, and a `/boot/efi`
+partition must carry the `esp` flag. Filesystems: `ext4`/`ext3`/`ext2`,
+`xfs`, `btrfs`, `vfat`, `f2fs`, `swap`. (One firmware rule can only be checked
+at install time, not by the schema: an `esp` partition on a BIOS machine, or a
+`bios_grub` partition on a UEFI machine, is rejected by the engine before any
+partition is created.)
+
+#### Software RAID
+
+For redundancy, a custom layout can span multiple disks and assemble md
+arrays. Select the disks with a `disks:` list (instead of a single `target:`),
+mark member partitions with `raid: `, and define each array under
+`raid:`. An array is then mounted, used as an LVM PV, or split into btrfs
+subvolumes — just like a partition. Members are the same partition replicated
+on every disk, so the device count equals the number of disks.
+
+```yaml
+storage:
+ layout: custom
+ disks: # selected by stable attribute, as always
+ - {match: {by-id: "...diskA*"}}
+ - {match: {by-id: "...diskB*"}}
+ - {match: {by-id: "...diskC*"}}
+ partitions:
+ - {size: 1GB, raid: md0} # one member per disk
+ - {size: rest, raid: md1}
+ raid:
+ - {name: md0, level: 1, mount: /boot, filesystem: ext4} # mirror
+ - {name: md1, level: 5, lvm_pv: vg0} # RAID5 as an LVM PV
+ lvm:
+ - {vg: vg0, lv: root, size: rest, mount: /, filesystem: ext4}
+```
+
+Levels `0`, `1`, `5`, and `10` are supported (`metadata` defaults to `1.2`).
+Validation enforces the minimum disk count per level (2 for 0/1, 3 for 5, 4
+for 10), that every member references a defined array and vice versa, and that
+RAID requires `disks:` (single-disk `target:` and `disks:` are mutually
+exclusive). The installer creates each array with `mdadm`, writes
+`/etc/mdadm/mdadm.conf` and the RAID initramfs modules so the root array
+assembles at boot, and **installs GRUB to every member disk** so the machine
+still boots if one disk fails. Non-RAID partitions (e.g. an ESP) are used from
+the first disk; the copies on the other disks exist only for that bootloader
+redundancy.
+
+### network
+
+Configures the **installed** system's networking. With no `network:` section
+every NIC is left to NetworkManager's default DHCP (the live session's own
+networking is never touched). The schema is a subset of netplan's v2 format —
+the same key names and structure — but it is rendered directly to
+NetworkManager keyfiles in `/etc/NetworkManager/system-connections/` (mode
+0600). It does **not** depend on the `netplan` binary, which LMDE/Debian does
+not ship; this mirrors how cloud-init renders its own Network Config v2 to the
+NetworkManager backend on Debian-family systems.
+
+```yaml
+network:
+ version: 2
+ ethernets:
+ lan0:
+ # Select the device by MAC (survives kernel renaming) or by name.
+ match: {macaddress: "52:54:00:aa:bb:01"}
+ addresses: [192.168.50.10/24, 2001:db8:50::10/64] # dual-stack
+ gateway4: 192.168.50.1
+ gateway6: 2001:db8:50::1
+ nameservers:
+ addresses: [192.168.50.53, 2001:db8:50::53]
+ search: [lab.example]
+ routes:
+ - {to: 10.0.0.0/8, via: 192.168.50.254} # optional extra route
+ vlans:
+ vlan50:
+ id: 50 # 802.1Q tag, 0..4094
+ link: lan0 # parent interface id
+ addresses: [10.50.0.5/24]
+```
+
+**ethernets** is a map of interface id → config. The id is the interface name
+unless a `match` is given: `match.macaddress` binds by hardware address (the
+robust choice for a mixed fleet, since the kernel name is unpredictable),
+`match.name` binds by interface name. **vlans** is a map of id → config with
+an `id` (the 802.1Q tag) and a `link` (the parent ethernet/wifi/vlan id).
+
+Each interface takes: `dhcp4`/`dhcp6` (default false), `addresses` (a list of
+`IP/prefix`, IPv4 and/or IPv6), a default gateway, `nameservers`
+(`addresses` + `search`), and `routes` (`to` is `default` or a CIDR, `via` is
+the next hop, optional `metric`). Per family: DHCP → NM `auto`; a static
+address → `manual`; neither → IPv4 `disabled` / IPv6 `link-local`. So a NIC
+with only an IPv4 address gets no global IPv6, and `dhcp6: true` selects
+SLAAC/DHCPv6 (`auto`). Validation rejects addresses without a prefix length,
+gateways of the wrong family or with no matching address, routes whose `via`
+family disagrees with `to`, VLAN ids outside 0..4094, VLAN links that do not
+name a defined interface (or that point at themselves), and an interface id
+reused across `ethernets`/`wifis`/`vlans`. Bonds and bridges are not
+modelled yet.
+
+**Default gateway — prefer `routes`.** netplan deprecated `gateway4`/`gateway6`
+in 2022 in favour of an explicit default route, so use that form as the
+canonical one:
+
+```yaml
+ routes:
+ - {to: default, via: 192.168.50.1} # IPv4 default
+ - {to: default, via: "2001:db8:50::1"} # IPv6 default
+```
+
+`gateway4`/`gateway6` are still accepted as a convenience for configs carried
+over from older netplan — they produce an equivalent default route in the
+keyfile — but new answer files should use `routes`.
+
+**Binding is your responsibility.** Each connection should select exactly one
+device — bind by `match.macaddress` (best for a mixed fleet) or a unique
+interface name. If a `match` is loose enough to apply to several NICs, or two
+connections claim the same interface name, which one NetworkManager activates
+is undefined. The installer does not set `autoconnect-priority`, so don't rely
+on ordering to disambiguate — make each match specific.
+
+#### wifi
+
+**wifis** is a map of interface id → config, taking all the same IP keys as an
+ethernet (static/DHCP, dual-stack, gateways, DNS, routes, `match`) plus an
+`access-points` map of SSID → access point. Each access point has an optional
+`password` (omit for an open network) and an optional `hidden: true` for a
+non-broadcast SSID:
+
+```yaml
+ wifis:
+ wlan0:
+ dhcp4: true
+ access-points:
+ "Corp-WPA":
+ password: "a-wpa2-passphrase" # 8..63 chars, or a 64-hex PSK
+ "Guest":
+ hidden: true # open, non-broadcast
+```
+
+Each access point becomes its own NetworkManager wifi connection (so a device
+with several APs roams between them); the connection keyfile carries the PSK in
+cleartext, which is why every keyfile is written `0600`. A password becomes
+WPA-PSK; no password renders an open network. **Note:** wifi cannot be
+exercised end-to-end in CI — QEMU does not emulate 802.11 — so it is validated
+by schema and keyfile-renderer unit tests, not an integration scenario.
+
+#### 802.1X / EAP (wired and WPA-Enterprise)
+
+An `auth:` block on an ethernet (wired 802.1X) or a wifi access point
+(WPA-Enterprise) configures EAP — netplan's own `auth:` shape, rendered to the
+keyfile's `[802-1x]` section:
+
+```yaml
+ ethernets:
+ lan0:
+ dhcp4: true
+ auth: # wired 802.1X
+ method: peap # tls | peap | ttls
+ identity: host/ws.example.com
+ ca-certificate: /etc/ssl/certs/corp-root.pem # server validation
+ anonymous-identity: anonymous@example.com
+ phase2-auth: mschapv2 # PEAP/TTLS inner method
+ password: "..."
+ wifis:
+ wlan0:
+ access-points:
+ "CorpSSID":
+ auth: # EAP-TLS over wifi
+ method: tls
+ identity: ws01
+ ca-certificate: /etc/ssl/certs/corp-root.pem
+ client-certificate: /etc/ssl/certs/ws01.pem
+ client-key: /etc/ssl/private/ws01.key # private key
+ client-key-password: "..."
+```
+
+`method: tls` (EAP-TLS) requires `client-certificate` + `client-key`;
+`peap`/`ttls` require `identity`, `password`, and `phase2-auth`.
+
+- **Server validation is required by default.** Omitting `ca-certificate`
+ means the supplicant won't validate the RADIUS server — the textbook
+ rogue-AP credential-theft setup — so it is *rejected* unless you set
+ `allow-unvalidated: true` to accept the risk (the same posture as refusing a
+ raw `/dev/sdX`).
+- **Certs and keys are referenced by absolute path** on the target — placed
+ there beforehand (baked into the image, or copied via `late_commands`). They
+ are deliberately **not** inline in v1, so an EAP-TLS **private key** — a live
+ network credential, higher-value than a password hash — need not travel in
+ the answer file at all. Any EAP secrets that are inline (a PEAP `password`)
+ land in the `0600` keyfile.
+- **Testing boundary:** like wifi, actual EAP authentication is not verifiable
+ in CI (no RADIUS server, no 802.11) — the `[802-1x]` rendering and the
+ validation are unit-tested; the handshake is a bare-metal concern.
+
+This per-connection EAP trust (`ca-certificate`) is separate from the system
+trust store ([`ca_certs`](#ca_certs)) — though you can point both at the same
+file. Bonds and bridges are still not modelled.
+
+### kernel
+
+```yaml
+kernel:
+ cmdline_extra: "mitigations=off ipv6.disable=1" # appended verbatim
+ serial_console: "ttyS0,115200" # provision a serial console
+```
+
+- `cmdline_extra` — appended to the installed system's kernel command
+ line (`GRUB_CMDLINE_LINUX_DEFAULT`).
+- `serial_console` — provision a full serial console on the installed
+ system (`ttyS0` or `ttyS0,`). This drops `quiet`/`splash`, adds
+ `console=tty0 console=,n8 plymouth.enable=0`, and points
+ GRUB's terminal at the serial line. Boot output — **including a LUKS
+ unlock passphrase prompt** — then appears on serial, so a headless box
+ can be unlocked and watched over IPMI Serial-over-LAN. Without this, an
+ encrypted machine prompts for its passphrase only on the local screen.
+
+Both are applied via a `grub.d` snippet and `update-grub`. For the
+mechanics and the live-system quirks involved, see
+[serial-console-and-luks.md](serial-console-and-luks.md).
+
+### apt
+
+Apt repositories to add before installing packages. This mirrors
+cloud-init's `apt:` section — a `sources` map keyed by an arbitrary name,
+each entry an apt sources.list line plus an optional signing key:
+
+```yaml
+apt:
+ sources:
+ vendor:
+ source: "deb https://example.com/repo trixie main"
+ key_url: https://example.com/repo.gpg # extension: fetch over https
+ upstream:
+ source: "deb https://other.example/deb stable main"
+ keyid: "0xABCDEF0123456789" # fetched from a keyserver
+ keyserver: keyserver.ubuntu.com # default shown
+ local:
+ source: "deb https://local.example/apt trixie main"
+ key: | # inline ASCII-armored key
+ -----BEGIN PGP PUBLIC KEY BLOCK-----
+ ...
+ -----END PGP PUBLIC KEY BLOCK-----
+```
+
+Each source must carry **at most one** signing key — `key` (inline armored),
+`keyid` (fetched from `keyserver`), or `key_url` (fetched over https; an
+extension beyond cloud-init). The source line is written to
+`/etc/apt/sources.list.d/.list` (override that basename with
+`filename:`), and the key to `/etc/apt/trusted.gpg.d/.asc` (inline
+`key` or `key_url`) or `.gpg` (`keyid`). The key filename always uses
+the source ``; `filename:` only renames the `.list`. `key_url` must
+use HTTPS. **Quote `keyid` values** — an unquoted `0x…` is parsed as a YAML
+integer.
+
+Repo config is deliberately apt-specific: cloud-init never unified
+apt/yum/zypper, and here it is executed by a swappable package backend
+(`pkgbackend.py`) rather than hardcoded into the driver, leaving room for
+dnf/zypper later. The agnostic `packages:`/`package_remove:` lists stay
+top-level and are dispatched through whichever backend is selected.
+
+### proxy
+
+A system-wide http(s) proxy for corporate networks:
+
+```yaml
+proxy: http://proxy.corp.example:3128
+```
+
+It is written to `/etc/apt/apt.conf.d/00proxy` **before** packages are
+installed, so the install's own apt fetches go through it, and to
+`/etc/environment` (`http_proxy`/`https_proxy` + upper-case) for every other
+TLS client on the installed system. The URL must be an `http://`/`https://`
+URL with no quotes or whitespace. (Note: this does not affect the installer's
+own answer-file/keyfile fetch, which happens before the proxy is known.)
+
+### ca_certs
+
+Add CA certificates to the **system** trust store (`/etc/ssl/certs`, consulted
+by apt, curl, and TLS clients) — for a corporate MITM-proxy CA or an internal
+PKI root. Mirrors cloud-init's `ca_certs:`:
+
+```yaml
+ca_certs:
+ remove_defaults: false # almost always false; see the warning below
+ trusted:
+ - |
+ -----BEGIN CERTIFICATE-----
+ MIID...corporate root CA...
+ -----END CERTIFICATE-----
+```
+
+Each `trusted` entry is an inline PEM certificate, written to
+`/usr/local/share/ca-certificates/li-ca-N.crt` and merged into the trust store
+with `update-ca-certificates` (run **before** packages install, so a private
+mirror's CA is trusted in time). Validation **rejects a private key** in
+`trusted` (a trust store holds public certs only) and non-PEM junk.
+
+`remove_defaults: true` wipes the bundled (Mozilla) trust and keeps only your
+`trusted` certs — dangerous (it breaks apt-over-HTTPS and most TLS unless you
+provide replacements), so it is rejected with an empty `trusted`.
+
+This is the **system** trust store only. It is deliberately separate from any
+future per-connection 802.1X/EAP trust (which lives in a NetworkManager
+profile and does not consult the system store).
+
+### flatpak
+
+Add flatpak remotes and install flatpak apps — the other package system Mint
+uses alongside apt:
+
+```yaml
+flatpak:
+ remotes:
+ - {name: flathub, url: "https://flathub.org/repo/flathub.flatpakrepo"}
+ install:
+ - com.github.tchx84.Flatseal
+ - org.gnome.Calculator
+```
+
+The installer ensures `flatpak` is present (via apt), adds each remote
+(`remote-add --if-not-exists`), and installs each app into the target with
+`flatpak install --system` at install time. Remote URLs must be `https://`,
+and `install:` needs at least one remote to install from. Mint ships flatpak
+and pre-configures Flathub; on LMDE the package is pulled in as needed.
+
+(Snap is intentionally not supported — Mint and LMDE disable snapd by default —
+but a future `snap:` section is not foreclosed: it would be a separate
+additive section, the same way `flatpak:` and `apt:` are.)
+
+### drivers
+
+Install recommended proprietary / DKMS drivers (e.g. NVIDIA), the way Ubuntu
+autoinstall's `drivers:` does:
+
+```yaml
+drivers:
+ install: true
+```
+
+This runs `ubuntu-drivers install` after the package phase. `ubuntu-drivers`
+comes from `ubuntu-drivers-common`, which Mint ships (its Driver Manager uses
+it); on **LMDE/Debian** it is absent, so the directive is a logged no-op — list
+the specific driver packages under `packages:` there instead.
+
+**SecureBoot caveat:** a freshly built DKMS module is signed by a local
+Machine Owner Key that must be **enrolled interactively at the next boot**
+(the blue MOK Manager screen). That enrollment cannot be automated — it is a
+universal SecureBoot limitation, not specific to this installer, and even
+autoinstall's `drivers:` hits it. On SecureBoot machines, expect a one-time
+manual MOK enrollment, or disable SecureBoot.
+
+### snapshots
+
+Configure **Timeshift** at install time, on a btrfs root:
+
+```yaml
+snapshots:
+ enabled: true
+ backend: timeshift-btrfs # v1: the only backend
+ schedule: # tool-neutral; counts are how many to keep
+ boot: true
+ daily: 5
+ weekly: 3
+ monthly: 0
+ initial_snapshot: false # take one on first boot
+```
+
+**Requires a btrfs `@`/`@home` root** (i.e. `layout: custom` with a btrfs root
+split into `@` at `/` and `@home` at `/home`). This is **cross-validated
+against `storage`**: enabling `timeshift-btrfs` without that layout is rejected
+at validation time, so a config can't install and then silently fail to make
+snapshots.
+
+The installer writes `/etc/timeshift/timeshift.json` (installing `timeshift`
+first if needed) before the system is unmounted; a self-removing first-boot
+service then registers Timeshift's schedule and takes the optional initial
+snapshot (Timeshift wants a booted system to wire its timers). Only
+`timeshift-btrfs` exists in v1 — the backend is behind a seam so an rsync or
+Snapper backend, and `backend: auto`, can be added later without a schema
+change. (The exact `timeshift.json` keys can vary across Timeshift releases;
+the format is verified by an integration test against the shipped Timeshift.)
+
+### early_commands
+
+A list of shell commands run in the **live environment, before partitioning**
+— Ubuntu autoinstall's `early-commands` and kickstart's `%pre`:
+
+```yaml
+early_commands:
+ - mdadm --stop --scan # tear down a stale array before re-partitioning
+ - /cdrom/scripts/site-prep.sh # an on-media script (run with sh)
+```
+
+They run in the live session (**not** a chroot — `/target` does not exist
+yet), before the disks are touched, so they are for **preparing the install
+environment**: tearing down stale arrays, mounting an out-of-band source,
+placing a cert/keyfile on the medium for `ca_certs`/EAP/LUKS to reference. A
+command that is a path to a file on the install media is run from there.
+Failure is governed by `on_failure.early_command_failure` (default `abort` —
+fail before any disk is touched).
+
+Unlike kickstart's `%pre`, `early_commands` **cannot change the partition
+layout** — the config is data, not a program. For per-machine layouts, generate
+the answer file beforehand or use `auto:` identity-based discovery to fetch a
+per-machine file.
+
+### late_commands
+
+A list of shell commands, run in the target (in a chroot) at the end of
+the install:
+
+```yaml
+late_commands:
+ - /cdrom/scripts/join-domain.sh
+ - systemctl enable ssh
+```
+
+This matches Ubuntu autoinstall's `late-commands` and kickstart `%post`:
+the commands run in the installed system at install time. It is
+deliberately **not** named `runcmd`, because cloud-init's `runcmd` runs on
+first boot — a different lifecycle (network up, systemd running) that this
+key does not provide. A command whose first word is a file present on the
+install media is copied into the target and run there, so on-media scripts
+work.
+
+## Discovering disk names
+
+To target a disk you need one of its stable attributes, not `/dev/sdX`.
+Boot the live medium and run:
+
+```
+live-installer --automated --list-disks
+```
+
+It prints every installable disk with its `by-id`, `by-path`, `model`,
+and size — the values you can paste into a `storage.target.match`
+expression. `by-path` is stable per chassis slot (reusable across
+identical machines); `by-id` is unique to one physical drive. It touches
+nothing and exits.
+
+## Validating an answer file
+
+You do not need a target machine, or any disks, to check that an answer
+file is well-formed. `--check` validates syntax and schema and exits:
+
+```
+live-installer --automated --check --config install.yaml
+```
+
+It prints `Answer file OK` and exits `0` on success, or the list of
+problems and exits `1` on failure. Because it stops before any disk is
+resolved, it runs anywhere — CI, a build host, a laptop — which makes it
+the natural lint step in a pipeline that generates these files. (`--dry-run`
+goes one step further and also resolves the target disk, so it must run on
+a machine that actually has the disk.)
+
+## Worked examples
+
+A complete, runnable answer file for each layout lives under
+[`tests/integration/scenarios/answers/`](../tests/integration/scenarios/answers/):
+
+| File | Demonstrates |
+|---|---|
+| `bios-simple.yaml` | Simplest case: simple layout, one user, one package |
+| `uefi-lvm.yaml` | LVM layout on a UEFI machine |
+| `uefi-lvm-luks.yaml` | LUKS-encrypted LVM with a keyfile and serial console |
+| `bios-multi-disk.yaml` | Selecting one disk out of several by `by-id` |
+| `custom.yaml` | Custom layout: explicit ESP + swap partitions and a custom LVM vg with root/home |
+| `btrfs.yaml` | Custom layout: btrfs root split into `@` (/) and `@home` (/home) subvolumes |
+| `static-network.yaml` | Static dual-stack (IPv4+IPv6) IP + 802.1Q VLAN on a second NIC |
+
+These double as the integration-test fixtures, so they are guaranteed to
+stay valid against the current schema.
+
+## Failure handling
+
+Every failure mode has an explicit policy, and the defaults fail closed
+(`abort`):
+
+```yaml
+on_failure:
+ early_command_failure: abort # an early_commands step failed
+ partition_mismatch: abort # disk match found nothing
+ network_unavailable: continue # an apt/network step failed
+ package_install_failure: abort
+ post_install_script_failure: abort
+```
+
+`abort` stops the install and prints `Automated installation FAILED`
+(plus the reason) to the console, log, and serial. `continue` logs a
+warning and proceeds. On any abort the machine is left unbooted rather
+than half-installed.
+
+Note: a disk-target mismatch (no disk matched, or more than one did) always
+aborts — that is the fail-closed `on_no_match: abort` on the target itself, and
+it fires before the `partition_mismatch` policy would be consulted, so in v1
+that knob has no softer setting to reach for. It is kept as an explicit field
+so a future relaxation (e.g. `prompt`) has a place to attach.
+
+### If the answer file itself cannot be loaded
+
+A source that is unreachable, missing, malformed, or fails schema
+validation is a hard failure: the driver prints the reason and
+`Automated installation FAILED`, then exits non-zero. It does **not**
+fall through to the interactive GUI, and it does not retry — falling
+through would risk an operator walking up to a half-expected manual
+install, and a silent retry loop hides a broken config. The fix is to
+correct the source and reboot. Validate the file with `--check` before
+deploying it to avoid this class of failure entirely.
+
+## Security notes
+
+- **Passwords** are crypt(5) hashes only; plaintext is rejected outright.
+- **Answer files and keyfiles** are refused over cleartext transports
+ (plain HTTP, NFS, TFTP) by default (they carry secrets) — use HTTPS or opt
+ in with `live-installer.auto-insecure`.
+- **LUKS passphrases** are passed to `cryptsetup` on stdin, never as a
+ command argument, so they are not visible in the process table; they
+ are also redacted from all logs.
+- Do not put secrets in `late_commands` command text. It is logged.
+ Reference a script on the media instead.
+
+### When `--insecure` (plain HTTP / NFS / TFTP) is appropriate
+
+The default refuses to fetch an answer file or keyfile over a cleartext
+transport (plain HTTP, NFS, or TFTP) because they carry secrets. `--insecure` (or
+`live-installer.auto-insecure`) lifts that, and there are legitimate uses:
+an air-gapped lab, an isolated provisioning VLAN, or a manufacturing floor
+where standing up trusted TLS is real work for little gain, and the wire is
+already trusted.
+
+It is *not* appropriate on any network an untrusted party can reach. The
+residual risk on plain HTTP is that anyone who can capture packets can
+observe the password hashes (salted hashes resist offline cracking but are
+still worth protecting) and could tamper with the install in flight. Make
+the choice deliberately, per network — not as a reflex to silence the
+error. HTTP fetches still time out after 60 seconds, so a hung or hostile
+server cannot wedge the install indefinitely.
+
+## Schema versioning policy
+
+The answer file declares `version: 1`. The policy for evolving it (chosen to
+match the closest analogues — Ubuntu autoinstall, whose `version: 1` this
+mirrors, and cloud-init, which parses network-config `version: 1` and `2`
+side by side):
+
+- **Additive changes never bump the version.** New optional keys and sections
+ are added under `version: 1`. An older answer file keeps validating, since
+ the new keys default to off. This is the common case.
+- **A breaking change mints `version: 2`, and `version: 1` stays supported.**
+ If a field is ever renamed, restructured, or removed, that goes in a new
+ `version: 2` shape; the parser keeps a `version: 1` model and routes old
+ files to it. Both are accepted for an extended overlap, so no existing
+ answer file breaks on upgrade.
+- A removed-in-`v1` field would first be accepted with a deprecation warning
+ for a release before it moves to `v2`-only.
+
+In short: grow `v1` additively, and only fork to `v2` for genuinely breaking
+changes while continuing to parse `v1`. (If breaking changes ever become
+frequent, the next step would be an Ignition-style forward-translation tool
+that migrates an old file up to the current version automatically.)
+
+## Limitations (v1)
+
+- Custom partition layouts cover explicit partitions, custom LVM, btrfs
+ subvolumes, and software RAID (md levels 0/1/5/10, LVM-on-RAID,
+ multi-disk via `disks:`).
+- `network:` covers static IPv4/IPv6, gateways, DNS, routes, 802.1Q VLANs,
+ wifi (WPA-PSK / open), and 802.1X/EAP (wired + WPA-Enterprise; certs by
+ reference) (netplan v2 subset); bonds and bridges are not modelled yet.
+ Wifi and EAP are unit-tested only (no 802.11 / RADIUS in CI).
+- Config delivery is local file, http(s) URL, NFS, or TFTP, plus `auto`
+ identity-based discovery; DNS-SRV discovery is not supported.
+- The config is data, not a program — no conditionals, loops, or
+ templating. Generate the YAML beforehand if you need that.
diff --git a/docs/comparison.md b/docs/comparison.md
new file mode 100644
index 00000000..e38d03fb
--- /dev/null
+++ b/docs/comparison.md
@@ -0,0 +1,139 @@
+# How this compares to kickstart, preseed, and autoinstall
+
+This is an honest feature comparison between live-installer's unattended
+mode and the three established Linux unattended-install systems. It exists
+so anyone deciding whether to use this, or thinking about contributing,
+can see exactly what is and is not implemented.
+
+The short version: live-installer's automated mode is a capable base for
+the common desktop and workstation case. It is not, and does not try to
+be, a kickstart-class enterprise provisioner. The gaps below are real, and
+most are well-scoped enough to be good contributions.
+
+The systems compared:
+
+- **kickstart** — Red Hat / Fedora, driven by Anaconda. The most mature,
+ ~20 years old. Its config is a custom directive syntax.
+- **preseed** — Debian Installer (d-i). debconf key/value answers.
+- **autoinstall** — Ubuntu Server, driven by Subiquity. YAML, and the
+ closest relative to this design (it embeds cloud-init).
+- **live-installer auto** — this project.
+
+Legend: ✅ full, ◐ partial or preset-only, ✗ not supported.
+
+## Format and validation
+
+| Feature | kickstart | preseed | autoinstall | live-installer |
+|---|---|---|---|---|
+| Config format | custom syntax | debconf k/v | YAML | YAML (cloud-init-flavored) |
+| Strict validation | ✅ ksvalidator | ◐ syntax check | ✅ JSON schema | ✅ pydantic |
+| Standalone validator CLI | ✅ | ◐ | ◐ built-in | ✅ `--check` (no disk needed) |
+| Versioned schema | ✗ | ✗ | ✅ | ✅ |
+| Dump config from a manual install | ✅ anaconda-ks.cfg | ✗ | ✅ | ✗ |
+
+## Storage
+
+| Feature | kickstart | preseed | autoinstall | live-installer |
+|---|---|---|---|---|
+| Disk select by stable id | ✅ | ◐ awkward | ✅ | ✅ |
+| Refuses ambiguous disk match | ◐ | ✗ | ◐ | ✅ |
+| Layout presets | ✅ autopart | ◐ recipes | ✅ | ✅ (3) |
+| Fully custom partitioning | ✅ | ✅ | ✅ | ✅ (explicit partitions, LVM, btrfs subvolumes, software RAID) |
+| LVM | ✅ | ✅ | ✅ | ✅ |
+| Software RAID | ✅ | ✅ | ✅ | ✅ (md, levels 0/1/5/10, LVM-on-RAID) |
+| btrfs / zfs | ✅ / ◐ | ◐ / ✗ | ✅ / ✅ | ◐ / ✗ (btrfs subvolumes; no zfs) |
+| Snapshot tool config | ✅ (Snapper) | ✗ | ✗ | ✅ (Timeshift on btrfs) |
+| LUKS encryption | ✅ | ✅ | ✅ | ✅ |
+| Passwordless unlock (TPM2 / NBDE) | ✅ | ✗ | ◐ | ✗ (schema reserves tpm2) |
+| LUKS unlock prompt on serial | ◐ | ◐ | ◐ | ✅ |
+| Enterprise storage (iSCSI/FCoE/multipath) | ✅ | ◐ | ◐ | ✗ |
+
+## Network
+
+| Feature | kickstart | preseed | autoinstall | live-installer |
+|---|---|---|---|---|
+| DHCP | ✅ | ✅ | ✅ | ✅ |
+| Static IP / DNS / gateway | ✅ | ✅ | ✅ | ✅ |
+| IPv6 | ✅ | ✅ | ✅ | ✅ |
+| VLAN / bonding / bridges | ✅ | ◐ | ✅ (netplan) | ◐ (VLAN; no bond/bridge) |
+| WiFi (WPA-PSK) | ◐ | ◐ | ✅ (netplan) | ✅ (WPA-PSK / open) |
+| 802.1X / EAP (wired + WPA-Enterprise) | ✅ | ◐ | ✅ (netplan) | ✅ (PEAP/TTLS/TLS; certs by-reference) |
+| Hostname | ✅ | ✅ | ✅ | ✅ |
+
+## Users, packages, scripting
+
+| Feature | kickstart | preseed | autoinstall | live-installer |
+|---|---|---|---|---|
+| Users with hashed passwords | ✅ | ✅ | ✅ | ✅ |
+| Reject plaintext passwords | ◐ | ✗ | ◐ | ✅ |
+| SSH authorized keys | ✅ | ◐ | ✅ | ✅ |
+| Multiple users | ✅ | ◐ | ◐ +cloud-init | ✅ |
+| Package install list | ✅ | ✅ | ✅ | ✅ |
+| Package groups / environments | ✅ | ◐ tasks | ◐ | ✗ |
+| Declarative package removal | ✅ | ◐ | ◐ | ✅ |
+| Add third-party repositories | ✅ | ◐ | ✅ | ✅ |
+| System http(s) proxy | ✅ | ✅ | ✅ | ✅ |
+| Flatpak / Snap apps | ✗ | ✗ | ◐ (snap) | ✅ flatpak (snap not foreclosed) |
+| Custom CA trust store | ◐ | ◐ | ◐ (via cloud-init) | ✅ (`ca_certs:`) |
+| Proprietary / DKMS driver install | ◐ | ✗ | ✅ (`drivers:`) | ✅ (`drivers:`; Mint, no-op on LMDE) |
+| Pre-install scripts (%pre) | ✅ | ✅ | ✅ | ✅ (early_commands, live env; no layout rewrite) |
+| Post-install scripts | ✅ | ✅ | ✅ | ✅ (late_commands, in target) |
+| First-boot scripts | ◐ | ✗ | ✅ cloud-init | ✗ |
+
+## Services, security, and system config
+
+| Feature | kickstart | preseed | autoinstall | live-installer |
+|---|---|---|---|---|
+| Enable / disable services | ✅ | ◐ | ◐ | ✗ |
+| Firewall config | ✅ | ✗ | ◐ | ✗ |
+| SELinux / AppArmor config | ✅ | ✗ | ◐ | ✗ |
+| Locale / timezone / keyboard | ✅ | ✅ | ✅ | ✅ |
+| Multiple keyboard layouts + supplementary locales | ✅ | ◐ | ✅ | ✅ (additional_layouts + toggle, additional_locales) |
+| Kernel cmdline / serial console | ✅ | ◐ | ◐ | ✅ |
+| OEM / first-boot user setup | ◐ | ✗ | ✗ | ✅ |
+| Security hardening add-on (OSCAP) | ✅ | ✗ | ✗ | ✗ |
+
+## Delivery
+
+| Feature | kickstart | preseed | autoinstall | live-installer |
+|---|---|---|---|---|
+| Local file on media | ✅ | ✅ | ✅ | ✅ |
+| HTTP(S) URL | ✅ | ✅ | ✅ | ✅ (HTTPS default, HTTP opt-in) |
+| NFS | ✅ | ✅ | ◐ | ✅ (v3/v4.2, IPv4/IPv6/hostname; cleartext, opt-in) |
+| TFTP | ✅ | ◐ | ✗ | ✅ (RFC 2347 negotiation + RFC 1350 fallback; cleartext, opt-in) |
+| Kernel cmdline trigger | ✅ | ✅ | ✅ | ✅ |
+| PXE / netboot install (no media) | ✅ | ✅ | ✅ | ✅ (BIOS + UEFI; IPv6 data path tested, IPv6 firmware-PXE n/a in CI) |
+| Per-machine by serial | ✅ | ◐ | ◐ | ✅ (auto-discovery by mac/serial/uuid) |
+| Includes / file composition | ✅ %include | ✅ | ✗ | ✗ |
+| Provisioning-server integration (Cobbler/MAAS/Foreman) | ✅ | ✅ | ✅ | ✗ |
+
+## Gaps, as possible contributions
+
+Roughly in order of value for a desktop/workstation fleet, which is what
+this targets:
+
+1. **Static networking.** Done. The `network:` section uses netplan's v2
+ schema (static IPv4/IPv6, gateways, DNS, routes, 802.1Q VLANs, wifi
+ WPA-PSK, and 802.1X/EAP), but it is rendered to NetworkManager keyfiles
+ directly rather than via the netplan binary (which LMDE/Debian does not
+ ship). Bonds and bridges are the remaining extensions.
+2. **Services enable/disable.** A small `services:` section. Easy, high
+ value (e.g. enable ssh, disable a default daemon).
+3. **Package groups / metapackages.** Today it is a flat list; Mint has
+ meta-packages that would be convenient to name.
+4. **Custom partition layouts.** Done — explicit partitions, custom LVM,
+ btrfs subvolumes, and software RAID (md, levels 0/1/5/10, LVM-on-RAID),
+ all under `layout: custom`.
+5. **TPM2 / NBDE (Clevis-Tang) LUKS unlock.** For passwordless encrypted
+ boot at scale. The schema already reserves `tpm2`.
+6. **Firewall / SELinux-AppArmor config**, if the fleet needs it.
+7. **Mint (casper) integration testing.** The harness assumes Debian-live;
+ a casper variant is needed before Mint itself (not just LMDE) is
+ covered end to end. See [serial-console-and-luks.md](serial-console-and-luks.md)
+ and the testing guide.
+
+Where this design already does well: stable-attribute disk selection that
+refuses to guess, plaintext-password rejection, explicit per-failure
+policy, a serial-console LUKS unlock, strict schema validation, and a
+turnkey integration test suite. Those are not universal among the systems
+above.
diff --git a/docs/getting-started.md b/docs/getting-started.md
new file mode 100644
index 00000000..4efcf86f
--- /dev/null
+++ b/docs/getting-started.md
@@ -0,0 +1,190 @@
+# Trying out unattended installation
+
+This is a hands-on walkthrough for developers and beta testers who want to
+try the automated installer. For the full key-by-key reference, see
+[automated-install.md](automated-install.md).
+
+## Status, read this first
+
+This feature is new and still being stabilized. Be aware:
+
+- It has been developed and tested end to end on **LMDE** (Debian-based).
+ It has **not** been tested on Linux Mint itself yet, because the Mint 23
+ base it targets has not had a beta to test against. Treat Mint as
+ unverified until that lands.
+- The answer-file schema may still change before it is final. Pin to a
+ known build and re-validate your files with `--check` after updating.
+- **It erases a disk without asking.** Only run a real install against a
+ VM or a machine whose disk you are willing to lose.
+
+The safest way to try it is in a virtual machine. The walkthrough below
+works the same on a VM or on spare physical hardware.
+
+## What you need
+
+- A live ISO built from this branch (the unattended driver and its
+ dependencies are included in the image).
+- A target to install onto: a VM with a blank virtual disk, or a spare
+ physical machine.
+
+## Step 1: boot the live medium
+
+Boot the live ISO normally. You do **not** need to start the graphical
+installer. Open a terminal in the live session.
+
+The unattended driver is invoked as `live-installer --automated`. Confirm
+it runs:
+
+```
+live-installer --automated --help
+```
+
+## Step 2: find your disk's stable name
+
+The answer file never refers to a disk as `/dev/sda` — that name is not
+stable across reboots or hardware. Instead you pick the disk by a stable
+attribute. List what this machine offers:
+
+```
+live-installer --automated --list-disks
+```
+
+You get each disk with everything you can match on:
+
+```
+/dev/sda 274GB model='Samsung SSD 870 EVO 250GB' removable=no
+ by-path (stable per chassis slot, fleet-reusable):
+ pci-0000:00:17.0-ata-1
+ by-id (unique to this physical drive):
+ ata-Samsung_SSD_870_EVO_250GB_S6PMNX0T123456
+ wwn-0x5002538f12345678
+```
+
+Which one to use:
+
+| You are... | Use |
+|---|---|
+| Installing a machine with one internal disk | `first-non-removable: true` (needs nothing from this list; aborts if the machine has several internal disks) |
+| Picking one disk out of several, this exact machine | a `by-id` name (unique to the drive) |
+| Imaging a fleet of identical hardware | a `by-path` name (same slot on every unit) |
+| Going off a spec sheet, no machine in hand | `model` (with a trailing `*`) or `size-min` |
+
+When you copy a `by-id` or `model` value, replace the serial-number tail
+with `*` so it is a glob, e.g.
+`ata-Samsung_SSD_870_EVO_250GB_*`.
+
+## Step 3: write a minimal answer file
+
+Create `install.yaml`. The smallest useful file:
+
+```yaml
+version: 1
+locale: en_US.UTF-8
+timezone: America/Toronto
+hostname: testbox
+users:
+ - name: admin
+ # generate with: openssl passwd -6
+ passwd: "$6$rounds=4096$REPLACE$ME..."
+ groups: [sudo]
+storage:
+ target:
+ match:
+ first-non-removable: true # or by-id / by-path / model from step 2
+ layout: simple # simple | lvm | lvm-on-luks | custom
+```
+
+Generate the password hash with `openssl passwd -6` (plaintext passwords
+are rejected). Paste the result into `passwd`.
+
+## Step 4: validate before you touch a disk
+
+Check the file is well-formed. This touches no disks and can run anywhere,
+including on your laptop before you ever boot the target:
+
+```
+live-installer --automated --check --config install.yaml
+```
+
+It prints `Answer file OK`, or the exact problems and a non-zero exit.
+
+Then confirm it resolves to the disk you expect on the target machine
+(this one does read the disks, so run it on the target):
+
+```
+live-installer --automated --dry-run --config install.yaml
+```
+
+It prints which disk it would install to, and stops. If your match is
+ambiguous or matches nothing, it tells you here instead of mid-install.
+
+## Step 5: run the install
+
+Two ways to hand the installer the file.
+
+**Interactively, from the live session** (good for a first try):
+
+```
+sudo live-installer --automated --config /path/to/install.yaml
+```
+
+**Unattended, from the boot menu** (how a real deployment works): put the
+file on the install media or an HTTPS URL and add to the kernel command
+line:
+
+```
+live-installer.auto=/cdrom/install.yaml
+```
+
+or
+
+```
+live-installer.auto=https://you.example.com/install.yaml
+```
+
+Plain HTTP is refused by default because the file carries a password hash;
+use HTTPS, or add `live-installer.auto-insecure` on a trusted network.
+
+The console prints `Automated installation complete` when the machine is
+ready to reboot, or `Automated installation FAILED` with a reason if a
+step hit an `abort` policy. Nothing is left half-installed on an abort.
+
+## Watching a headless box
+
+If the target has no monitor, add a serial console so the whole install
+(and a LUKS unlock prompt, if you use encryption) appears on serial:
+
+```yaml
+kernel:
+ serial_console: "ttyS0,115200"
+logging:
+ destination: /var/log/live-installer-auto.log
+ also_serial: ttyS0
+```
+
+See [serial-console-and-luks.md](serial-console-and-luks.md) for the
+mechanics and how to drive it over IPMI Serial-over-LAN.
+
+## Ready-made examples
+
+Complete, valid answer files live in
+[`tests/integration/scenarios/answers/`](../tests/integration/scenarios/answers/),
+covering each layout (`bios-simple`, `uefi-lvm`, `uefi-lvm-luks`, `custom`,
+`btrfs`) plus multi-disk selection, software RAID (`raid1-bios`,
+`raid5-lvm-bios`), a LUKS passphrase prompted on first boot
+(`luks-prompt`), flatpak apps (`flatpak`), static networking, and
+netboot. They double as the integration-test fixtures, so they are guaranteed
+to match the current schema. Start from the one closest to what you want and
+run it through `--check`.
+
+## Reporting back
+
+Useful things to include in feedback:
+
+- The exact build/commit of the ISO.
+- Your full `install.yaml` (redact the password hash).
+- The console output, and `/var/log/live-installer-auto.log` from the
+ target if it got that far.
+- `--list-disks` output if a disk match did not resolve the way you
+ expected.
+- Whether you were on LMDE, Mint, or a VM, and BIOS vs UEFI.
diff --git a/docs/pxe-netboot.md b/docs/pxe-netboot.md
new file mode 100644
index 00000000..2ab713ca
--- /dev/null
+++ b/docs/pxe-netboot.md
@@ -0,0 +1,214 @@
+# Network (PXE / iPXE) installation
+
+The unattended installer can run from a fully network-booted live system,
+with no install media attached. This document covers how that works, the
+boot command line, and the several non-obvious requirements a netboot
+image has to meet that a normal desktop ISO does not. It is the companion
+to [automated-install.md](automated-install.md) (the answer-file
+reference) and [serial-console-and-luks.md](serial-console-and-luks.md).
+
+Most of "PXE support" is infrastructure (DHCP, TFTP, iPXE, live-boot), not
+installer code. `live-installer` runs after the live system is already up.
+But netboot exposes a handful of requirements in the installer and in the
+image that are easy to miss, because the normal CD/USB boot path hides
+them. Those are the focus here.
+
+- [What is delivered over the network](#what-is-delivered-over-the-network)
+- [The boot command line](#the-boot-command-line)
+- [Netboot image requirements](#netboot-image-requirements)
+- [How the installer handles netboot](#how-the-installer-handles-netboot)
+- [Per-machine configuration](#per-machine-configuration)
+- [How it is tested](#how-it-is-tested)
+- [Traps, in the order they bite](#traps-in-the-order-they-bite)
+
+## What is delivered over the network
+
+- The bootloader (iPXE) over TFTP — handed out by DHCP (next-server +
+ boot filename), or chainloaded over HTTP.
+- The kernel and initrd over TFTP (or HTTP, if the iPXE build has it).
+- The root filesystem (squashfs) over HTTP, fetched by live-boot.
+- The answer file (and any LUKS keyfiles) over HTTP(S), fetched by the
+ installer from `live-installer.auto=`.
+
+## The boot command line
+
+The iPXE script boots the kernel/initrd and sets a command line like:
+
+```
+boot=live components ip=dhcp \
+ fetch=http://server/live/filesystem.squashfs \
+ live-installer.auto=http://server/install.yaml live-installer.auto-insecure
+```
+
+Three parts matter for netboot specifically:
+
+- **`ip=dhcp` is required.** live-boot's `fetch=` runs in the initramfs,
+ whose network stack is separate from iPXE's — iPXE's DHCP lease does not
+ carry over. Without `ip=dhcp` the initramfs never brings up networking
+ and the boot dies with `Unable to find a live file system on the
+ network`.
+- **`fetch=` takes a single squashfs URL.** live-boot word-splits the
+ value on whitespace, and a kernel command-line value cannot contain
+ spaces, so there is no way to pass two squashfs images. The dev-overlay
+ trick that works on CD/USB (a second `*.squashfs` that live-boot unions
+ in) does not work over PXE. See [a single squashfs](#1-a-single-squashfs-with-the-installer-baked-in).
+- **`live-installer.auto=`** triggers the unattended install as usual.
+ Add `live-installer.auto-insecure` for plain HTTP (the answer file
+ carries a password hash; HTTPS is preferred — see automated-install.md).
+
+## Netboot image requirements
+
+A desktop ISO is built for CD/USB boot and does not meet these. A netboot
+image has to.
+
+### 1. A single squashfs with the installer baked in
+
+Because `fetch=` is one URL (above), the installer's code has to be inside
+the one squashfs you serve, not in a separate overlay. Build a combined
+image: unpack the base `filesystem.squashfs`, lay the installer files on
+top, and repack. The integration harness does exactly this in
+`isotools.build_combined_squashfs()` (rootless, via
+`mksquashfs -all-root`).
+
+### 2. The rootfs must carry the kernel, initrd, and package manifests
+
+On CD/USB the kernel, initrd, and the live-package manifests sit next to
+the squashfs on the medium (`/run/live/medium/live/`). The installer reads
+them there to seed `/target/boot` and to know which live-only packages to
+remove from the installed system.
+
+A netboot medium has only the fetched squashfs — none of those other
+files. So the installer instead looks for them in the rootfs at:
+
+```
+/usr/lib/live-installer/netboot-live/
+ vmlinuz
+ initrd.img
+ filesystem.packages-remove
+ pool/main/ # the signed EFI bootloader .debs, for UEFI installs
+```
+
+A netboot image must place them there. If it does not, the install aborts
+copying the kernel (`No such file or directory`), reading the removal
+manifest, or (on UEFI) installing the signed bootloader (`Missing EFI
+package`). See `installer.py` (`self.live_files`, `self.pool`) and
+`isotools.build_combined_squashfs()` (which stages them).
+
+### 3. A NetworkManager-based live system (for DNS)
+
+The boot NIC is configured by the initramfs (`ip=dhcp`). NetworkManager
+leaves an initramfs-configured interface **unmanaged**, so it never runs
+its own DHCP and never writes `/etc/resolv.conf` — the live image's
+placeholder (seen as `nameserver dhcp`) survives, and any package install
+fails to resolve. On CD/USB this never happens because NetworkManager
+manages the NIC from the start.
+
+The installer repairs this itself (see below), so no operator action is
+needed — but it does assume a NetworkManager-based live system. A server
+image without NetworkManager would need its own way to get DNS into the
+live session.
+
+## How the installer handles netboot
+
+Two pieces of the installer are netboot-aware. Both are no-ops on CD/USB.
+
+- **`installer.py` — `self.live_files`.** The squashfs is still read from
+ the medium (live-boot fetches it to `/run/live/medium/live/`), but the
+ kernel, initrd, and manifests are read from the rootfs bundle in
+ requirement 2 when they are absent from the medium. Detected by the
+ absence of `vmlinuz` on the medium.
+- **`auto_installer.py` — `_ensure_dns()`.** Before any network step, if
+ `/etc/resolv.conf` has no real nameserver, it forces NetworkManager to do
+ a real DHCP on the boot NIC. A plain activation makes NetworkManager
+ *assume* the initramfs IP without doing DHCP, so it never learns DNS;
+ the fix is to disconnect and reconnect the device, then read the
+ nameservers NetworkManager learned (`nmcli IP4.DNS` / the DHCP4 options)
+ and write `/etc/resolv.conf` directly, since NetworkManager's rc-manager
+ may not own that file.
+
+## Per-machine configuration
+
+Templating one PXE entry per machine does not scale. Point every machine
+at the same base and let the installer find its own answer file from its
+identity:
+
+```
+live-installer.auto=auto:https://cfg.example.com/configs/
+```
+
+See [auto-discovery](automated-install.md#auto-discovery-for-netboot) for
+the lookup order (by MAC, then SMBIOS serial/UUID, then a fleet-wide
+default).
+
+## How it is tested
+
+The `pxe-simple` (BIOS) and `pxe-uefi-simple` (UEFI) integration scenarios
+perform real PXE installs end to end with no media, and `netboot-ipv6-simple`
+covers the IPv6 data path (see below). They use QEMU's user-mode
+network: its built-in TFTP/BOOTP server boots the kernel/initrd, live-boot
+fetches the combined squashfs over the harness HTTP server, and the
+install runs and is verified over SSH on the booted system. No privileged
+host networking and no real DHCP/TFTP daemons are involved, so they run on
+a standard GitHub hosted runner (with KVM). See
+[../tests/TESTING.md](../tests/TESTING.md) and the `netboot:` scenario
+knob.
+
+### BIOS vs UEFI
+
+The two differ only at the front of the boot chain:
+
+- **BIOS** — the NIC's iPXE option ROM (shipped with QEMU) runs the
+ `boot.ipxe` script directly.
+- **UEFI** — OVMF cannot run a raw script, so it PXE-loads an EFI binary.
+ The harness builds `ipxe.efi` once (in `vm-setup`, cached) with an
+ embedded script that `dhcp`s and chainloads `boot.ipxe` over TFTP, so the
+ EFI binary stays static while the boot script stays per-run. OVMF honours
+ `bootindex` rather than the legacy boot order, so the NIC carries one.
+ The UEFI install also pulls the signed bootloader packages
+ (`grub-efi-amd64`, `shim-signed`, …) from the pool, which the netboot
+ bundle carries alongside the kernel (requirement 2).
+
+From the chainload onward the two paths are identical.
+
+## Traps, in the order they bite
+
+Getting a PXE install green meant clearing these one by one. They are
+listed so the next person does not rediscover them:
+
+1. **No `ip=dhcp`** → the initramfs has no network → `Unable to find a
+ live file system on the network`.
+2. **Multiple squashfs in `fetch=`** → live-boot treats the
+ comma-joined URLs as one bad URL → same failure. Use one combined
+ squashfs.
+3. **Rootless `unsquashfs`** → cannot write `security.*` xattrs
+ (`-no-xattrs`) and cannot `mknod` device nodes (returns rc=2, which is
+ safe to tolerate — devtmpfs recreates `/dev` at boot).
+4. **Kernel/initrd/manifest not on the netboot medium** → the engine's
+ medium reads fail; carry them in the rootfs (requirement 2).
+5. **No DNS in the live system** → NetworkManager leaves the
+ initramfs-configured NIC unmanaged; force a real DHCP (requirement 3 /
+ `_ensure_dns`).
+6. **UEFI: signed bootloader packages not on the medium** → the
+ `grub-efi-amd64` / `shim-signed` `.deb`s come from `/pool`; carry them in
+ the bundle (requirement 2). UEFI also needs an EFI boot binary
+ (`ipxe.efi`) and a NIC `bootindex`, since OVMF cannot run a raw script.
+
+### IPv6
+
+The IPv6 story splits in two:
+
+- **IPv6 data path — supported and tested.** The `netboot-ipv6-simple`
+ scenario performs an unattended install with the rootfs squashfs and the
+ answer file both fetched over IPv6. live-boot's `fetch=` brings up IPv6 in
+ the initramfs via SLAAC (the `ip=dhcp` cmdline starts the link; the kernel
+ autoconfigures IPv6 from router advertisements), and the installer fetches
+ its answer file over IPv6 too. Answer-file and keyfile fetching over IPv6
+ (http/https/nfs) is supported throughout.
+- **IPv6 PXE firmware boot — not testable in slirp-based CI.** Enabling IPv6
+ in QEMU's user-mode network breaks the firmware PXE boot itself (slirp has
+ no DHCPv6 boot-URL option and the IPv4 PXE ROM stalls when IPv6 is
+ present). So the IPv6 scenario boots the kernel directly (QEMU `-kernel`)
+ rather than over PXE, and still fetches everything over IPv6 — which is the
+ part the installer owns. A real IPv6 deployment would PXE-boot over IPv6
+ (DHCPv6 + UEFI HTTP boot); that infrastructure cannot be emulated in
+ user-mode networking.
diff --git a/docs/serial-console-and-luks.md b/docs/serial-console-and-luks.md
new file mode 100644
index 00000000..edded597
--- /dev/null
+++ b/docs/serial-console-and-luks.md
@@ -0,0 +1,219 @@
+# Serial console & encrypted (LUKS) installs — how it works and why
+
+This document explains two coupled, non-obvious features of the
+unattended installer, and the live-system quirks they have to work
+around. If you touch `auto_installer.py`'s `_build_grub_snippet` or
+`_regenerate_initramfs_if_luks`, read this first — each piece below
+exists because of a specific failure that is invisible until you watch
+the installed system boot on a serial console.
+
+- [What the user asks for](#what-the-user-asks-for)
+- [Serial console: the four moving parts](#serial-console-the-four-moving-parts)
+- [Why a grub.d snippet, not /etc/default/grub](#why-a-grubd-snippet-not-etcdefaultgrub)
+- [Why LUKS needs the initramfs rebuilt](#why-luks-needs-the-initramfs-rebuilt)
+- [The live-system traps](#the-live-system-traps)
+- [End-to-end sequence](#end-to-end-sequence)
+- [How the test exercises it](#how-the-test-exercises-it)
+
+## What the user asks for
+
+```yaml
+storage:
+ layout: lvm-on-luks
+ luks:
+ passphrase_source: keyfile # or prompt-on-first-boot
+ keyfile: "https://cfg/host.key"
+kernel:
+ serial_console: "ttyS0,115200" # headless: console on the serial line
+```
+
+The intent: a LUKS-encrypted machine whose disk-unlock passphrase prompt
+appears on the **serial console** at boot, so an operator can unlock it
+over IPMI Serial-over-LAN (or the test harness can type it) — no monitor
+or keyboard required. Both halves (serial console, encrypted root) are
+ordinary fleet needs; together they hit several live-system quirks.
+
+## Serial console: the four moving parts
+
+For the cryptsetup unlock prompt — or any boot output — to reach
+`ttyS0`, **all** of these must be true on the *installed* system. Getting
+three of four produces a silent boot that looks hung on serial.
+
+1. **Kernel console on serial.** `console=ttyS0,115200n8` on the kernel
+ command line. The last `console=` becomes `/dev/console`, which is
+ where cryptsetup's askpass reads/writes. We also add `console=tty0`
+ first so the local screen still works.
+2. **No plymouth.** With `splash` (and even without it, if plymouth is in
+ the initramfs), plymouth grabs the password prompt and renders it
+ graphically — nothing reaches serial. We drop `quiet splash` and add
+ `plymouth.enable=0`, so cryptsetup falls back to a plain-text askpass
+ on `/dev/console`.
+3. **GRUB on serial.** `GRUB_TERMINAL="console serial"` +
+ `GRUB_SERIAL_COMMAND="serial --unit=0 --speed=115200"` so the GRUB
+ menu is also on serial. (Under OVMF/EFI this is partly automatic — the
+ EFI console already mirrors to serial — but on BIOS/real hardware it
+ is required, so we always set it.)
+4. **The initramfs can actually unlock the disk** — see the LUKS section
+ below. Without this the prompt never even appears; the boot drops to
+ an `(initramfs)` rescue shell with `ALERT! /dev/mapper/... does not
+ exist`.
+
+`_build_grub_snippet()` produces parts 1–3; `_regenerate_initramfs_if_luks()`
+handles part 4.
+
+## Why a grub.d snippet, not /etc/default/grub
+
+The obvious approach — edit `GRUB_CMDLINE_LINUX_DEFAULT` in
+`/etc/default/grub` — **silently does not work**. `grub-mkconfig` sources
+`/etc/default/grub` *and then* every `/etc/default/grub.d/*.cfg`, in
+order. LMDE ships a snippet there that re-sets
+`GRUB_CMDLINE_LINUX_DEFAULT="quiet splash"`, clobbering our edit. The
+generated `grub.cfg` ends up with `quiet splash` and no `console=`, and
+the diagnostic (see below) showed exactly that: `/etc/default/grub` held
+our value while `grub.cfg` did not.
+
+So we instead drop our own snippet at
+`/etc/default/grub.d/zz-live-installer.cfg`. The `zz-` prefix sorts it
+**last**, so it sees whatever earlier snippets set and rewrites it:
+
+```sh
+GRUB_CMDLINE_LINUX_DEFAULT="$(echo " ${GRUB_CMDLINE_LINUX_DEFAULT} " \
+ | sed -e 's/ quiet / /g' -e 's/ splash / /g' -e 's/^ *//' -e 's/ *$//') \
+ console=tty0 console=ttyS0,115200n8 plymouth.enable=0"
+GRUB_TERMINAL="console serial"
+GRUB_SERIAL_COMMAND="serial --unit=0 --speed=115200"
+```
+
+It strips `quiet`/`splash` from the *runtime* value (whatever the distro
+snippet set) and appends our console args. Last writer wins.
+
+> **Rule:** anything that influences the installed kernel cmdline or GRUB
+> behaviour must go through a `grub.d` snippet that sorts after the
+> distro's, never a direct edit of `/etc/default/grub`.
+
+## Why LUKS needs the initramfs rebuilt
+
+The cryptsetup *initramfs* — not the running system — is what unlocks the
+root device at boot. It learns which devices to unlock from `/etc/crypttab`
+**at `update-initramfs` time**: the cryptroot hook reads crypttab and
+bakes the device list into the initramfs. The engine writes
+`/etc/crypttab` and then runs `update-initramfs`.
+
+The problem: in the live session that `update-initramfs` is a **no-op**,
+so the crypttab is never baked in, and at boot the initramfs has no idea
+the root is encrypted. It waits, gives up, and drops to a rescue shell —
+with **no unlock prompt at all**. Plain and LVM (non-encrypted) installs
+are unaffected because they boot fine from the stock initramfs; only LUKS
+needs the rebuild, which is why this stayed hidden until serial console
+made the boot visible.
+
+`_regenerate_initramfs_if_luks()` (run from the post-install hook, only
+for `lvm-on-luks`) does the rebuild correctly — but it has to get past
+three live-system traps to do it.
+
+## The live-system traps
+
+These are properties of the Debian/LMDE **live** environment that the
+installer runs inside. Each one silently breaks `update-initramfs`.
+
+1. **`update-initramfs` is diverted.** `live-tools` replaces
+ `/usr/sbin/update-initramfs` (via `dpkg-divert`) with a wrapper that
+ refuses to run while booted from live media — it prints
+ `update-initramfs is disabled (live system is running on read-only
+ media)` and exits 0. The engine's `update-initramfs` therefore does
+ nothing. We resolve the real binary with
+ `dpkg-divert --truename /usr/sbin/update-initramfs` (which returns
+ e.g. `/usr/sbin/update-initramfs.orig.initramfs-tools`) and run that
+ directly, bypassing the wrapper.
+
+ - *Tried first and rejected:* bind-mounting the live medium into the
+ chroot so the wrapper's `/run/live/medium` check passes. The wrapper
+ then failed a **different** check (`read-only media`). Don't bother;
+ go straight to `--truename`.
+
+2. **A live-boot initramfs hook fails.** Once the real `update-initramfs`
+ runs, `live-boot`'s hook (`/usr/share/initramfs-tools/hooks/live`)
+ errors with `cp: cannot stat '/usr/lib/live/boot'` — a live-only path
+ that doesn't exist in the installed system — and makes
+ `update-initramfs` exit non-zero. We `rm -f` that hook before
+ rebuilding; the installed system is not a live system and has no
+ business carrying it.
+
+ - *Tried first and rejected:* `apt-get purge` of the live packages.
+ `apt-get purge` fails wholesale if *any* named package is absent (it
+ removed nothing and returned 100), and resolving the exact package
+ set across versions is fragile. Removing the one offending hook is
+ surgical and reliable. (Fully purging the live stack is a reasonable
+ future nicety, but do it package-by-package and tolerant of
+ absence.)
+
+3. **The `chroot` quoting trap.** `CommandRunner.chroot()` wraps the
+ command in `sh -c "..."` *and replaces `"` with `'`*. Shell variables
+ and command substitutions inside a chroot command are therefore
+ evaluated by the **host** shell, not the target's, unless you are very
+ careful. Prefer host-side file operations on `/target/...` paths
+ (e.g. `rm -f /target/usr/share/initramfs-tools/hooks/live`), or
+ resolve values on the host (via `runner.output(...)`) and substitute
+ them in Python, rather than writing `$(...)`/`$var` inside a
+ `runner.chroot()` string.
+
+## End-to-end sequence
+
+For a `lvm-on-luks` + `serial_console` install, the post-install hook
+(`before_unmount_hook`, while the chroot is still mounted) does, in order:
+
+1. `_regenerate_initramfs_if_luks()`
+ - `rm -f /target/usr/share/initramfs-tools/hooks/live` (trap 2)
+ - `real = dpkg-divert --truename /usr/sbin/update-initramfs` (trap 1)
+ - `chroot $real -u -k all` → crypttab is baked into the initramfs
+2. `_apply_kernel_config()`
+ - write `/etc/default/grub.d/zz-live-installer.cfg` (the grub.d snippet)
+ - `chroot update-grub` → grub.cfg gets `console=...` + serial terminal
+ - log the resulting `grub.cfg` kernel line (diagnostic)
+
+At boot: GRUB (on serial) → kernel (on serial, no plymouth) → initramfs
+cryptroot prompts `Please unlock disk lvmmint:` on `ttyS0` → operator (or
+harness) types the passphrase → LVM activates → root mounts → multi-user.
+
+## Prompt-on-first-boot rekey (serial)
+
+When `luks.passphrase_source: prompt-on-first-boot`, no passphrase is in the
+answer file at all: the installer formats LUKS with a random throwaway key and
+embeds it in the initramfs so the **first** boot unlocks unattended, then a
+one-shot service prompts for the real passphrase on the console — including the
+serial console when `serial_console` is set — and rekeys.
+
+The post-install step `_setup_luks_first_boot_rekey()` (in `auto_installer.py`)
+writes, into the target:
+
+- the keyfile at `/etc/cryptsetup-keys.d/cryptroot.key` (byte-exact, no trailing
+ newline) and the matching `crypttab` keyfile entry, plus the cryptsetup
+ initramfs hook so the key is carried into the initramfs;
+- a `li-luks-rekey` systemd one-shot, ordered `After=systemd-user-sessions`
+ and `Before=getty.target serial-getty@ttyS0.service`, with
+ `StandardInput=tty-force` / `TTYPath=/dev/console`, so its prompt lands on
+ the same console (tty0 *and* serial) as the boot.
+
+On that first boot the service uses a plain `read` on `/dev/console` to collect
+the passphrase, `luksAddKey`s it, `luksRemoveKey`s the throwaway key, rebuilds
+the initramfs (so the key is gone from `/boot`), removes itself, and reboots.
+Every subsequent boot is an ordinary cryptroot prompt as above. The
+`uefi-luks-prompt` integration scenario drives this over serial end to end.
+
+## How the test exercises it
+
+`tests/integration/scenarios/uefi-lvm-luks.yaml` runs the whole path:
+install, reboot, **type the passphrase over the serial console**
+(`boot_unlock:` in the scenario; `VM.send_serial()` in the harness),
+then verify the unlocked system over SSH. Two diagnostics made this
+debuggable and are kept in place:
+
+- the driver logs the actual `grub.cfg` kernel line after `update-grub`,
+ so a wrong cmdline is visible in the install log without re-running;
+- `run_scenario.py` copies the install-phase serial log to
+ `*-serial.install.log` before phase 2 truncates it, so the install and
+ the boot can be inspected separately.
+
+If you change any of the four serial-console parts or the initramfs
+rebuild, run this scenario and watch the serial log — a regression here
+is silent everywhere else.
diff --git a/pytest.ini b/pytest.ini
new file mode 100644
index 00000000..5ee64771
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,2 @@
+[pytest]
+testpaths = tests
diff --git a/tests/TESTING.md b/tests/TESTING.md
new file mode 100644
index 00000000..460be20a
--- /dev/null
+++ b/tests/TESTING.md
@@ -0,0 +1,291 @@
+# Testing & maintaining live-installer
+
+This document is for maintainers. It explains how the automated-install
+code is structured, how the test suite works, how to run and extend it,
+and the non-obvious traps a change is likely to hit. You do **not** need
+PXE, automation, or VM expertise to use it — the harness hides all of
+that behind one command.
+
+- [Architecture](#architecture-of-the-automated-install-path)
+- [Test layers](#test-layers)
+- [Running the tests](#running-the-tests)
+- [Adding an integration scenario](#adding-an-integration-scenario)
+- [Debugging a failing scenario](#debugging-a-failing-scenario)
+- [Known traps](#known-traps)
+- [CI](#ci)
+
+## Architecture of the automated-install path
+
+The GUI and the unattended driver are two front-ends over the **same**
+install engine. The engine was already GUI-agnostic — it talks to its
+caller through two callbacks (`set_progress_hook`, `set_error_hook`) and
+never imports GTK — so the unattended path is mostly new code beside the
+existing engine, not a rewrite of it.
+
+```
+ InstallerEngine (installer.py)
+ ── the actual install, GUI-agnostic ──
+ ▲ ▲
+ progress/error │ │ progress/error
+ hooks (GTK) │ │ hooks (console/log/serial)
+ │ │
+ InstallerWindow ───────┘ └─────── HeadlessDriver
+ (main.py, the GUI) (auto_installer.py)
+```
+
+New modules (all under `usr/lib/live-installer/`):
+
+| Module | Responsibility |
+|---|---|
+| `schema.py` | Parse + strictly validate the YAML answer file (pydantic). All input rules live here. |
+| `diskmatch.py` | Resolve a `storage.target.match` expression to exactly one `/dev/...` path, or fail. No `/dev/sdX`. |
+| `auto_installer.py` | The headless driver: fetch the answer file, build the engine's `Setup`, run the install, do post-install steps (extra users, SSH keys, packages, kernel cmdline, scripts). |
+| `commandrunner.py` | The single point every shell-out goes through. Logs commands, checks exit codes, redacts secrets, feeds stdin. |
+
+The engine change that made the driver possible: every `os.system()` /
+`subprocess.getoutput()` in `installer.py` now goes through a
+`CommandRunner` held on the engine (`self.runner`), injectable via the
+constructor. The GUI passes nothing and gets the real runner; tests pass
+a recording fake.
+
+Entry point (`main.py`): if `live-installer.auto=` is on the kernel
+cmdline (or `--automated=`), `main.py` hands off to
+`auto_installer.main()` before any GTK setup. Otherwise the GUI starts
+as before.
+
+The system unit `live-installer-auto.service` launches the installer in
+the live session when the cmdline trigger is present (and prints a
+failure marker if it dies before doing so).
+
+## Test layers
+
+| Layer | Where | Speed | Needs |
+|---|---|---|---|
+| **Unit** | `tests/unit/` | ~10 s | Python + pytest only |
+| **Integration** | `tests/integration/` | ~10–15 min/scenario | KVM, QEMU, OVMF, swtpm, an ISO |
+
+The unit tests import the real installer modules with `gi`, `parted` and
+`dialogs` replaced by stubs (`tests/unit/conftest.py`), so they run
+anywhere with no GTK and no root. They cover the schema, disk matching,
+the command runner (including secret redaction), and the driver's
+config-to-`Setup` mapping and failure-policy logic.
+
+The integration tests perform **real installs** in a VM: boot the live
+ISO, drive it with an answer file served over HTTP, wait for the
+completion marker on the serial console, reboot into the installed disk,
+and assert on the result over SSH.
+
+## Running the tests
+
+### Unit
+
+```sh
+python3 -m venv .venv
+.venv/bin/pip install -r tests/requirements-test.txt
+.venv/bin/pytest tests/unit -v
+```
+
+### Integration (one-time setup)
+
+Install the tooling (Debian/Ubuntu shown; EL uses `qemu-kvm`,
+`edk2-ovmf`):
+
+```sh
+sudo apt-get install -y qemu-system-x86 qemu-utils ovmf swtpm \
+ swtpm-tools squashfs-tools xorriso
+pip install pyyaml
+```
+
+Fetch the pinned ISO once:
+
+```sh
+cd tests/integration/fixtures
+curl -sLO https://mirrors.kernel.org/linuxmint/debian/sha256sum.txt
+curl -sLO https://mirrors.kernel.org/linuxmint/debian/lmde-7-cinnamon-64bit.iso
+sha256sum -c <(grep lmde-7-cinnamon-64bit.iso sha256sum.txt)
+```
+
+### Integration (per run)
+
+The harness can't modify the read-only main squashfs, so it builds a
+**dev ISO**: an overlay squashfs containing the working-tree installer is
+added to the ISO (live-boot union-mounts it over the stock files).
+**Rebuild it whenever you change installer code:**
+
+```sh
+python tests/integration/harness/make_test_iso.py
+```
+
+Then run a scenario:
+
+```sh
+python tests/integration/harness/run_scenario.py \
+ tests/integration/scenarios/bios-simple.yaml
+```
+
+`--smoke` boots the stock ISO and just checks the VM stays up (no
+install) — a fast sanity check that QEMU/KVM/firmware work. `--keep`
+preserves the work directory (disk image, serial log) for inspection.
+`--junit out.xml` writes JUnit results.
+
+## Adding an integration scenario
+
+A scenario is two files under `tests/integration/scenarios/`:
+
+1. `answers/.yaml` — the answer file under test (a real example, per
+ the schema). It's served to the VM; the harness injects an SSH key
+ into the first user automatically so it can log in to verify.
+2. `.yaml` — the scenario: firmware, disk topology, timeouts, and
+ the `verify` assertions.
+
+Minimal scenario:
+
+```yaml
+name: my-case
+firmware: bios # bios | uefi | uefi-secureboot
+disk_gb: 25
+answer_file: answers/my-case.yaml
+install_timeout_s: 1800
+boot_timeout_s: 300
+expect:
+ serial_markers: ["Automated installation complete"]
+ failure_markers: ["Automated installation FAILED"]
+ssh:
+ user: testuser
+verify:
+ - name: hostname-applied
+ type: command
+ command: hostname
+ expect_substring: "my-host"
+```
+
+Verify assertion types: `command` (run over SSH; check `expect_rc` and/or
+`expect_substring`), `file_exists`, `mount_source` (the device backing a
+mountpoint contains a substring — e.g. `lvmmint-root`).
+
+Useful scenario knobs:
+
+- `disks: [{size_gb, serial}, ...]` — multiple disks, each with a serial
+ that surfaces as `/dev/disk/by-id/virtio-` (for `by-id` tests).
+- `boot_unlock: {prompt, passphrase_file, timeout_s}` — type a LUKS
+ passphrase over serial at the initramfs prompt during boot-verify.
+- `expect.outcome: failure` — a negative test: the failure marker is the
+ expected result and there is no boot/verify phase.
+- `netboot: true` — PXE boot instead of attaching the ISO (see
+ `pxe-simple`). The harness builds a single combined squashfs with the
+ installer baked in (`isotools.build_combined_squashfs`), serves the
+ kernel/initrd over QEMU's built-in TFTP and the squashfs over HTTP, and
+ the NIC's iPXE ROM boots it. Give it more `memory_mb` (live-boot fetches
+ the squashfs into RAM). The netboot mechanics, image requirements, and
+ traps are documented in [../docs/pxe-netboot.md](../docs/pxe-netboot.md).
+
+Add the scenario name to the matrix in
+`.github/workflows/integration-tests.yml` so CI runs it.
+
+## Debugging a failing scenario
+
+Run with `--keep` and look in `tests/integration/.work//`:
+
+- `-serial.log` — the full guest serial console. **Start here.**
+ Every command the installer runs is logged (`EXEC: ...`), so the last
+ `EXEC` line before a failure is usually the culprit. The log contains
+ terminal escape sequences; strip them with
+ `sed 's/\x1b\[[0-9;]*m//g'`.
+- `-serial.install.log` — for full-boot scenarios, the serial log
+ of the **install** phase (phase 2 truncates the live `-serial.log`).
+ Look here for what the installer did; look in `-serial.log` for the
+ installed system's boot.
+- `.qcow2` — the installed disk; boot it by hand with `qemu-system-x86_64`
+ if you need to poke around (`--keep` preserves it).
+
+The driver also logs the resulting `grub.cfg` kernel line after it
+regenerates grub, so the booted cmdline is visible in the install log
+without re-running — invaluable for serial-console / boot debugging.
+
+The installer tolerates a failed command unless `check=True`, so a broken
+install can still "finish" — the `EXEC failed (rc=...)` lines in the log
+are the signal, not just the final marker.
+
+## Known traps
+
+These have each caused a real failure; keep them in mind when changing
+the relevant code.
+
+- **udev races after partitioning.** Each `parted` call triggers a
+ partition-table rescan; udev briefly removes and recreates the device
+ nodes. `mkfs`/`mount` must wait (`udevadm settle`) or they race a
+ missing node. A tolerated `mkfs` failure is worse than a loud one — it
+ cascades into the file copy landing in tmpfs until it fills.
+- **`finish_installation()` unmounts the target.** Anything that needs
+ the chroot mounted with network (extra users, packages) must run via
+ the `before_unmount_hook`, not after `finish_installation()` returns.
+- **Offline EFI bootloader install leaves dpkg unsatisfied.** The engine
+ dpkg-installs shim/grub-efi from the ISO pool without their dependency
+ closure; the driver runs `apt-get install -f` before any other package
+ op to repair it.
+- **Secrets in commands.** Never build a shell command with a secret in
+ it. Feed it on stdin via `runner.run(..., stdin=secret)`; if it truly
+ must be in the command, pass `secrets=[...]` so it is redacted from
+ logs. Serial logs are routinely captured off real hardware.
+- **The dev ISO is stale until rebuilt.** Changing installer code without
+ re-running `make_test_iso.py` tests the *old* code. The integration CI
+ always rebuilds, but local runs don't.
+- **The answer-file fixtures are also the docs' examples.** A unit test
+ parses them against the schema so the two can't drift; if you change
+ the schema, update the fixtures.
+- **Serial console & LUKS boot are a minefield of live-system quirks.**
+ Editing `/etc/default/grub` is silently overridden by distro `grub.d`
+ snippets; `update-initramfs` is a diverted no-op in the live session;
+ a live-boot initramfs hook fails on the installed system. Each is
+ invisible until you watch the installed system boot on serial. The full
+ map — what each piece does and which approaches were tried and rejected
+ — is in **[docs/serial-console-and-luks.md](../docs/serial-console-and-luks.md)**.
+ Read it before touching `_build_grub_snippet` or
+ `_regenerate_initramfs_if_luks` in `auto_installer.py`.
+
+## Mint vs LMDE coverage
+
+The engine reads the live filesystem and the grub-title script from
+different places depending on edition (`Setup.is_mint`):
+
+| | Mint (Ubuntu/casper) | LMDE (Debian/live-boot) |
+|---|---|---|
+| squashfs / kernel | `/cdrom/casper` | `/run/live/medium/live` |
+| grub-title script | `ubuntu-system-adjustments` | `debian-system-adjustments` |
+
+**Integration tests currently cover the LMDE branch only**, because LMDE
+is the edition that ships `live-installer` today. Mint does not adopt it
+until Mint 23. The `is_mint=True` path-selection is covered by unit tests
+(`TestEditionPaths`) so a wrong path can't regress silently, but it is
+not yet exercised end-to-end.
+
+To add Mint-side integration coverage:
+
+- A Mint ISO's live session is **casper**-based, not Debian live-boot, so
+ `isotools.build_dev_iso` (which adds an overlay squashfs under `/live`
+ for live-boot to union-mount) needs a casper variant — casper layers
+ squashfs differently and the installer autostart differs.
+- Until a Mint 23 ISO exists, the Mint 22.x beta can stand in as a
+ *casper environment* proxy (it exercises the same `is_mint=True` paths)
+ even though Ubiquity, not live-installer, is its native installer.
+- This is best validated against the **Mint 23 beta** when it lands; its
+ live environment is the real target and may differ from 22.x.
+
+## CI
+
+Two GitHub Actions workflows (`.github/workflows/`):
+
+- **unit-tests.yml** — `pytest tests/unit` on Python 3.11–3.13, on every
+ push and pull request. Fast, no special hardware.
+- **integration-tests.yml** — the real VM installs. KVM-dependent and
+ slow, so it runs nightly and on manual dispatch (Actions → integration
+ tests → Run workflow), not per-push. A fast failure-mode gate runs
+ first; the install scenarios then run as a parallel matrix. Serial logs
+ and JUnit results are uploaded as artifacts (always for the failure
+ gate, on failure for installs) so a red run is debuggable without
+ reproducing locally. `workflow_dispatch` accepts a space-separated
+ `scenarios` input to run a subset.
+
+The shared setup (enable KVM, install QEMU/OVMF/swtpm, fetch + cache the
+ISO, build the dev ISO) lives in the composite action
+`.github/actions/vm-setup`.
diff --git a/tests/integration/README.md b/tests/integration/README.md
new file mode 100644
index 00000000..957c3761
--- /dev/null
+++ b/tests/integration/README.md
@@ -0,0 +1,57 @@
+# Integration test harness
+
+Boots the pinned LMDE ISO in QEMU/KVM, drives an unattended install via
+an answer file served over HTTP, reboots into the installed disk, and
+asserts on the result over SSH. Results are emitted as JUnit XML.
+
+## Requirements
+
+- KVM available (`/dev/kvm`) — falls back to slow TCG emulation without it
+- QEMU: `dnf install qemu-kvm` (EL) or `apt install qemu-system-x86` (Debian/Ubuntu)
+- UEFI firmware: `dnf install edk2-ovmf` / `apt install ovmf`
+- TPM scenarios: `dnf install swtpm swtpm-tools` / `apt install swtpm`
+- Python: `pip install pyyaml`
+
+## Fixtures
+
+The pinned ISO lives in `fixtures/` (not committed — ~3 GB):
+
+```sh
+cd fixtures
+curl -sLO https://mirrors.kernel.org/linuxmint/debian/sha256sum.txt
+curl -sLO https://mirrors.kernel.org/linuxmint/debian/lmde-7-cinnamon-64bit.iso
+sha256sum -c <(grep lmde-7-cinnamon-64bit.iso sha256sum.txt)
+```
+
+LMDE is the edition under test because it ships `live-installer` today;
+Mint 22.x still ships Ubiquity. When testing changes to the installer the
+working tree must be injected into the live session (squashfs remaster —
+tooling for that lands together with the headless driver).
+
+## Running
+
+```sh
+# Smoke mode: boot the ISO, confirm the VM stays up, tear down.
+# This is the only mode that passes until the headless driver exists.
+harness/run_scenario.py scenarios/bios-simple.yaml --smoke
+
+# Full mode (install + verify) — requires the headless driver:
+harness/run_scenario.py scenarios/bios-simple.yaml --junit results.xml
+```
+
+## Scenario format
+
+See `scenarios/bios-simple.yaml`. Key fields: `firmware`
+(bios | uefi | uefi-secureboot), `tpm`, `disk_gb`, `answer_file`
+(served over HTTP; the guest reaches the host at 10.0.2.2),
+`expect.serial_markers`, and `verify` (SSH assertions:
+`command` / `file_exists` / `mount_source`).
+
+## Design rules
+
+- Always verify by rebooting into the installed disk — never by
+ inspecting /target from the live session.
+- Fresh disk image per scenario; no snapshot reuse.
+- Assertions check outcomes (file exists, service active), never just
+ exit codes.
+- Every wait has a timeout; every assertion that can race has a retry.
diff --git a/tests/integration/fixtures/.gitkeep b/tests/integration/fixtures/.gitkeep
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/integration/harness/isotools.py b/tests/integration/harness/isotools.py
new file mode 100644
index 00000000..31e544a4
--- /dev/null
+++ b/tests/integration/harness/isotools.py
@@ -0,0 +1,362 @@
+"""ISO manipulation for the integration harness.
+
+Two jobs, both root-less:
+
+1. build_dev_iso(): produce a test ISO whose live session contains the
+ working tree's installer. Instead of remastering the (root-owned)
+ main squashfs, a small overlay squashfs with just our usr/ tree is
+ added to the ISO's /live directory — Debian live-boot union-mounts
+ every *.squashfs it finds there, so the overlay's files shadow the
+ originals. The overlay also carries the systemd unit that launches
+ the installer when live-installer.auto= is on the kernel cmdline.
+
+2. extract_boot_files(): pull vmlinuz/initrd out of an ISO so QEMU can
+ direct-kernel-boot it with our kernel arguments (the stock bootloader
+ menu can't be driven non-interactively).
+
+Requires squashfs-tools and xorriso.
+"""
+
+import shutil
+import subprocess
+import sys
+import zipfile
+from pathlib import Path
+
+# Sorts after "filesystem.squashfs"; live-boot stacks images in sorted
+# order with later images taking precedence in the union.
+OVERLAY_NAME = "zz-installer-dev.squashfs"
+
+
+class IsoToolsError(Exception):
+ pass
+
+
+def _require(binary, package_hint):
+ if shutil.which(binary) is None:
+ raise IsoToolsError(
+ f"{binary} not found — install it (e.g. dnf install "
+ f"{package_hint} / apt install {package_hint})"
+ )
+
+
+def _run(cmd):
+ result = subprocess.run(cmd, capture_output=True, text=True)
+ if result.returncode != 0:
+ raise IsoToolsError(
+ f"{cmd[0]} failed (rc={result.returncode}):\n"
+ f"{result.stdout[-1000:]}\n{result.stderr[-1000:]}"
+ )
+ return result
+
+
+def build_overlay_tree(source_tree, overlay_dir):
+ """Stage the working tree's installer files for the overlay squashfs."""
+ source_tree = Path(source_tree)
+ overlay_dir = Path(overlay_dir)
+ if overlay_dir.exists():
+ shutil.rmtree(overlay_dir)
+ for relative in ("usr/bin", "usr/lib/live-installer",
+ "usr/lib/systemd/system", "usr/share/live-installer"):
+ src = source_tree / relative
+ if not src.exists():
+ raise IsoToolsError(f"missing {src} — wrong --source-tree?")
+ shutil.copytree(src, overlay_dir / relative, symlinks=True,
+ ignore=shutil.ignore_patterns("__pycache__"))
+ return overlay_dir
+
+
+# Runtime deps of the headless installer that the stock live ISO does not
+# ship (they come from debian/control Depends when installed as a .deb).
+# LMDE 7 is Debian 13: Python 3.13 on x86_64.
+PYTHON_DEPS = ["pyyaml", "pydantic"]
+TARGET_PYTHON = "313"
+TARGET_PLATFORM = "manylinux_2_17_x86_64"
+
+
+def bundle_python_deps(overlay_dir, cache_dir):
+ """Download wheels for the target live system's Python and unpack them
+ into the overlay's dist-packages, standing in for the .deb Depends."""
+ wheel_dir = Path(cache_dir) / "wheels"
+ wheel_dir.mkdir(parents=True, exist_ok=True)
+ if not any(wheel_dir.glob("*.whl")):
+ _run([
+ sys.executable, "-m", "pip", "download", "-q",
+ "--only-binary", ":all:",
+ "--python-version", TARGET_PYTHON,
+ "--platform", TARGET_PLATFORM,
+ "-d", str(wheel_dir),
+ *PYTHON_DEPS,
+ ])
+ dist_packages = Path(overlay_dir) / "usr" / "lib" / "python3" / "dist-packages"
+ dist_packages.mkdir(parents=True, exist_ok=True)
+ for wheel in sorted(wheel_dir.glob("*.whl")):
+ with zipfile.ZipFile(wheel) as zf:
+ zf.extractall(dist_packages)
+ # zipfile does not preserve the +x/read bits the live system needs on
+ # shared objects; normalize everything to world-readable
+ for path in dist_packages.rglob("*"):
+ path.chmod(0o755 if path.is_dir() or path.suffix == ".so" else 0o644)
+
+
+def build_dev_iso(source_iso, source_tree, output_iso, workdir):
+ """Create output_iso = source_iso + overlay squashfs with our code."""
+ _require("mksquashfs", "squashfs-tools")
+ _require("xorriso", "xorriso")
+ workdir = Path(workdir)
+ workdir.mkdir(parents=True, exist_ok=True)
+
+ overlay_dir = build_overlay_tree(source_tree, workdir / "overlay")
+ bundle_python_deps(overlay_dir, workdir)
+ overlay_squash = workdir / OVERLAY_NAME
+ overlay_squash.unlink(missing_ok=True)
+ _run([
+ "mksquashfs", str(overlay_dir), str(overlay_squash),
+ "-all-root", # files must be root-owned in the live system
+ "-no-progress", "-quiet", "-comp", "zstd",
+ ])
+
+ output_iso = Path(output_iso)
+ output_iso.unlink(missing_ok=True)
+ # -boot_image any replay preserves the BIOS/UEFI boot records
+ _run([
+ "xorriso", "-indev", str(source_iso),
+ "-outdev", str(output_iso),
+ "-boot_image", "any", "replay",
+ "-map", str(overlay_squash), f"/live/{OVERLAY_NAME}",
+ ])
+ return output_iso
+
+
+def extract_boot_files(iso, outdir):
+ """Extract the live kernel and initrd from the ISO's /live directory.
+
+ Returns (kernel_path, initrd_path).
+ """
+ _require("xorriso", "xorriso")
+ outdir = Path(outdir)
+ outdir.mkdir(parents=True, exist_ok=True)
+
+ listing = _run([
+ "xorriso", "-indev", str(iso), "-find", "/live", "-type", "f",
+ ]).stdout
+ kernel_name = initrd_name = None
+ for line in listing.splitlines():
+ name = line.strip().strip("'")
+ base = name.rsplit("/", 1)[-1]
+ if base.startswith("vmlinuz"):
+ kernel_name = name
+ elif base.startswith("initrd"):
+ initrd_name = name
+ if not kernel_name or not initrd_name:
+ raise IsoToolsError(
+ f"could not find vmlinuz/initrd under /live in {iso}; "
+ f"listing:\n{listing}"
+ )
+
+ _run([
+ "xorriso", "-osirrox", "on", "-indev", str(iso),
+ "-extract", kernel_name, str(outdir / "vmlinuz"),
+ "-extract", initrd_name, str(outdir / "initrd.img"),
+ ])
+ # xorriso preserves the ISO's read-only mode bits; make them readable
+ for name in ("vmlinuz", "initrd.img"):
+ (outdir / name).chmod(0o644)
+ return outdir / "vmlinuz", outdir / "initrd.img"
+
+
+def extract_netboot_assets(iso, outdir):
+ """Extract vmlinuz, initrd, and every /live/*.squashfs for network boot.
+
+ Unlike extract_boot_files (which only needs the kernel/initrd because the
+ rootfs comes off the attached CD), a PXE boot has no media: the squashfs
+ images must be served too, so live-boot can fetch them over HTTP.
+
+ Returns (kernel_path, initrd_path, [squashfs_paths]) with the squashfs
+ list in sorted (live-boot stacking) order.
+ """
+ _require("xorriso", "xorriso")
+ outdir = Path(outdir)
+ (outdir / "live").mkdir(parents=True, exist_ok=True)
+
+ listing = _run([
+ "xorriso", "-indev", str(iso), "-find", "/live", "-type", "f",
+ ]).stdout
+ kernel_name = initrd_name = None
+ squashfs = [] # (iso_path, basename)
+ for line in listing.splitlines():
+ name = line.strip().strip("'")
+ base = name.rsplit("/", 1)[-1]
+ if base.startswith("vmlinuz"):
+ kernel_name = name
+ elif base.startswith("initrd"):
+ initrd_name = name
+ elif base.endswith(".squashfs"):
+ squashfs.append((name, base))
+ if not kernel_name or not initrd_name or not squashfs:
+ raise IsoToolsError(
+ f"could not find vmlinuz/initrd/*.squashfs under /live in {iso}; "
+ f"listing:\n{listing}"
+ )
+
+ extract = [
+ "xorriso", "-osirrox", "on", "-indev", str(iso),
+ "-extract", kernel_name, str(outdir / "vmlinuz"),
+ "-extract", initrd_name, str(outdir / "initrd.img"),
+ ]
+ for iso_path, base in squashfs:
+ extract += ["-extract", iso_path, str(outdir / "live" / base)]
+ _run(extract)
+
+ (outdir / "vmlinuz").chmod(0o644)
+ (outdir / "initrd.img").chmod(0o644)
+ sq_paths = []
+ for _iso_path, base in sorted(squashfs, key=lambda pair: pair[1]):
+ path = outdir / "live" / base
+ path.chmod(0o644)
+ sq_paths.append(path)
+ return outdir / "vmlinuz", outdir / "initrd.img", sq_paths
+
+
+def build_combined_squashfs(iso, source_tree, outdir, workdir):
+ """Build ONE squashfs = base rootfs + our dev code, for PXE.
+
+ On CD/USB boot live-boot stacks every *.squashfs on the medium, so our
+ code can ride in a small separate overlay squashfs. PXE can't do that:
+ live-boot's fetch= takes a single URL, so the two images must be merged.
+ We unsquashfs the base, lay the overlay tree on top (it shadows the base
+ exactly as the union mount would), and re-squash with -all-root so the
+ result is root-owned without needing real root.
+
+ Returns (kernel_path, initrd_path, combined_squashfs_path).
+ """
+ _require("unsquashfs", "squashfs-tools")
+ _require("mksquashfs", "squashfs-tools")
+ workdir = Path(workdir)
+ workdir.mkdir(parents=True, exist_ok=True)
+
+ kernel, initrd, squashes = extract_netboot_assets(iso, workdir / "assets")
+ base = next((p for p in squashes if p.name == "filesystem.squashfs"), None)
+ if base is None:
+ raise IsoToolsError(
+ f"no filesystem.squashfs to merge in {iso}; found "
+ f"{[p.name for p in squashes]}"
+ )
+
+ overlay = build_overlay_tree(source_tree, workdir / "overlay")
+ bundle_python_deps(overlay, workdir)
+
+ root = workdir / "root"
+ if root.exists():
+ shutil.rmtree(root)
+ # unsquashfs runs unprivileged: -no-xattrs skips security.* capability
+ # xattrs it cannot write as non-root (the installer runs as root and needs
+ # no file caps). It still returns rc=2 ("could not create some files")
+ # because it cannot mknod the rootfs's device/special files — but the live
+ # system's /dev is a devtmpfs created at boot, so those are not needed.
+ # Tolerate rc=2, then check the bulk of the tree really did extract.
+ # -all-root fixes ownership at re-squash, so root need not be real here.
+ result = subprocess.run(
+ ["unsquashfs", "-d", str(root), "-no-progress", "-no-xattrs",
+ str(base)],
+ capture_output=True, text=True,
+ )
+ if result.returncode not in (0, 2):
+ raise IsoToolsError(
+ f"unsquashfs failed (rc={result.returncode}):\n"
+ f"{result.stdout[-1000:]}\n{result.stderr[-1000:]}"
+ )
+ if not (root / "usr" / "bin").is_dir() or not (root / "etc").is_dir():
+ raise IsoToolsError(
+ f"unsquashfs produced an incomplete rootfs at {root}"
+ )
+ base.unlink(missing_ok=True) # free the ~GB base image before re-squashing
+ _run(["cp", "-a", f"{overlay}/.", f"{root}/"])
+
+ # A netboot medium has only the squashfs; the installer also needs the
+ # kernel, initrd, and the live-package-removal manifest, which on CD/USB
+ # sit next to the squashfs. Carry them in the rootfs where installer.py's
+ # netboot path (self.live_files) reads them.
+ bundle = root / "usr" / "lib" / "live-installer" / "netboot-live"
+ bundle.mkdir(parents=True, exist_ok=True)
+ shutil.copyfile(kernel, bundle / "vmlinuz")
+ shutil.copyfile(initrd, bundle / "initrd.img")
+ for manifest in ("filesystem.packages", "filesystem.packages-remove"):
+ try:
+ _run(["xorriso", "-osirrox", "on", "-indev", str(iso),
+ "-extract", f"/live/{manifest}", str(bundle / manifest)])
+ except IsoToolsError:
+ pass # absent on some images; the engine tolerates a missing one
+
+ # UEFI installs pull the signed bootloader packages from the medium's
+ # /pool, which a netboot medium lacks; carry them in the bundle too.
+ # (Keep in sync with installer.py EFI_PACKAGES.)
+ efi_pkgs = ("grub-efi-amd64", "grub-efi-amd64-bin",
+ "grub-efi-amd64-unsigned", "grub-efi-amd64-signed",
+ "shim-signed")
+ pool_main = bundle / "pool" / "main"
+ pool_main.mkdir(parents=True, exist_ok=True)
+ deb_listing = _run([
+ "xorriso", "-indev", str(iso), "-find", "/pool", "-name", "*.deb",
+ ]).stdout
+ deb_extract = ["xorriso", "-osirrox", "on", "-indev", str(iso)]
+ found_debs = 0
+ for line in deb_listing.splitlines():
+ name = line.strip().strip("'")
+ base = name.rsplit("/", 1)[-1]
+ if any(base.startswith(pkg + "_") for pkg in efi_pkgs):
+ deb_extract += ["-extract", name, str(pool_main / base)]
+ found_debs += 1
+ if found_debs:
+ _run(deb_extract)
+
+ # On a netboot the NIC is configured by the initramfs, which NetworkManager
+ # leaves unmanaged by default — so it never DHCPs and never writes real DNS,
+ # leaving the live image's 'nameserver dhcp' placeholder and breaking apt.
+ # Force NM to manage ethernet so DHCP populates resolv.conf before install
+ # (a netboot image has to carry this; on CD/USB NM already manages the NIC).
+ nm_conf = root / "etc" / "NetworkManager" / "conf.d" / "99-netboot-manage.conf"
+ nm_conf.parent.mkdir(parents=True, exist_ok=True)
+ nm_conf.write_text(
+ "[device-netboot-manage]\n"
+ "match-device=interface-name:en*,interface-name:eth*\n"
+ "managed=1\n"
+ )
+
+ outdir = Path(outdir)
+ outdir.mkdir(parents=True, exist_ok=True)
+ combined = outdir / "filesystem.squashfs"
+ combined.unlink(missing_ok=True)
+ _run([
+ "mksquashfs", str(root), str(combined), "-all-root", "-no-xattrs",
+ "-no-progress", "-quiet", "-comp", "zstd", "-noappend",
+ ])
+ shutil.rmtree(root, ignore_errors=True) # reclaim the unpacked tree
+ return kernel, initrd, combined
+
+
+def write_ipxe_script(path, http_base, squashfs_names, answer_url,
+ console="ttyS0"):
+ """Write a #!ipxe boot script for the PXE scenario.
+
+ The kernel and initrd come over TFTP (QEMU's built-in server); live-boot
+ then fetches the squashfs images over HTTP, and the installer fetches its
+ answer file over HTTP. So the whole boot is network-delivered, no media.
+ """
+ fetch = ",".join(f"{http_base}/live/{name}" for name in squashfs_names)
+ # ip=dhcp: live-boot's fetch= runs in the initramfs, which has its own
+ # network stack separate from iPXE's — it must DHCP an interface before it
+ # can pull the squashfs, or it fails with "Unable to find a live file
+ # system on the network".
+ cmdline = (
+ f"boot=live components ip=dhcp console={console} "
+ f"fetch={fetch} "
+ f"live-installer.auto={answer_url} live-installer.auto-insecure"
+ )
+ Path(path).write_text(
+ "#!ipxe\n"
+ f"kernel vmlinuz initrd=initrd.img {cmdline}\n"
+ "initrd initrd.img\n"
+ "boot\n"
+ )
+ return Path(path)
diff --git a/tests/integration/harness/make_test_iso.py b/tests/integration/harness/make_test_iso.py
new file mode 100755
index 00000000..2975a6ab
--- /dev/null
+++ b/tests/integration/harness/make_test_iso.py
@@ -0,0 +1,52 @@
+#!/usr/bin/env python3
+"""Build the dev test ISO: stock LMDE ISO + overlay squashfs containing
+the working tree's installer (and the live-installer-auto systemd unit).
+
+Usage:
+ make_test_iso.py [--iso fixtures/lmde-7-cinnamon-64bit.iso]
+ [--output fixtures/lmde-7-dev.iso]
+
+Rebuild whenever installer code changes; the overlay build takes a few
+seconds (only our files are compressed, the main squashfs is untouched).
+"""
+
+import argparse
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+import isotools # noqa: E402
+
+HERE = Path(__file__).resolve().parent
+INTEGRATION_DIR = HERE.parent
+REPO_ROOT = INTEGRATION_DIR.parents[1]
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "--iso",
+ default=str(INTEGRATION_DIR / "fixtures" / "lmde-7-cinnamon-64bit.iso"),
+ )
+ parser.add_argument(
+ "--output",
+ default=str(INTEGRATION_DIR / "fixtures" / "lmde-7-dev.iso"),
+ )
+ parser.add_argument("--source-tree", default=str(REPO_ROOT))
+ args = parser.parse_args()
+
+ if not Path(args.iso).exists():
+ sys.exit(f"source ISO not found: {args.iso}")
+
+ try:
+ output = isotools.build_dev_iso(
+ args.iso, args.source_tree, args.output,
+ INTEGRATION_DIR / ".work" / "iso-build",
+ )
+ except isotools.IsoToolsError as exc:
+ sys.exit(f"ERROR: {exc}")
+ print(f"dev ISO written: {output}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/integration/harness/run_scenario.py b/tests/integration/harness/run_scenario.py
new file mode 100755
index 00000000..735f8e08
--- /dev/null
+++ b/tests/integration/harness/run_scenario.py
@@ -0,0 +1,484 @@
+#!/usr/bin/env python3
+"""Run a live-installer integration scenario in a QEMU VM.
+
+Usage:
+ run_scenario.py scenarios/bios-simple.yaml [--iso PATH] [--smoke]
+
+A scenario boots the pinned ISO with an answer file served over HTTP,
+waits for the installer's completion marker on the serial console, then
+reboots into the installed disk and runs the scenario's verification
+assertions over SSH. Results are written as JUnit XML.
+
+--smoke skips the install/verify phases: it boots the ISO, confirms the
+VM stays up for a fixed period (i.e. KVM, firmware and ISO are sane),
+and tears down. This is the only mode that passes until the headless
+installer driver exists.
+"""
+
+import argparse
+import http.server
+import os
+import shutil
+import socket
+import socketserver
+import subprocess
+import sys
+import tempfile
+import threading
+import time
+from datetime import datetime, timezone
+from pathlib import Path
+from xml.sax.saxutils import escape
+
+import yaml
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+import isotools # noqa: E402
+import verify_install # noqa: E402
+import vm # noqa: E402
+
+HERE = Path(__file__).resolve().parent
+INTEGRATION_DIR = HERE.parent
+SOURCE_TREE = INTEGRATION_DIR.parent.parent # repo root: usr/lib/live-installer/…
+DEFAULT_ISO = INTEGRATION_DIR / "fixtures" / "lmde-7-cinnamon-64bit.iso"
+DEFAULT_DEV_ISO = INTEGRATION_DIR / "fixtures" / "lmde-7-dev.iso"
+WORK_ROOT = INTEGRATION_DIR / ".work"
+
+DEFAULTS = {
+ "firmware": "bios",
+ "tpm": False,
+ "memory_mb": 4096,
+ "cpus": 2,
+ "disk_gb": 25,
+ "install_timeout_s": 1800,
+ "boot_timeout_s": 300,
+ # success/failure markers printed by the headless driver
+ "expect": {
+ "serial_markers": ["Automated installation complete"],
+ "failure_markers": ["Automated installation FAILED"],
+ },
+ "verify": [],
+}
+
+
+# Strings live-boot/the kernel print when the system never reaches the
+# installer; treated as an immediate install failure so a broken boot does
+# not wait out the full install timeout.
+BOOT_FAILED_MARKERS = [
+ "Unable to find a live file system",
+ "BOOT FAILED",
+ "Kernel panic",
+]
+
+
+class _QuietHTTPHandler(http.server.SimpleHTTPRequestHandler):
+ def log_message(self, fmt, *args):
+ pass
+
+
+class _DualStackServer(socketserver.TCPServer):
+ """Listen on :: with IPV6_V6ONLY off, so the same server is reachable from
+ the guest over both IPv4 (10.0.2.2) and IPv6 (the QEMU user-net host)."""
+ address_family = socket.AF_INET6
+
+ def server_bind(self):
+ self.socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0)
+ super().server_bind()
+
+
+def serve_directory(directory):
+ """Serve `directory` over HTTP on an ephemeral port, dual-stack."""
+ handler = lambda *a, **kw: _QuietHTTPHandler(*a, directory=str(directory), **kw)
+ httpd = _DualStackServer(("::", 0), handler)
+ thread = threading.Thread(target=httpd.serve_forever, daemon=True)
+ thread.start()
+ return httpd, httpd.server_address[1]
+
+
+def load_scenario(path):
+ scenario = dict(DEFAULTS)
+ with open(path) as f:
+ data = yaml.safe_load(f) or {}
+ scenario.update(data)
+ scenario.setdefault("name", Path(path).stem)
+ return scenario
+
+
+def write_junit(path, suite_name, cases, elapsed_s):
+ """cases: list of (name, ok, detail)"""
+ failures = sum(1 for _, ok, _ in cases if not ok)
+ lines = [
+ '',
+ f'',
+ ]
+ for name, ok, detail in cases:
+ lines.append(f' ')
+ if not ok:
+ lines.append(f" {escape(detail or '')}")
+ lines.append(" ")
+ lines.append("")
+ Path(path).write_text("\n".join(lines) + "\n")
+
+
+def run_smoke(scenario, iso, workdir, smoke_seconds):
+ machine = vm.VM(
+ workdir,
+ name=scenario["name"],
+ memory_mb=scenario["memory_mb"],
+ cpus=scenario["cpus"],
+ )
+ machine.create_disk(scenario["disk_gb"])
+ machine.start(iso=iso, boot="cdrom", firmware=scenario["firmware"],
+ tpm=scenario["tpm"])
+ try:
+ print(f"[smoke] VM booted, holding for {smoke_seconds}s...")
+ deadline = time.monotonic() + smoke_seconds
+ while time.monotonic() < deadline:
+ if not machine.alive():
+ stderr = machine.process.stderr.read().decode(errors="replace")
+ return [("smoke-boot", False,
+ f"VM exited prematurely: {stderr[-2000:]}")]
+ time.sleep(2)
+ return [("smoke-boot", True, "")]
+ finally:
+ machine.stop()
+
+
+def generate_ssh_key(workdir):
+ """Per-run keypair; the public key is injected into the answer file."""
+ key = workdir / "id_ed25519"
+ subprocess.run(
+ ["ssh-keygen", "-t", "ed25519", "-N", "", "-q", "-f", str(key),
+ "-C", "live-installer-harness"],
+ check=True,
+ )
+ return key, (workdir / "id_ed25519.pub").read_text().strip()
+
+
+def _fill_ca_cert_placeholder(answer):
+ """Replace a `{ca_cert}` placeholder in ca_certs.trusted with a freshly
+ generated self-signed CA cert, so the ca_certs path can be tested end to
+ end without committing a real cert. The runner has openssl."""
+ marker = "LI_HARNESS_CA_CERT_PLACEHOLDER"
+ cc = answer.get("ca_certs")
+ trusted = (cc or {}).get("trusted") or []
+ if not any(marker in t for t in trusted):
+ return
+ d = tempfile.mkdtemp(prefix="li-ca-")
+ crt = os.path.join(d, "ca.pem")
+ subprocess.run(
+ ["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes",
+ "-keyout", os.path.join(d, "key.pem"), "-out", crt,
+ "-days", "3650", "-subj", "/CN=LI Integration Test CA"],
+ check=True, capture_output=True)
+ pem = open(crt).read()
+ cc["trusted"] = [pem if marker in t else t for t in trusted]
+
+
+def stage_answer_file(scenario_dir, answer_rel, serve_dir, pubkey, base_url):
+ """Copy the scenario's answer file into the served directory: the
+ {server} placeholder is expanded to the harness HTTP server's base URL
+ (for auxiliary files like LUKS keyfiles), and the harness SSH public
+ key is added to the first user so verify can log in. Auxiliary files
+ next to the answer file are copied verbatim."""
+ target = serve_dir / answer_rel
+ target.parent.mkdir(parents=True, exist_ok=True)
+ text = (scenario_dir / answer_rel).read_text().replace("{server}", base_url)
+ answer = yaml.safe_load(text)
+ user = answer["users"][0]
+ user.setdefault("ssh_authorized_keys", []).append(pubkey)
+ _fill_ca_cert_placeholder(answer)
+ with open(target, "w") as f:
+ yaml.safe_dump(answer, f, sort_keys=False)
+ for aux in (scenario_dir / answer_rel).parent.iterdir():
+ if aux.is_file() and aux.name != Path(answer_rel).name:
+ shutil.copyfile(aux, target.parent / aux.name)
+ return serve_dir
+
+
+def run_full(scenario, iso, workdir, scenario_dir):
+ cases = []
+ answer = scenario.get("answer_file")
+ if not answer:
+ return [("install", False, "scenario has no answer_file")]
+
+ # Failure-mode scenarios assert that bad input fails fast and cleanly:
+ # the failure marker is the expected outcome and there is no phase 2.
+ # The answer file is served verbatim (it may be deliberately malformed).
+ expect_failure = scenario["expect"].get("outcome") == "failure"
+ if expect_failure:
+ ssh_key = None
+ serve_dir = scenario_dir
+ httpd, http_port = serve_directory(serve_dir)
+ else:
+ # the server starts first so its port can be substituted into the
+ # answer file ({server} placeholder); files appear afterwards
+ serve_dir = workdir / "serve"
+ serve_dir.mkdir(parents=True, exist_ok=True)
+ httpd, http_port = serve_directory(serve_dir)
+ ssh_key, pubkey = generate_ssh_key(workdir)
+ stage_answer_file(scenario_dir, answer, serve_dir, pubkey,
+ f"http://10.0.2.2:{http_port}")
+ netboot = bool(scenario.get("netboot"))
+
+ try:
+ machine = vm.VM(
+ workdir,
+ name=scenario["name"],
+ memory_mb=scenario["memory_mb"],
+ cpus=scenario["cpus"],
+ )
+ # Extra fixed-MAC NICs for static-IP/VLAN scenarios (bound by the
+ # answer file via match.macaddress). Present in both install and boot
+ # phases so the keyfile's target device exists when NM brings it up.
+ machine.extra_nics = list(scenario.get("extra_nics") or [])
+ # Disk topology. Default: one unnamed disk. A scenario may instead
+ # declare `disks: [{size_gb, serial?}, ...]`; the first is primary.
+ disks = scenario.get("disks")
+ if disks:
+ machine.create_disk(disks[0]["size_gb"], disks[0].get("serial"))
+ for extra in disks[1:]:
+ machine.add_disk(extra["size_gb"], extra.get("serial"))
+ else:
+ machine.create_disk(scenario["disk_gb"])
+ ssh_port = vm.free_port()
+ # auto-insecure: the answer file travels over QEMU's host-only user
+ # network; there is no TLS endpoint to offer. An ipv6 scenario reaches
+ # the same dual-stack server at the QEMU user-net IPv6 host (fd00::2).
+ ipv6 = bool(scenario.get("ipv6"))
+ http_host = "[fd00::2]" if ipv6 else "10.0.2.2"
+ http_base = f"http://{http_host}:{http_port}"
+ answer_url = f"{http_base}/{answer}"
+
+ if netboot:
+ # No install media: build one combined squashfs (live-boot's
+ # fetch= takes a single URL, so our dev code is merged into the
+ # base image), served over HTTP; the installer fetches its answer
+ # file over HTTP too.
+ kernel, initrd, squashfs = isotools.build_combined_squashfs(
+ iso, SOURCE_TREE, serve_dir / "live", workdir / "netboot")
+
+ if netboot and ipv6:
+ # Enabling IPv6 in QEMU's user-net breaks the firmware PXE boot
+ # (slirp has no DHCPv6 boot-URL and the v4 PXE ROM stalls), so boot
+ # the kernel directly instead — and still fetch the rootfs and the
+ # answer file over IPv6, which is the part our code owns.
+ append = (
+ "boot=live components ip=dhcp console=ttyS0 "
+ f"fetch={http_base}/live/{squashfs.name} "
+ f"live-installer.auto={answer_url} live-installer.auto-insecure"
+ )
+ machine.start(boot="disk", firmware=scenario["firmware"],
+ tpm=scenario["tpm"], ssh_port=ssh_port,
+ kernel=kernel, initrd=initrd, append=append,
+ ipv6=True)
+ elif netboot:
+ # Phase 1 (PXE): kernel/initrd over TFTP, live-boot fetches the
+ # squashfs over HTTP.
+ tftp_dir = workdir / "tftp"
+ tftp_dir.mkdir(parents=True, exist_ok=True)
+ shutil.copyfile(kernel, tftp_dir / "vmlinuz")
+ shutil.copyfile(initrd, tftp_dir / "initrd.img")
+ isotools.write_ipxe_script(
+ tftp_dir / "boot.ipxe", http_base, [squashfs.name], answer_url)
+ # BIOS: the NIC's iPXE option ROM runs boot.ipxe directly. UEFI:
+ # OVMF needs an EFI binary, so it loads ipxe.efi (built in
+ # vm-setup), whose embedded script chainloads boot.ipxe over TFTP.
+ if scenario["firmware"].startswith("uefi"):
+ shutil.copyfile(INTEGRATION_DIR / "fixtures" / "ipxe.efi",
+ tftp_dir / "ipxe.efi")
+ bootfile = "ipxe.efi"
+ else:
+ bootfile = "boot.ipxe"
+ machine.start(boot="net", firmware=scenario["firmware"],
+ tpm=scenario["tpm"], ssh_port=ssh_port,
+ tftp_dir=str(tftp_dir), bootfile=bootfile)
+ else:
+ # Phase 1: direct-kernel boot of the live ISO (rootfs off the
+ # attached CD) with the answer-file URL on the kernel cmdline.
+ kernel, initrd = isotools.extract_boot_files(iso, workdir / "boot")
+ append = (
+ "boot=live components console=ttyS0 "
+ f"live-installer.auto={answer_url} "
+ "live-installer.auto-insecure"
+ )
+ machine.start(iso=iso, boot="cdrom", firmware=scenario["firmware"],
+ tpm=scenario["tpm"], ssh_port=ssh_port,
+ kernel=kernel, initrd=initrd, append=append)
+ try:
+ success = scenario["expect"]["serial_markers"]
+ failure = scenario["expect"].get("failure_markers", [])
+ marker = machine.wait_serial(
+ success + failure + BOOT_FAILED_MARKERS,
+ scenario["install_timeout_s"],
+ )
+ # A boot that dies before the installer runs (e.g. live-boot can't
+ # fetch the rootfs over PXE) must fail fast, not wait out the full
+ # install timeout for a marker that will never come.
+ if marker in BOOT_FAILED_MARKERS:
+ label = "fails-cleanly" if expect_failure else "install"
+ cases.append((label, False,
+ f"boot failed before the installer ran: {marker}"))
+ return cases
+ if expect_failure:
+ if marker in failure:
+ cases.append(("fails-cleanly", True, f"matched: {marker}"))
+ else:
+ cases.append(("fails-cleanly", False,
+ f"unexpectedly succeeded: {marker}"))
+ return cases
+ if marker in failure:
+ cases.append(("install", False,
+ f"installer reported failure: {marker}"))
+ return cases
+ cases.append(("install", True, f"matched: {marker}"))
+ except (TimeoutError, vm.VMError) as exc:
+ name = "fails-cleanly" if expect_failure else "install"
+ cases.append((name, False, str(exc)))
+ return cases
+ finally:
+ machine.stop()
+
+ # Scenarios whose installed system cannot boot unattended yet
+ # (e.g. LUKS passphrase prompt at the initramfs) stop here until
+ # the harness can interact with the serial console.
+ if scenario.get("skip_boot_phase"):
+ cases.append(("boot-verify", True,
+ "SKIPPED: " + str(scenario.get("skip_boot_reason",
+ "skip_boot_phase set"))))
+ return cases
+
+ # Preserve the install-phase serial log; phase 2 truncates it.
+ if machine.serial_log.exists():
+ shutil.copyfile(machine.serial_log,
+ machine.serial_log.with_suffix(".install.log"))
+
+ # Phase 2: boot the installed system and verify over SSH. In a
+ # multi-disk VM, boot the disk the OS landed on (boot_disk_serial).
+ machine.start(boot="disk", firmware=scenario["firmware"],
+ tpm=scenario["tpm"], ssh_port=ssh_port,
+ boot_serial=scenario.get("boot_disk_serial"))
+ try:
+ # passphrase_source: prompt-on-first-boot — the first boot
+ # auto-unlocks (throwaway key in the initramfs), a one-shot prompts
+ # to set + confirm the real passphrase on serial, then the service
+ # rekeys and reboots; the second boot prompts at the initramfs.
+ rekey = scenario.get("first_boot_rekey")
+ if rekey:
+ pw = (scenario_dir / rekey["passphrase_file"]).read_text().rstrip("\n")
+ try:
+ machine.wait_serial([rekey["set_prompt"]],
+ rekey.get("set_timeout_s", 300))
+ time.sleep(1)
+ machine.send_serial(pw + "\n")
+ machine.wait_serial([rekey["confirm_prompt"]], 60)
+ time.sleep(1)
+ machine.send_serial(pw + "\n")
+ cases.append(("rekey-set-passphrase", True, ""))
+ # The service rekeys and reboots; the second boot prompts at
+ # the initramfs (boot_prompt is distinct from the set prompt,
+ # and only appears on this second boot since the first
+ # auto-unlocked).
+ machine.wait_serial([rekey["boot_prompt"]],
+ rekey.get("boot_timeout_s", 300))
+ time.sleep(1)
+ machine.send_serial(pw + "\n")
+ cases.append(("rekey-second-boot-unlock", True, ""))
+ except (TimeoutError, vm.VMError) as exc:
+ cases.append(("first-boot-rekey", False,
+ f"rekey flow failed: {exc}"))
+ return cases
+
+ # Encrypted installs prompt for the LUKS passphrase at the
+ # initramfs; type it over serial as a real admin would via SOL.
+ unlock = scenario.get("boot_unlock")
+ if unlock:
+ passphrase = (scenario_dir / unlock["passphrase_file"]).read_text()
+ try:
+ machine.wait_serial([unlock["prompt"]],
+ unlock.get("timeout_s", 180))
+ time.sleep(1)
+ machine.send_serial(passphrase.rstrip("\n") + "\n")
+ cases.append(("luks-unlock", True, ""))
+ except (TimeoutError, vm.VMError) as exc:
+ cases.append(("luks-unlock", False,
+ f"unlock prompt never appeared: {exc}"))
+ return cases
+ if not verify_install.wait_for_ssh("127.0.0.1", ssh_port,
+ scenario["boot_timeout_s"]):
+ cases.append(("first-boot", False,
+ "SSH never came up on installed system"))
+ return cases
+ cases.append(("first-boot", True, ""))
+ ssh_cfg = dict(scenario.get("ssh") or {})
+ ssh_cfg["key"] = str(ssh_key)
+ cases.extend(
+ verify_install.run_assertions(
+ "127.0.0.1", ssh_port, ssh_cfg, scenario["verify"],
+ )
+ )
+ finally:
+ machine.stop()
+ finally:
+ httpd.shutdown()
+ return cases
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("scenario", help="scenario YAML file")
+ parser.add_argument(
+ "--iso", default=None,
+ help="ISO to boot (default: stock ISO for --smoke, dev ISO "
+ "from make_test_iso.py for full runs)",
+ )
+ parser.add_argument("--smoke", action="store_true",
+ help="boot the ISO and verify the VM stays up")
+ parser.add_argument("--smoke-seconds", type=int, default=90)
+ parser.add_argument("--junit", help="write JUnit XML to this path")
+ parser.add_argument("--keep", action="store_true",
+ help="keep the work directory after the run")
+ args = parser.parse_args()
+
+ scenario_path = Path(args.scenario)
+ scenario = load_scenario(scenario_path)
+ if args.iso:
+ iso = Path(args.iso)
+ elif args.smoke:
+ iso = DEFAULT_ISO
+ else:
+ iso = DEFAULT_DEV_ISO
+ if not iso.exists():
+ hint = ("download it to fixtures/ first" if args.smoke or args.iso
+ else "build it with harness/make_test_iso.py first")
+ sys.exit(f"ISO not found: {iso} ({hint})")
+
+ workdir = WORK_ROOT / scenario["name"]
+ if workdir.exists():
+ shutil.rmtree(workdir)
+ workdir.mkdir(parents=True)
+
+ started = time.monotonic()
+ if args.smoke:
+ cases = run_smoke(scenario, iso, workdir, args.smoke_seconds)
+ else:
+ cases = run_full(scenario, iso, workdir, scenario_path.parent)
+ elapsed = time.monotonic() - started
+
+ failed = False
+ for name, ok, detail in cases:
+ status = "PASS" if ok else "FAIL"
+ print(f"[{status}] {scenario['name']}/{name}" + (f": {detail}" if detail and not ok else ""))
+ failed = failed or not ok
+
+ if args.junit:
+ write_junit(args.junit, scenario["name"], cases, elapsed)
+ if not args.keep and not failed:
+ shutil.rmtree(workdir, ignore_errors=True)
+ sys.exit(1 if failed else 0)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/integration/harness/verify_install.py b/tests/integration/harness/verify_install.py
new file mode 100644
index 00000000..923bd936
--- /dev/null
+++ b/tests/integration/harness/verify_install.py
@@ -0,0 +1,94 @@
+"""SSH-based assertions against an installed system.
+
+Used by run_scenario.py after rebooting into the installed disk. The
+answer file used during installation is expected to have created a test
+user, installed openssh-server and authorized the harness's SSH key —
+that wiring lands together with the headless installer driver.
+"""
+
+import socket
+import subprocess
+import time
+
+
+def wait_for_ssh(host, port, timeout_s, poll_interval=3):
+ """Wait until something speaking SSH accepts on host:port."""
+ deadline = time.monotonic() + timeout_s
+ while time.monotonic() < deadline:
+ try:
+ with socket.create_connection((host, port), timeout=5) as sock:
+ banner = sock.recv(64)
+ if banner.startswith(b"SSH-"):
+ return True
+ except OSError:
+ pass
+ time.sleep(poll_interval)
+ return False
+
+
+def _ssh_base(host, port, ssh_cfg):
+ cmd = [
+ "ssh",
+ "-p", str(port),
+ "-o", "StrictHostKeyChecking=no",
+ "-o", "UserKnownHostsFile=/dev/null",
+ "-o", "ConnectTimeout=10",
+ "-o", "BatchMode=yes",
+ "-o", "LogLevel=ERROR",
+ ]
+ if ssh_cfg.get("key"):
+ cmd += ["-i", str(ssh_cfg["key"])]
+ user = ssh_cfg.get("user", "testuser")
+ return cmd + [f"{user}@{host}"]
+
+
+def _run(host, port, ssh_cfg, command, timeout_s=60):
+ return subprocess.run(
+ _ssh_base(host, port, ssh_cfg) + [command],
+ capture_output=True,
+ text=True,
+ timeout=timeout_s,
+ )
+
+
+def run_assertions(host, port, ssh_cfg, assertions):
+ """Run scenario `verify` entries; returns [(name, ok, detail), ...].
+
+ Supported assertion types:
+ - command: run a shell command; check expect_rc (default 0)
+ and/or expect_substring against stdout
+ - file_exists: path must exist on the installed system
+ - mount_source: mountpoint must be backed by a source matching
+ a substring (e.g. / on /dev/mapper/ for LUKS)
+ """
+ results = []
+ for index, entry in enumerate(assertions):
+ kind = entry.get("type", "command")
+ name = entry.get("name", f"{kind}-{index}")
+ try:
+ if kind == "command":
+ proc = _run(host, port, ssh_cfg, entry["command"])
+ ok = proc.returncode == entry.get("expect_rc", 0)
+ detail = f"rc={proc.returncode}"
+ want = entry.get("expect_substring")
+ if ok and want is not None:
+ ok = want in proc.stdout
+ if not ok:
+ detail = f"{want!r} not in stdout: {proc.stdout[-500:]!r}"
+ elif kind == "file_exists":
+ proc = _run(host, port, ssh_cfg, f"test -e {entry['path']}")
+ ok = proc.returncode == 0
+ detail = f"{entry['path']} missing" if not ok else ""
+ elif kind == "mount_source":
+ proc = _run(
+ host, port, ssh_cfg,
+ f"findmnt -n -o SOURCE {entry['mountpoint']}",
+ )
+ ok = proc.returncode == 0 and entry["source_contains"] in proc.stdout
+ detail = f"source={proc.stdout.strip()!r}"
+ else:
+ ok, detail = False, f"unknown assertion type {kind!r}"
+ except subprocess.TimeoutExpired:
+ ok, detail = False, "ssh command timed out"
+ results.append((name, ok, detail))
+ return results
diff --git a/tests/integration/harness/vm.py b/tests/integration/harness/vm.py
new file mode 100644
index 00000000..5f25b671
--- /dev/null
+++ b/tests/integration/harness/vm.py
@@ -0,0 +1,405 @@
+"""QEMU/KVM virtual machine management for live-installer integration tests.
+
+Stdlib-only. Supports BIOS, UEFI (OVMF) and UEFI+SecureBoot firmware,
+an emulated TPM2 via swtpm, serial-console capture to a log file, and
+user-mode networking with an SSH host-forward. Firmware and binary
+paths cover both EL (AlmaLinux/Fedora) and Debian/Ubuntu layouts so the
+harness runs on developer machines and CI runners alike.
+"""
+
+import os
+import re
+import shutil
+import socket
+import subprocess
+import tempfile
+import threading
+import time
+from pathlib import Path
+
+_EL_OVMF = Path("/usr/share/edk2/ovmf")
+_DEB_OVMF = Path("/usr/share/OVMF")
+
+# (code, vars) candidate pairs, first existing pair wins
+_FIRMWARE_PATHS = {
+ "uefi": [
+ (_EL_OVMF / "OVMF_CODE.fd", _EL_OVMF / "OVMF_VARS.fd"),
+ (_DEB_OVMF / "OVMF_CODE_4M.fd", _DEB_OVMF / "OVMF_VARS_4M.fd"),
+ (_DEB_OVMF / "OVMF_CODE.fd", _DEB_OVMF / "OVMF_VARS.fd"),
+ ],
+ "uefi-secureboot": [
+ (_EL_OVMF / "OVMF_CODE.secboot.fd", _EL_OVMF / "OVMF_VARS.secboot.fd"),
+ (_DEB_OVMF / "OVMF_CODE_4M.ms.fd", _DEB_OVMF / "OVMF_VARS_4M.ms.fd"),
+ ],
+}
+
+
+class VMError(Exception):
+ pass
+
+
+def find_qemu():
+ """Locate the system emulator binary."""
+ candidates = [os.environ.get("QEMU_BIN")]
+ candidates.append(shutil.which("qemu-system-x86_64"))
+ candidates.append("/usr/libexec/qemu-kvm") # EL packaging
+ for candidate in candidates:
+ if candidate and Path(candidate).exists():
+ return candidate
+ raise VMError(
+ "No QEMU binary found. Install qemu-system-x86 (Debian/Ubuntu) or "
+ "qemu-kvm (EL), or set QEMU_BIN."
+ )
+
+
+def find_firmware(firmware):
+ """Return (code, vars) firmware image paths for 'uefi'/'uefi-secureboot'."""
+ for code, vars_template in _FIRMWARE_PATHS[firmware]:
+ if code.exists() and vars_template.exists():
+ return code, vars_template
+ raise VMError(
+ f"No OVMF firmware found for {firmware!r}. Install edk2-ovmf (EL) "
+ "or ovmf (Debian/Ubuntu)."
+ )
+
+
+def free_port():
+ with socket.socket() as sock:
+ sock.bind(("127.0.0.1", 0))
+ return sock.getsockname()[1]
+
+
+def kvm_available():
+ return os.access("/dev/kvm", os.R_OK | os.W_OK)
+
+
+class VM:
+ """A single QEMU machine bound to a working directory."""
+
+ def __init__(self, workdir, name="vm", memory_mb=4096, cpus=2):
+ self.workdir = Path(workdir)
+ self.workdir.mkdir(parents=True, exist_ok=True)
+ self.name = name
+ self.memory_mb = memory_mb
+ self.cpus = cpus
+ self.disk = self.workdir / f"{name}.qcow2"
+ self.serial_log = self.workdir / f"{name}-serial.log"
+ self.process = None
+ self._swtpm = None
+ self._serial_sock = None
+ self._serial_reader = None
+ self._serial_stop = None
+ self._serial_dir = None
+ self._disks = [] # list of (path, serial)
+ # Extra NICs (besides the SSH/boot NIC), each pinned to a fixed MAC so
+ # the answer file can bind a static/VLAN config to it by macaddress.
+ # Each gets its own isolated user-net (no hostfwd), so it never
+ # competes with the primary NIC's port-forwarded SSH.
+ self.extra_nics = [] # list of MAC strings
+
+ # -- setup ------------------------------------------------------------
+
+ def create_disk(self, size_gb, serial=None):
+ """Create the primary target disk. An optional serial surfaces in
+ the guest as /dev/disk/by-id/virtio-, for by-id matching."""
+ subprocess.run(
+ ["qemu-img", "create", "-f", "qcow2", str(self.disk), f"{size_gb}G"],
+ check=True,
+ capture_output=True,
+ )
+ self._disks = [(self.disk, serial)]
+
+ def add_disk(self, size_gb, serial):
+ """Attach an additional disk (multi-disk by-id scenarios)."""
+ path = self.workdir / f"{self.name}-disk{len(self._disks)}.qcow2"
+ subprocess.run(
+ ["qemu-img", "create", "-f", "qcow2", str(path), f"{size_gb}G"],
+ check=True,
+ capture_output=True,
+ )
+ self._disks.append((path, serial))
+ return path
+
+ def _start_swtpm(self):
+ tpm_dir = self.workdir / "tpm"
+ tpm_dir.mkdir(exist_ok=True)
+ self._tpm_sock = tpm_dir / "swtpm.sock"
+ self._swtpm = subprocess.Popen(
+ [
+ "swtpm", "socket", "--tpm2",
+ "--tpmstate", f"dir={tpm_dir}",
+ "--ctrl", f"type=unixio,path={self._tpm_sock}",
+ ],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+ for _ in range(50):
+ if self._tpm_sock.exists():
+ return
+ time.sleep(0.1)
+ raise VMError("swtpm socket did not appear")
+
+ # -- lifecycle --------------------------------------------------------
+
+ def start(
+ self,
+ *,
+ iso=None,
+ boot="cdrom", # cdrom | disk
+ firmware="bios", # bios | uefi | uefi-secureboot
+ tpm=False,
+ ssh_port=None,
+ kernel=None,
+ initrd=None,
+ append=None,
+ boot_serial=None,
+ tftp_dir=None,
+ bootfile=None,
+ ipv6=False,
+ ):
+ if self.process is not None:
+ raise VMError("VM already running")
+ self.serial_log.unlink(missing_ok=True)
+
+ # Serial goes to a unix socket QEMU listens on; a reader thread tees
+ # everything to serial_log (so wait_serial still works on the file)
+ # and the same socket carries keystrokes back via send_serial — the
+ # channel a real admin drives over IPMI Serial-over-LAN.
+ #
+ # The socket lives in a short tempdir, NOT the workdir: AF_UNIX paths
+ # are capped at ~108 bytes and a deep workdir (e.g. on a CI runner)
+ # blows past it, so QEMU silently fails to create the socket.
+ self._serial_dir = tempfile.mkdtemp(prefix="li-ser-")
+ serial_path = Path(self._serial_dir) / "s"
+ cmd = [
+ find_qemu(),
+ "-name", self.name,
+ "-machine", "q35" + (",smm=on" if firmware == "uefi-secureboot" else ""),
+ "-m", str(self.memory_mb),
+ "-smp", str(self.cpus),
+ "-display", "none",
+ "-chardev", f"socket,id=ser0,path={serial_path},server=on,wait=off",
+ "-serial", "chardev:ser0",
+ "-monitor", "none",
+ ]
+ self._serial_path = serial_path
+ if kvm_available():
+ cmd += ["-accel", "kvm", "-cpu", "host"]
+ else: # slow, but lets the harness run where nesting is unavailable
+ cmd += ["-accel", "tcg", "-cpu", "max"]
+
+ if firmware in ("uefi", "uefi-secureboot"):
+ code, vars_template = find_firmware(firmware)
+ vars_copy = self.workdir / f"{self.name}-VARS.fd"
+ if not vars_copy.exists():
+ shutil.copyfile(vars_template, vars_copy)
+ cmd += [
+ "-drive", f"if=pflash,format=raw,readonly=on,file={code}",
+ "-drive", f"if=pflash,format=raw,file={vars_copy}",
+ ]
+ if firmware == "uefi-secureboot":
+ cmd += ["-global", "driver=cfi.pflash01,property=secure,value=on"]
+
+ # Attach disks via explicit blockdev+device so each can carry a
+ # serial (-> /dev/disk/by-id/virtio- in the guest). Fall
+ # back to the legacy single-drive form when create_disk was never
+ # called but the qcow2 exists (boot-from-disk phase 2).
+ disks = self._disks
+ if not disks and self.disk.exists():
+ disks = [(self.disk, None)]
+ for index, (path, serial) in enumerate(disks):
+ if not Path(path).exists():
+ continue
+ node = f"disk{index}"
+ cmd += ["-blockdev",
+ f"driver=qcow2,node-name={node},"
+ f"file.driver=file,file.filename={path}"]
+ dev = f"virtio-blk-pci,drive={node}"
+ if serial:
+ dev += f",serial={serial}"
+ # When booting from disk in a multi-disk VM, the firmware must
+ # boot the disk the OS was installed to — not whichever disk
+ # enumerates first. Pin it with bootindex.
+ if boot == "disk" and boot_serial is not None and serial == boot_serial:
+ dev += ",bootindex=0"
+ cmd += ["-device", dev]
+ if iso:
+ cmd += ["-cdrom", str(iso)]
+ cmd += ["-boot", {"cdrom": "d", "disk": "c", "net": "n"}[boot]]
+
+ # QEMU's user-mode network has a built-in TFTP/BOOTP server and the
+ # NIC carries an iPXE option ROM, so a full PXE boot needs no
+ # privileged host networking: the ROM DHCPs, TFTPs `bootfile`, and
+ # (for a #!ipxe script) runs it. The squashfs and answer file are
+ # then pulled over HTTP from the harness server at 10.0.2.2.
+ netdev = "user,id=net0"
+ if ssh_port:
+ netdev += f",hostfwd=tcp:127.0.0.1:{ssh_port}-:22"
+ if ipv6:
+ # Give the guest an IPv6 ULA (host at fd00::2) alongside IPv4, so a
+ # scenario can fetch the rootfs and answer file over IPv6. (PXE
+ # firmware boot stays IPv4: slirp has no DHCPv6 boot-URL option.)
+ netdev += ",ipv6=on,ipv6-net=fd00::/64"
+ if tftp_dir:
+ netdev += f",tftp={tftp_dir},bootfile={bootfile}"
+ netcard = "virtio-net-pci,netdev=net0"
+ # On UEFI, OVMF honours bootindex rather than the legacy -boot order,
+ # so the NIC must carry one to be tried for PXE. (BIOS PXE already works
+ # via -boot n and the NIC option ROM, so leave it alone.)
+ if boot == "net" and firmware in ("uefi", "uefi-secureboot"):
+ netcard += ",bootindex=0"
+ cmd += ["-netdev", netdev, "-device", netcard]
+
+ # Additional NICs with fixed MACs on isolated user-nets. They carry no
+ # boot/SSH role — they exist so the installed system has a stable
+ # hardware address to bind a static/VLAN connection to.
+ for index, mac in enumerate(self.extra_nics, start=1):
+ nid = f"net{index}"
+ cmd += ["-netdev", f"user,id={nid}",
+ "-device", f"virtio-net-pci,netdev={nid},mac={mac}"]
+
+ if tpm:
+ self._start_swtpm()
+ cmd += [
+ "-chardev", f"socket,id=chrtpm,path={self._tpm_sock}",
+ "-tpmdev", "emulator,id=tpm0,chardev=chrtpm",
+ "-device", "tpm-tis,tpmdev=tpm0",
+ ]
+
+ if kernel:
+ cmd += ["-kernel", str(kernel)]
+ if initrd:
+ cmd += ["-initrd", str(initrd)]
+ if append:
+ cmd += ["-append", append]
+
+ # QEMU stderr goes to a file, not an unread PIPE: a chatty backend
+ # (e.g. slirp warnings) can fill a 64K pipe and block QEMU's write,
+ # freezing the guest — alive but silent — until the harness times out.
+ self._stderr_path = self.workdir / f"{self.name}-qemu-stderr.log"
+ self._stderr_file = open(self._stderr_path, "wb")
+ self.process = subprocess.Popen(
+ cmd,
+ stdout=subprocess.DEVNULL,
+ stderr=self._stderr_file,
+ )
+ self._start_serial_reader()
+ return self
+
+ def qemu_stderr(self):
+ try:
+ self._stderr_file.flush()
+ return self._stderr_path.read_text(errors="replace")
+ except (OSError, AttributeError):
+ return ""
+
+ def _start_serial_reader(self):
+ # Connect to QEMU's serial socket (created at launch) and tee
+ # everything received to serial_log in a background thread.
+ sock = None
+ for _ in range(100):
+ # If QEMU died at launch the socket will never appear; surface
+ # its stderr instead of a misleading "socket" error.
+ if self.process.poll() is not None:
+ raise VMError(
+ f"QEMU exited at launch (rc={self.process.returncode}).\n"
+ f"stderr: {self.qemu_stderr()[-2000:]}"
+ )
+ try:
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ sock.connect(str(self._serial_path))
+ break
+ except OSError:
+ sock.close()
+ sock = None
+ time.sleep(0.1)
+ if sock is None:
+ raise VMError(
+ f"could not connect to QEMU serial socket {self._serial_path} "
+ "(QEMU is running but never created it)"
+ )
+ self._serial_sock = sock
+ self._serial_stop = threading.Event()
+
+ def reader():
+ sock.settimeout(0.5)
+ with open(self.serial_log, "ab", buffering=0) as log:
+ while not self._serial_stop.is_set():
+ try:
+ chunk = sock.recv(4096)
+ except socket.timeout:
+ continue
+ except OSError:
+ break
+ if not chunk:
+ break
+ log.write(chunk)
+
+ self._serial_reader = threading.Thread(target=reader, daemon=True)
+ self._serial_reader.start()
+
+ def send_serial(self, text):
+ """Type `text` into the guest's serial console (e.g. a LUKS
+ passphrase at the initramfs unlock prompt). Include the trailing
+ newline yourself."""
+ if self._serial_sock is None:
+ raise VMError("serial socket not connected")
+ self._serial_sock.sendall(text.encode("utf-8"))
+
+ def alive(self):
+ return self.process is not None and self.process.poll() is None
+
+ def wait_serial(self, patterns, timeout_s, poll_interval=0.5):
+ """Block until any regex in `patterns` appears on the serial console.
+
+ Returns the matched pattern. Raises TimeoutError (with the tail of
+ the serial log) on timeout, VMError if the VM exits prematurely.
+ """
+ compiled = [re.compile(p) for p in patterns]
+ deadline = time.monotonic() + timeout_s
+ while time.monotonic() < deadline:
+ text = ""
+ if self.serial_log.exists():
+ text = self.serial_log.read_text(errors="replace")
+ for pattern in compiled:
+ if pattern.search(text):
+ return pattern.pattern
+ if not self.alive():
+ raise VMError(
+ f"VM exited (rc={self.process.returncode}) while waiting "
+ f"for serial output.\nstderr: {self.qemu_stderr()[-2000:]}\n"
+ f"serial tail: {text[-2000:]}"
+ )
+ time.sleep(poll_interval)
+ tail = ""
+ if self.serial_log.exists():
+ tail = self.serial_log.read_text(errors="replace")[-2000:]
+ raise TimeoutError(
+ f"Timed out after {timeout_s}s waiting for {patterns} on serial "
+ f"console.\nserial tail: {tail}\n"
+ f"qemu stderr: {self.qemu_stderr()[-2000:]}"
+ )
+
+ def stop(self, grace_s=10):
+ if self._serial_stop is not None:
+ self._serial_stop.set()
+ if self.process is not None and self.process.poll() is None:
+ self.process.terminate()
+ try:
+ self.process.wait(grace_s)
+ except subprocess.TimeoutExpired:
+ self.process.kill()
+ self.process.wait()
+ self.process = None
+ if self._serial_reader is not None:
+ self._serial_reader.join(timeout=2)
+ self._serial_reader = None
+ if self._serial_sock is not None:
+ self._serial_sock.close()
+ self._serial_sock = None
+ if self._serial_dir is not None:
+ shutil.rmtree(self._serial_dir, ignore_errors=True)
+ self._serial_dir = None
+ if self._swtpm is not None:
+ self._swtpm.terminate()
+ self._swtpm = None
diff --git a/tests/integration/scenarios/answers/bios-lvm-luks.yaml b/tests/integration/scenarios/answers/bios-lvm-luks.yaml
new file mode 100644
index 00000000..cb4be979
--- /dev/null
+++ b/tests/integration/scenarios/answers/bios-lvm-luks.yaml
@@ -0,0 +1,46 @@
+# Answer file for the bios-lvm-luks scenario.
+# {server} is expanded by the harness to its HTTP server's base URL.
+version: 1
+
+hostname: lmde-luks-bios-01
+locale: en_US.UTF-8
+timezone: America/Toronto
+
+keyboard:
+ model: pc105
+ layout: us
+
+users:
+ - name: testuser
+ gecos: Integration Test User
+ # "testpass", sha512crypt — test fixture only
+ passwd: "$6$rounds=4096$integration.test$kPEsZSlAg3pjDM0yaeSdAVPbHcsiBVMobBC0SXmdGRX9WtflVB.ETP6/yo8Lw0PRJlXP6tEXkbQjnLnnyG5BV1"
+ groups: [sudo]
+
+storage:
+ target:
+ match:
+ first-non-removable: true
+ on_no_match: abort
+ layout: lvm-on-luks
+ luks:
+ passphrase_source: keyfile
+ keyfile: "{server}/answers/luks.key"
+
+kernel:
+ # Full serial-console provisioning: drops quiet/splash, adds console=,
+ # and points GRUB at the serial line, so the initramfs LUKS prompt
+ # appears on serial (and can be answered over IPMI Serial-over-LAN on
+ # real hardware) instead of being grabbed by plymouth.
+ serial_console: "ttyS0,115200"
+
+packages: [openssh-server]
+
+on_failure:
+ partition_mismatch: abort
+ network_unavailable: continue
+ package_install_failure: abort
+
+logging:
+ destination: /var/log/live-installer-auto.log
+ also_serial: ttyS0
diff --git a/tests/integration/scenarios/answers/bios-multi-disk.yaml b/tests/integration/scenarios/answers/bios-multi-disk.yaml
new file mode 100644
index 00000000..3f52125a
--- /dev/null
+++ b/tests/integration/scenarios/answers/bios-multi-disk.yaml
@@ -0,0 +1,36 @@
+# Answer file for bios-multi-disk: select the target by stable by-id,
+# NOT by enumeration order. The target is the second disk (vdb).
+version: 1
+
+hostname: lmde-multidisk-01
+locale: en_US.UTF-8
+timezone: America/Toronto
+
+keyboard:
+ model: pc105
+ layout: us
+
+users:
+ - name: testuser
+ gecos: Integration Test User
+ passwd: "$6$rounds=4096$integration.test$kPEsZSlAg3pjDM0yaeSdAVPbHcsiBVMobBC0SXmdGRX9WtflVB.ETP6/yo8Lw0PRJlXP6tEXkbQjnLnnyG5BV1"
+ groups: [sudo]
+
+storage:
+ target:
+ match:
+ # virtio serial 'installtarget' -> /dev/disk/by-id/virtio-installtarget
+ by-id: "virtio-installtarget"
+ on_no_match: abort
+ layout: simple
+
+packages: [openssh-server]
+
+on_failure:
+ partition_mismatch: abort
+ network_unavailable: continue
+ package_install_failure: abort
+
+logging:
+ destination: /var/log/live-installer-auto.log
+ also_serial: ttyS0
diff --git a/tests/integration/scenarios/answers/bios-simple.yaml b/tests/integration/scenarios/answers/bios-simple.yaml
new file mode 100644
index 00000000..3fa86430
--- /dev/null
+++ b/tests/integration/scenarios/answers/bios-simple.yaml
@@ -0,0 +1,51 @@
+# Answer file for the bios-simple scenario.
+version: 1
+
+hostname: lmde-test-01
+locale: en_US.UTF-8
+timezone: America/Toronto
+# Also generate fr_CA so multi-locale support is exercised end to end.
+additional_locales: [fr_CA.UTF-8]
+
+keyboard:
+ model: pc105
+ layout: us
+ # A second, switchable layout (us + ca/fr) with an explicit toggle.
+ additional_layouts:
+ - {layout: ca, variant: fr}
+ toggle: grp:alt_shift_toggle
+
+users:
+ - name: testuser
+ gecos: Integration Test User
+ # "testpass", sha512crypt — test fixture only, obviously not a secret
+ passwd: "$6$rounds=4096$integration.test$kPEsZSlAg3pjDM0yaeSdAVPbHcsiBVMobBC0SXmdGRX9WtflVB.ETP6/yo8Lw0PRJlXP6tEXkbQjnLnnyG5BV1"
+ groups: [sudo]
+
+storage:
+ target:
+ match:
+ first-non-removable: true
+ on_no_match: abort
+ layout: simple
+
+# A self-signed CA added to the system trust store. The placeholder is a
+# structurally valid PEM (so the file passes schema validation as-is); the
+# harness swaps in a freshly generated cert at run time.
+ca_certs:
+ trusted:
+ - |
+ -----BEGIN CERTIFICATE-----
+ LI_HARNESS_CA_CERT_PLACEHOLDER
+ -----END CERTIFICATE-----
+
+packages: [openssh-server]
+
+on_failure:
+ partition_mismatch: abort
+ network_unavailable: continue
+ package_install_failure: abort
+
+logging:
+ destination: /var/log/live-installer-auto.log
+ also_serial: ttyS0
diff --git a/tests/integration/scenarios/answers/btrfs.yaml b/tests/integration/scenarios/answers/btrfs.yaml
new file mode 100644
index 00000000..6603c32b
--- /dev/null
+++ b/tests/integration/scenarios/answers/btrfs.yaml
@@ -0,0 +1,50 @@
+# Custom layout with btrfs subvolumes: ESP + swap, and a btrfs root split into
+# @ (mounted /) and @home (mounted /home) — the snapshot-friendly layout.
+version: 1
+
+hostname: lmde-btrfs-01
+locale: en_US.UTF-8
+timezone: America/Toronto
+
+keyboard:
+ model: pc105
+ layout: us
+
+users:
+ - name: testuser
+ gecos: Integration Test User
+ # "testpass", sha512crypt — test fixture only
+ passwd: "$6$rounds=4096$integration.test$kPEsZSlAg3pjDM0yaeSdAVPbHcsiBVMobBC0SXmdGRX9WtflVB.ETP6/yo8Lw0PRJlXP6tEXkbQjnLnnyG5BV1"
+ groups: [sudo]
+
+storage:
+ target:
+ match:
+ first-non-removable: true
+ on_no_match: abort
+ layout: custom
+ partitions:
+ - {size: 512MB, mount: /boot/efi, filesystem: vfat, flags: [esp]}
+ - {size: 2GB, mount: swap, filesystem: swap}
+ - size: rest
+ filesystem: btrfs
+ subvolumes:
+ - {name: "@", mount: /}
+ - {name: "@home", mount: /home}
+
+packages: [openssh-server]
+
+# Configure Timeshift on the btrfs @/@home root (the cross-validation requires
+# this layout). Exercises the timeshift.json render against the real Timeshift.
+snapshots:
+ enabled: true
+ backend: timeshift-btrfs
+ schedule:
+ boot: true
+ daily: 5
+ weekly: 3
+
+on_failure:
+ partition_mismatch: abort
+ network_unavailable: continue
+ package_install_failure: abort
diff --git a/tests/integration/scenarios/answers/custom.yaml b/tests/integration/scenarios/answers/custom.yaml
new file mode 100644
index 00000000..aacb564e
--- /dev/null
+++ b/tests/integration/scenarios/answers/custom.yaml
@@ -0,0 +1,39 @@
+# Custom partition layout: explicit ESP + swap partitions, and an LVM PV
+# carrying a vg0 volume group with root and home logical volumes.
+version: 1
+
+hostname: lmde-custom-01
+locale: en_US.UTF-8
+timezone: America/Toronto
+
+keyboard:
+ model: pc105
+ layout: us
+
+users:
+ - name: testuser
+ gecos: Integration Test User
+ # "testpass", sha512crypt — test fixture only
+ passwd: "$6$rounds=4096$integration.test$kPEsZSlAg3pjDM0yaeSdAVPbHcsiBVMobBC0SXmdGRX9WtflVB.ETP6/yo8Lw0PRJlXP6tEXkbQjnLnnyG5BV1"
+ groups: [sudo]
+
+storage:
+ target:
+ match:
+ first-non-removable: true
+ on_no_match: abort
+ layout: custom
+ partitions:
+ - {size: 512MB, mount: /boot/efi, filesystem: vfat, flags: [esp]}
+ - {size: 2GB, mount: swap, filesystem: swap}
+ - {size: rest, lvm_pv: vg0}
+ lvm:
+ - {vg: vg0, lv: root, size: 16GB, mount: /, filesystem: ext4}
+ - {vg: vg0, lv: home, size: rest, mount: /home, filesystem: ext4}
+
+packages: [openssh-server]
+
+on_failure:
+ partition_mismatch: abort
+ network_unavailable: continue
+ package_install_failure: abort
diff --git a/tests/integration/scenarios/answers/fail-bios-grub-on-uefi.yaml b/tests/integration/scenarios/answers/fail-bios-grub-on-uefi.yaml
new file mode 100644
index 00000000..60829b58
--- /dev/null
+++ b/tests/integration/scenarios/answers/fail-bios-grub-on-uefi.yaml
@@ -0,0 +1,29 @@
+# Valid BIOS-style custom layout (a bios_grub partition + root) deliberately
+# run on a UEFI machine. The schema can't catch this — firmware mode is only
+# known at runtime — so the engine must reject it before touching the disk
+# (review finding #2). Expected outcome: a clean abort, no partitioning.
+version: 1
+
+locale: en_US.UTF-8
+timezone: America/Toronto
+
+users:
+ - name: testuser
+ passwd: "$6$rounds=4096$integration.test$kPEsZSlAg3pjDM0yaeSdAVPbHcsiBVMobBC0SXmdGRX9WtflVB.ETP6/yo8Lw0PRJlXP6tEXkbQjnLnnyG5BV1"
+
+storage:
+ target:
+ match:
+ first-non-removable: true
+ on_no_match: abort
+ layout: custom
+ partitions:
+ - {size: 1MB, flags: [bios_grub]}
+ - {size: rest, mount: /, filesystem: ext4}
+
+on_failure:
+ partition_mismatch: abort
+
+logging:
+ destination: /var/log/live-installer-auto.log
+ also_serial: ttyS0
diff --git a/tests/integration/scenarios/answers/fail-snapshots-on-ext4.yaml b/tests/integration/scenarios/answers/fail-snapshots-on-ext4.yaml
new file mode 100644
index 00000000..0812f410
--- /dev/null
+++ b/tests/integration/scenarios/answers/fail-snapshots-on-ext4.yaml
@@ -0,0 +1,26 @@
+# snapshots: timeshift-btrfs requires a btrfs @/@home root, but this is a plain
+# ext4 (simple) layout. The cross-validation must reject it at schema time,
+# before any disk is touched — the silent-failure guard for the feature.
+version: 1
+
+locale: en_US.UTF-8
+timezone: America/Toronto
+
+users:
+ - name: testuser
+ passwd: "$6$rounds=4096$integration.test$kPEsZSlAg3pjDM0yaeSdAVPbHcsiBVMobBC0SXmdGRX9WtflVB.ETP6/yo8Lw0PRJlXP6tEXkbQjnLnnyG5BV1"
+
+storage:
+ target:
+ match:
+ first-non-removable: true
+ on_no_match: abort
+ layout: simple
+
+snapshots:
+ enabled: true
+ backend: timeshift-btrfs
+
+logging:
+ destination: /var/log/live-installer-auto.log
+ also_serial: ttyS0
diff --git a/tests/integration/scenarios/answers/flatpak.yaml b/tests/integration/scenarios/answers/flatpak.yaml
new file mode 100644
index 00000000..daeffff7
--- /dev/null
+++ b/tests/integration/scenarios/answers/flatpak.yaml
@@ -0,0 +1,44 @@
+# Flatpak: add Flathub and install a couple of small, popular apps at install
+# time, verified present on the booted system.
+version: 1
+
+hostname: lmde-flatpak-01
+locale: en_US.UTF-8
+timezone: America/Toronto
+
+keyboard:
+ model: pc105
+ layout: us
+
+users:
+ - name: testuser
+ gecos: Integration Test User
+ # "testpass", sha512crypt — test fixture only
+ passwd: "$6$rounds=4096$integration.test$kPEsZSlAg3pjDM0yaeSdAVPbHcsiBVMobBC0SXmdGRX9WtflVB.ETP6/yo8Lw0PRJlXP6tEXkbQjnLnnyG5BV1"
+ groups: [sudo]
+
+storage:
+ target:
+ match:
+ first-non-removable: true
+ on_no_match: abort
+ layout: simple
+
+packages: [openssh-server]
+
+flatpak:
+ remotes:
+ - {name: flathub, url: "https://flathub.org/repo/flathub.flatpakrepo"}
+ install:
+ # small, popular, both on the GNOME runtime (downloaded once)
+ - com.github.tchx84.Flatseal
+ - org.gnome.Calculator
+
+on_failure:
+ partition_mismatch: abort
+ network_unavailable: continue
+ package_install_failure: abort
+
+logging:
+ destination: /var/log/live-installer-auto.log
+ also_serial: ttyS0
diff --git a/tests/integration/scenarios/answers/luks-prompt.yaml b/tests/integration/scenarios/answers/luks-prompt.yaml
new file mode 100644
index 00000000..7f7ba6c5
--- /dev/null
+++ b/tests/integration/scenarios/answers/luks-prompt.yaml
@@ -0,0 +1,46 @@
+# Answer file for the uefi-luks-prompt scenario: LVM-on-LUKS with
+# passphrase_source: prompt-on-first-boot. No secret is in the answer file —
+# the install formats LUKS with a random throwaway key (auto-unlocks the first
+# boot via a keyfile in the initramfs), then a one-shot prompts the operator
+# for the real passphrase on serial, rekeys, and reboots.
+version: 1
+
+hostname: lmde-luks-prompt-01
+locale: en_US.UTF-8
+timezone: America/Toronto
+
+keyboard:
+ model: pc105
+ layout: us
+
+users:
+ - name: testuser
+ gecos: Integration Test User
+ # "testpass", sha512crypt — test fixture only
+ passwd: "$6$rounds=4096$integration.test$kPEsZSlAg3pjDM0yaeSdAVPbHcsiBVMobBC0SXmdGRX9WtflVB.ETP6/yo8Lw0PRJlXP6tEXkbQjnLnnyG5BV1"
+ groups: [sudo]
+
+storage:
+ target:
+ match:
+ first-non-removable: true
+ on_no_match: abort
+ layout: lvm-on-luks
+ luks:
+ passphrase_source: prompt-on-first-boot
+
+kernel:
+ # Serial console so the first-boot rekey prompt AND the subsequent
+ # initramfs unlock prompt both appear on ttyS0 (answerable over SoL).
+ serial_console: "ttyS0,115200"
+
+packages: [openssh-server]
+
+on_failure:
+ partition_mismatch: abort
+ network_unavailable: continue
+ package_install_failure: abort
+
+logging:
+ destination: /var/log/live-installer-auto.log
+ also_serial: ttyS0
diff --git a/tests/integration/scenarios/answers/luks.key b/tests/integration/scenarios/answers/luks.key
new file mode 100644
index 00000000..76573c72
--- /dev/null
+++ b/tests/integration/scenarios/answers/luks.key
@@ -0,0 +1 @@
+integration-test-luks-passphrase-not-a-secret
diff --git a/tests/integration/scenarios/answers/malformed.yaml b/tests/integration/scenarios/answers/malformed.yaml
new file mode 100644
index 00000000..401f51a7
--- /dev/null
+++ b/tests/integration/scenarios/answers/malformed.yaml
@@ -0,0 +1,4 @@
+# Deliberately malformed: unbalanced brace + tab indentation.
+version: 1
+locale: {language: en_US.UTF-8
+ timezone: broken
diff --git a/tests/integration/scenarios/answers/netboot-ipv6.yaml b/tests/integration/scenarios/answers/netboot-ipv6.yaml
new file mode 100644
index 00000000..31c699a3
--- /dev/null
+++ b/tests/integration/scenarios/answers/netboot-ipv6.yaml
@@ -0,0 +1,33 @@
+# Answer file for the netboot-ipv6 scenario. No packages: the point of this
+# scenario is that the rootfs and this answer file are fetched over IPv6, so it
+# must not depend on apt reaching the internet (DNS over a dual-stack QEMU
+# user-net is flaky, and apt-over-internet is already covered by the BIOS/UEFI
+# PXE scenarios).
+version: 1
+
+hostname: lmde-test-01
+locale: en_US.UTF-8
+timezone: America/Toronto
+
+keyboard:
+ model: pc105
+ layout: us
+
+users:
+ - name: testuser
+ gecos: Integration Test User
+ # "testpass", sha512crypt — test fixture only
+ passwd: "$6$rounds=4096$integration.test$kPEsZSlAg3pjDM0yaeSdAVPbHcsiBVMobBC0SXmdGRX9WtflVB.ETP6/yo8Lw0PRJlXP6tEXkbQjnLnnyG5BV1"
+ groups: [sudo]
+
+storage:
+ target:
+ match:
+ first-non-removable: true
+ on_no_match: abort
+ layout: simple
+
+on_failure:
+ partition_mismatch: abort
+ network_unavailable: continue
+ package_install_failure: abort
diff --git a/tests/integration/scenarios/answers/no-disk-match.yaml b/tests/integration/scenarios/answers/no-disk-match.yaml
new file mode 100644
index 00000000..94f2f378
--- /dev/null
+++ b/tests/integration/scenarios/answers/no-disk-match.yaml
@@ -0,0 +1,20 @@
+# Valid answer file whose disk matcher cannot match anything in the VM.
+version: 1
+
+locale: en_US.UTF-8
+timezone: America/Toronto
+
+users:
+ - name: testuser
+ passwd: "$6$rounds=4096$integration.test$kPEsZSlAg3pjDM0yaeSdAVPbHcsiBVMobBC0SXmdGRX9WtflVB.ETP6/yo8Lw0PRJlXP6tEXkbQjnLnnyG5BV1"
+
+storage:
+ target:
+ match:
+ by-id: "nvme-Definitely_Nonexistent_Disk_*"
+ on_no_match: abort
+ layout: simple
+
+logging:
+ destination: /var/log/live-installer-auto.log
+ also_serial: ttyS0
diff --git a/tests/integration/scenarios/answers/raid1-bios.yaml b/tests/integration/scenarios/answers/raid1-bios.yaml
new file mode 100644
index 00000000..74bb2dab
--- /dev/null
+++ b/tests/integration/scenarios/answers/raid1-bios.yaml
@@ -0,0 +1,45 @@
+# BIOS, software RAID1 across two disks: /boot and / each on a RAID1 mirror.
+# No LVM — the simplest bootable RAID, to prove the multi-disk + mdadm + grub
+# path end to end. grub is installed to both disks for boot redundancy.
+version: 1
+
+hostname: lmde-raid1-01
+locale: en_US.UTF-8
+timezone: America/Toronto
+
+keyboard:
+ model: pc105
+ layout: us
+
+users:
+ - name: testuser
+ gecos: Integration Test User
+ # "testpass", sha512crypt — test fixture only
+ passwd: "$6$rounds=4096$integration.test$kPEsZSlAg3pjDM0yaeSdAVPbHcsiBVMobBC0SXmdGRX9WtflVB.ETP6/yo8Lw0PRJlXP6tEXkbQjnLnnyG5BV1"
+ groups: [sudo]
+
+storage:
+ layout: custom
+ disks:
+ - match: {by-id: "virtio-raidA"}
+ - match: {by-id: "virtio-raidB"}
+ partitions:
+ - {size: 1GB, raid: md0}
+ - {size: rest, raid: md1}
+ raid:
+ - {name: md0, level: 1, mount: /boot, filesystem: ext4}
+ - {name: md1, level: 1, mount: /, filesystem: ext4}
+
+packages: [openssh-server]
+
+kernel:
+ serial_console: "ttyS0,115200"
+
+on_failure:
+ partition_mismatch: abort
+ network_unavailable: continue
+ package_install_failure: abort
+
+logging:
+ destination: /var/log/live-installer-auto.log
+ also_serial: ttyS0
diff --git a/tests/integration/scenarios/answers/raid5-lvm-bios.yaml b/tests/integration/scenarios/answers/raid5-lvm-bios.yaml
new file mode 100644
index 00000000..1fe813f8
--- /dev/null
+++ b/tests/integration/scenarios/answers/raid5-lvm-bios.yaml
@@ -0,0 +1,48 @@
+# BIOS, RAID1 /boot + RAID5 across three disks used as an LVM PV, with the root
+# LV on top (LVM-on-RAID) — the full multi-level shape. Proves RAID5, LVM on an
+# md device, and booting root from LVM-on-RAID.
+version: 1
+
+hostname: lmde-raid5-01
+locale: en_US.UTF-8
+timezone: America/Toronto
+
+keyboard:
+ model: pc105
+ layout: us
+
+users:
+ - name: testuser
+ gecos: Integration Test User
+ # "testpass", sha512crypt — test fixture only
+ passwd: "$6$rounds=4096$integration.test$kPEsZSlAg3pjDM0yaeSdAVPbHcsiBVMobBC0SXmdGRX9WtflVB.ETP6/yo8Lw0PRJlXP6tEXkbQjnLnnyG5BV1"
+ groups: [sudo]
+
+storage:
+ layout: custom
+ disks:
+ - match: {by-id: "virtio-r5a"}
+ - match: {by-id: "virtio-r5b"}
+ - match: {by-id: "virtio-r5c"}
+ partitions:
+ - {size: 1GB, raid: md0}
+ - {size: rest, raid: md1}
+ raid:
+ - {name: md0, level: 1, mount: /boot, filesystem: ext4}
+ - {name: md1, level: 5, lvm_pv: vg0}
+ lvm:
+ - {vg: vg0, lv: root, size: rest, mount: /, filesystem: ext4}
+
+packages: [openssh-server]
+
+kernel:
+ serial_console: "ttyS0,115200"
+
+on_failure:
+ partition_mismatch: abort
+ network_unavailable: continue
+ package_install_failure: abort
+
+logging:
+ destination: /var/log/live-installer-auto.log
+ also_serial: ttyS0
diff --git a/tests/integration/scenarios/answers/static-network.yaml b/tests/integration/scenarios/answers/static-network.yaml
new file mode 100644
index 00000000..8e35c914
--- /dev/null
+++ b/tests/integration/scenarios/answers/static-network.yaml
@@ -0,0 +1,59 @@
+# Static IP + VLAN networking, dual-stack (IPv4 + IPv6).
+#
+# The primary NIC is left to NetworkManager's default DHCP so the harness can
+# reach the installed system over SSH. A SECOND NIC (bound by MAC) gets a
+# static dual-stack address, and an 802.1Q VLAN rides on top of it — also
+# dual-stack. This proves the installer renders the netplan-v2 network: section
+# to working NetworkManager keyfiles on the booted system.
+version: 1
+
+hostname: lmde-net-01
+locale: en_US.UTF-8
+timezone: America/Toronto
+
+keyboard:
+ model: pc105
+ layout: us
+
+users:
+ - name: testuser
+ gecos: Integration Test User
+ # "testpass", sha512crypt — test fixture only
+ passwd: "$6$rounds=4096$integration.test$kPEsZSlAg3pjDM0yaeSdAVPbHcsiBVMobBC0SXmdGRX9WtflVB.ETP6/yo8Lw0PRJlXP6tEXkbQjnLnnyG5BV1"
+ groups: [sudo]
+
+storage:
+ target:
+ match:
+ first-non-removable: true
+ on_no_match: abort
+ layout: simple
+
+network:
+ version: 2
+ ethernets:
+ lan1:
+ # Bind by hardware address — the kernel name is unpredictable.
+ match: {macaddress: "52:54:00:aa:bb:01"}
+ addresses: [192.168.50.10/24, 2001:db8:50::10/64]
+ gateway4: 192.168.50.1
+ gateway6: 2001:db8:50::1
+ nameservers:
+ addresses: [192.168.50.53, 2001:db8:50::53]
+ search: [lab.example]
+ vlans:
+ vlan50:
+ id: 50
+ link: lan1
+ addresses: [10.50.0.5/24, 2001:db8:5050::5/64]
+
+packages: [openssh-server]
+
+on_failure:
+ partition_mismatch: abort
+ network_unavailable: continue
+ package_install_failure: abort
+
+logging:
+ destination: /var/log/live-installer-auto.log
+ also_serial: ttyS0
diff --git a/tests/integration/scenarios/answers/uefi-lvm-luks.yaml b/tests/integration/scenarios/answers/uefi-lvm-luks.yaml
new file mode 100644
index 00000000..611dd7d4
--- /dev/null
+++ b/tests/integration/scenarios/answers/uefi-lvm-luks.yaml
@@ -0,0 +1,46 @@
+# Answer file for the uefi-lvm-luks scenario.
+# {server} is expanded by the harness to its HTTP server's base URL.
+version: 1
+
+hostname: lmde-luks-01
+locale: en_US.UTF-8
+timezone: America/Toronto
+
+keyboard:
+ model: pc105
+ layout: us
+
+users:
+ - name: testuser
+ gecos: Integration Test User
+ # "testpass", sha512crypt — test fixture only
+ passwd: "$6$rounds=4096$integration.test$kPEsZSlAg3pjDM0yaeSdAVPbHcsiBVMobBC0SXmdGRX9WtflVB.ETP6/yo8Lw0PRJlXP6tEXkbQjnLnnyG5BV1"
+ groups: [sudo]
+
+storage:
+ target:
+ match:
+ first-non-removable: true
+ on_no_match: abort
+ layout: lvm-on-luks
+ luks:
+ passphrase_source: keyfile
+ keyfile: "{server}/answers/luks.key"
+
+kernel:
+ # Full serial-console provisioning: drops quiet/splash, adds console=,
+ # and points GRUB at the serial line, so the initramfs LUKS prompt
+ # appears on serial (and can be answered over IPMI Serial-over-LAN on
+ # real hardware) instead of being grabbed by plymouth.
+ serial_console: "ttyS0,115200"
+
+packages: [openssh-server]
+
+on_failure:
+ partition_mismatch: abort
+ network_unavailable: continue
+ package_install_failure: abort
+
+logging:
+ destination: /var/log/live-installer-auto.log
+ also_serial: ttyS0
diff --git a/tests/integration/scenarios/answers/uefi-lvm.yaml b/tests/integration/scenarios/answers/uefi-lvm.yaml
new file mode 100644
index 00000000..6983852f
--- /dev/null
+++ b/tests/integration/scenarios/answers/uefi-lvm.yaml
@@ -0,0 +1,34 @@
+# Answer file for the uefi-lvm scenario.
+version: 1
+
+hostname: lmde-lvm-01
+locale: en_US.UTF-8
+timezone: America/Toronto
+
+keyboard:
+ model: pc105
+ layout: us
+
+users:
+ - name: testuser
+ gecos: Integration Test User
+ passwd: "$6$rounds=4096$integration.test$kPEsZSlAg3pjDM0yaeSdAVPbHcsiBVMobBC0SXmdGRX9WtflVB.ETP6/yo8Lw0PRJlXP6tEXkbQjnLnnyG5BV1"
+ groups: [sudo]
+
+storage:
+ target:
+ match:
+ first-non-removable: true
+ on_no_match: abort
+ layout: lvm
+
+packages: [openssh-server]
+
+on_failure:
+ partition_mismatch: abort
+ network_unavailable: continue
+ package_install_failure: abort
+
+logging:
+ destination: /var/log/live-installer-auto.log
+ also_serial: ttyS0
diff --git a/tests/integration/scenarios/bios-lvm-luks.yaml b/tests/integration/scenarios/bios-lvm-luks.yaml
new file mode 100644
index 00000000..8662acf2
--- /dev/null
+++ b/tests/integration/scenarios/bios-lvm-luks.yaml
@@ -0,0 +1,49 @@
+# BIOS firmware, LVM-on-LUKS layout, passphrase from a keyfile URL.
+#
+# The BIOS counterpart of uefi-lvm-luks: same encrypted LVM-on-LUKS
+# layout and serial-console unlock, but MBR/GRUB-on-disk instead of an
+# ESP. The installer picks the partition layout from firmware detection,
+# so the answer file is identical; only the firmware and the bootloader
+# path differ. Encrypted installs were UEFI-only in the matrix; the BIOS
+# boot path is a distinct initramfs/GRUB route, so it is covered here too.
+name: bios-lvm-luks
+description: Unattended BIOS install with LUKS-encrypted LVM, unlocked over serial
+
+firmware: bios
+tpm: false
+memory_mb: 4096
+cpus: 2
+disk_gb: 25
+
+answer_file: answers/bios-lvm-luks.yaml
+
+install_timeout_s: 1800
+boot_timeout_s: 300
+
+boot_unlock:
+ prompt: "Please unlock disk"
+ passphrase_file: answers/luks.key
+ timeout_s: 180
+
+expect:
+ serial_markers:
+ - "Automated installation complete"
+ failure_markers:
+ - "Automated installation FAILED"
+
+ssh:
+ user: testuser
+
+verify:
+ - name: hostname-applied
+ type: command
+ command: hostname
+ expect_substring: "lmde-luks-bios-01"
+ - name: root-on-luks-lvm
+ type: mount_source
+ mountpoint: /
+ source_contains: "lvmmint-root"
+ - name: luks-device-open
+ type: command
+ command: "lsblk -rno TYPE | grep -c crypt"
+ expect_substring: "1"
diff --git a/tests/integration/scenarios/bios-multi-disk.yaml b/tests/integration/scenarios/bios-multi-disk.yaml
new file mode 100644
index 00000000..4f8286b2
--- /dev/null
+++ b/tests/integration/scenarios/bios-multi-disk.yaml
@@ -0,0 +1,54 @@
+# BIOS, two disks, by-id selection of the SECOND disk.
+#
+# Proves the installer targets the disk named by its stable by-id, not
+# whichever the kernel enumerated first — the failure this design exists
+# to prevent (wiping the wrong disk). The decoy disk (vda) is larger and
+# first, so any "biggest" or "first" heuristic would pick it instead.
+name: bios-multi-disk
+description: Unattended BIOS install selecting the target disk by by-id
+
+firmware: bios
+tpm: false
+memory_mb: 4096
+cpus: 2
+
+disks:
+ - size_gb: 30 # vda — decoy: first and largest
+ serial: systemdecoy
+ - size_gb: 20 # vdb — the actual target
+ serial: installtarget
+
+install_timeout_s: 1800
+boot_timeout_s: 300
+
+# Phase 2 must boot the disk the OS was installed to, not the decoy
+# (which enumerates first). This is the test harness telling QEMU what
+# the firmware on a real machine would be configured to boot.
+boot_disk_serial: installtarget
+
+expect:
+ serial_markers:
+ - "Automated installation complete"
+ failure_markers:
+ - "Automated installation FAILED"
+
+answer_file: answers/bios-multi-disk.yaml
+
+ssh:
+ user: testuser
+
+verify:
+ - name: hostname-applied
+ type: command
+ command: hostname
+ expect_substring: "lmde-multidisk-01"
+ - name: root-on-target-disk-by-serial
+ # the disk holding / must be the one with serial 'installtarget'
+ type: command
+ command: "lsblk -dno SERIAL \"/dev/$(lsblk -no PKNAME \"$(findmnt -no SOURCE /)\" | head -1)\""
+ expect_substring: "installtarget"
+ - name: decoy-disk-untouched
+ # the decoy disk must have no partition table (no children)
+ type: command
+ command: "lsblk -rno NAME \"/dev/$(lsblk -dno NAME,SERIAL | awk '/systemdecoy/{print $1}')\" | wc -l"
+ expect_substring: "1"
diff --git a/tests/integration/scenarios/bios-simple.yaml b/tests/integration/scenarios/bios-simple.yaml
new file mode 100644
index 00000000..200c7c91
--- /dev/null
+++ b/tests/integration/scenarios/bios-simple.yaml
@@ -0,0 +1,65 @@
+# Baseline scenario: BIOS firmware, single disk, simple layout.
+#
+# NOTE: the full install phase requires the headless driver and
+# answer-file support in the installer (not yet implemented). Until
+# then this scenario is only runnable in smoke mode:
+#
+# harness/run_scenario.py scenarios/bios-simple.yaml --smoke
+
+name: bios-simple
+description: Unattended BIOS install, single disk, simple partition layout
+
+firmware: bios
+tpm: false
+memory_mb: 4096
+cpus: 2
+disk_gb: 25
+
+# Served to the VM over HTTP from this directory (10.0.2.2 = host)
+answer_file: answers/bios-simple.yaml
+
+install_timeout_s: 1800
+boot_timeout_s: 300
+
+expect:
+ serial_markers:
+ - "Automated installation complete"
+ failure_markers:
+ - "Automated installation FAILED"
+
+ssh:
+ user: testuser
+
+verify:
+ - name: os-is-lmde
+ type: command
+ command: ". /etc/os-release && echo $PRETTY_NAME"
+ expect_substring: "LMDE"
+ - name: hostname-applied
+ type: command
+ command: hostname
+ expect_substring: "lmde-test-01"
+ - name: root-on-plain-partition
+ type: mount_source
+ mountpoint: /
+ source_contains: "/dev/"
+ - name: network-manager-running
+ type: command
+ command: systemctl is-active NetworkManager
+ expect_substring: "active"
+ - name: keyboard-multilayout
+ type: command
+ command: ". /etc/default/keyboard && echo \"$XKBLAYOUT|$XKBVARIANT|$XKBOPTIONS\""
+ expect_substring: "us,ca|,fr|grp:alt_shift_toggle"
+ - name: additional-locale-generated
+ type: command
+ command: locale -a | grep -i fr_CA
+ expect_substring: "fr_CA"
+ - name: ca-cert-installed
+ type: file_exists
+ path: /usr/local/share/ca-certificates/li-ca-1.crt
+ # update-ca-certificates only creates /etc/ssl/certs/.pem for a VALID
+ # cert, so this proves the cert reached the system trust store.
+ - name: ca-cert-trusted
+ type: file_exists
+ path: /etc/ssl/certs/li-ca-1.pem
diff --git a/tests/integration/scenarios/custom-btrfs.yaml b/tests/integration/scenarios/custom-btrfs.yaml
new file mode 100644
index 00000000..323f5226
--- /dev/null
+++ b/tests/integration/scenarios/custom-btrfs.yaml
@@ -0,0 +1,69 @@
+# Custom layout with btrfs subvolumes, UEFI: ESP + swap + a btrfs filesystem
+# split into @ (/) and @home (/home). Exercises the subvolume engine path
+# (mkfs.btrfs + btrfs subvolume create + subvol= mounts and fstab options).
+
+name: custom-btrfs
+description: Unattended UEFI install, custom layout with btrfs @/@home subvolumes
+
+firmware: uefi
+tpm: false
+memory_mb: 4096
+cpus: 2
+disk_gb: 25
+
+answer_file: answers/btrfs.yaml
+
+install_timeout_s: 1800
+boot_timeout_s: 300
+
+expect:
+ serial_markers:
+ - "Automated installation complete"
+ failure_markers:
+ - "Automated installation FAILED"
+
+ssh:
+ user: testuser
+
+verify:
+ - name: os-is-lmde
+ type: command
+ command: ". /etc/os-release && echo $PRETTY_NAME"
+ expect_substring: "LMDE"
+ - name: hostname-applied
+ type: command
+ command: hostname
+ expect_substring: "lmde-btrfs-01"
+ - name: root-is-btrfs
+ type: command
+ command: findmnt -n -o FSTYPE /
+ expect_substring: "btrfs"
+ - name: root-on-at-subvolume
+ type: command
+ command: findmnt -n -o OPTIONS /
+ expect_substring: "subvol=/@"
+ - name: home-on-athome-subvolume
+ type: command
+ command: findmnt -n -o OPTIONS /home
+ expect_substring: "subvol=/@home"
+ - name: network-manager-running
+ type: command
+ command: systemctl is-active NetworkManager
+ expect_substring: "active"
+ - name: timeshift-config-btrfs-mode
+ type: command
+ command: "grep -o 'btrfs_mode.*true' /etc/timeshift/timeshift.json"
+ expect_substring: "btrfs_mode"
+ # timeshift itself requires root (the verify runs as the unprivileged ssh
+ # user), so we can't run `timeshift --list` here; assert the tool is
+ # installed. Whether it parses the config is exercised by the first-boot
+ # `timeshift --check` (run as root), proven to have completed below.
+ - name: timeshift-installed
+ type: command
+ command: command -v timeshift
+ expect_substring: "timeshift"
+ # The first-boot one-shot registers the schedule then self-removes.
+ - name: timeshift-firstboot-ran
+ type: command
+ command: "for i in $(seq 1 30); do systemctl cat li-timeshift-firstboot.service >/dev/null 2>&1 || break; sleep 2; done; systemctl cat li-timeshift-firstboot.service >/dev/null 2>&1 && echo present || echo removed"
+ expect_substring: "removed"
diff --git a/tests/integration/scenarios/custom-uefi.yaml b/tests/integration/scenarios/custom-uefi.yaml
new file mode 100644
index 00000000..378e6c91
--- /dev/null
+++ b/tests/integration/scenarios/custom-uefi.yaml
@@ -0,0 +1,61 @@
+# Custom partition layout, UEFI: explicit ESP + swap partitions plus a custom
+# LVM volume group (vg0) with root and home logical volumes. Exercises the
+# layout: custom engine path (parted + pvcreate/vgcreate/lvcreate + per-mount
+# fstab) end to end and verifies the resulting layout on the installed system.
+
+name: custom-uefi
+description: Unattended UEFI install, custom layout (ESP + swap + LVM root/home)
+
+firmware: uefi
+tpm: false
+memory_mb: 4096
+cpus: 2
+disk_gb: 25
+
+answer_file: answers/custom.yaml
+
+install_timeout_s: 1800
+boot_timeout_s: 300
+
+expect:
+ serial_markers:
+ - "Automated installation complete"
+ failure_markers:
+ - "Automated installation FAILED"
+
+ssh:
+ user: testuser
+
+verify:
+ - name: os-is-lmde
+ type: command
+ command: ". /etc/os-release && echo $PRETTY_NAME"
+ expect_substring: "LMDE"
+ - name: hostname-applied
+ type: command
+ command: hostname
+ expect_substring: "lmde-custom-01"
+ - name: root-on-lvm
+ type: mount_source
+ mountpoint: /
+ source_contains: "vg0"
+ - name: home-on-separate-lv
+ type: command
+ command: findmnt -n -o SOURCE /home
+ expect_substring: "vg0"
+ - name: swap-in-fstab
+ type: command
+ command: "grep -w swap /etc/fstab"
+ expect_substring: "swap"
+ - name: swap-partition-formatted
+ type: command
+ command: "lsblk -nro FSTYPE /dev/vda2"
+ expect_substring: "swap"
+ - name: esp-mounted
+ type: command
+ command: findmnt -n -o TARGET /boot/efi
+ expect_substring: "/boot/efi"
+ - name: network-manager-running
+ type: command
+ command: systemctl is-active NetworkManager
+ expect_substring: "active"
diff --git a/tests/integration/scenarios/fail-bios-grub-on-uefi.yaml b/tests/integration/scenarios/fail-bios-grub-on-uefi.yaml
new file mode 100644
index 00000000..ce5e5ce1
--- /dev/null
+++ b/tests/integration/scenarios/fail-bios-grub-on-uefi.yaml
@@ -0,0 +1,20 @@
+# Regression for review finding #2: a custom layout with a bios_grub partition
+# on a UEFI machine must abort cleanly (engine-side firmware/flag check), not
+# silently create an unbootable layout.
+name: fail-bios-grub-on-uefi
+description: bios_grub partition on UEFI aborts before any destructive action
+
+firmware: uefi
+memory_mb: 3072
+cpus: 2
+disk_gb: 15
+
+answer_file: answers/fail-bios-grub-on-uefi.yaml
+install_timeout_s: 600
+
+expect:
+ outcome: failure
+ serial_markers:
+ - "Automated installation complete"
+ failure_markers:
+ - "Automated installation FAILED"
diff --git a/tests/integration/scenarios/fail-malformed-yaml.yaml b/tests/integration/scenarios/fail-malformed-yaml.yaml
new file mode 100644
index 00000000..62581764
--- /dev/null
+++ b/tests/integration/scenarios/fail-malformed-yaml.yaml
@@ -0,0 +1,19 @@
+# Failure mode: a syntactically broken answer file must abort cleanly
+# before touching any disk — never guess, never half-install.
+name: fail-malformed-yaml
+description: Malformed answer file fails fast with the failure marker
+
+firmware: bios
+memory_mb: 3072
+cpus: 2
+disk_gb: 10
+
+answer_file: answers/malformed.yaml
+install_timeout_s: 420
+
+expect:
+ outcome: failure
+ serial_markers:
+ - "Automated installation complete"
+ failure_markers:
+ - "Automated installation FAILED"
diff --git a/tests/integration/scenarios/fail-no-disk-match.yaml b/tests/integration/scenarios/fail-no-disk-match.yaml
new file mode 100644
index 00000000..5309be1a
--- /dev/null
+++ b/tests/integration/scenarios/fail-no-disk-match.yaml
@@ -0,0 +1,19 @@
+# Failure mode: a disk matcher that matches nothing must abort with a
+# clear error (on_no_match: abort) before any destructive action.
+name: fail-no-disk-match
+description: Unmatchable disk selector fails fast with the failure marker
+
+firmware: bios
+memory_mb: 3072
+cpus: 2
+disk_gb: 10
+
+answer_file: answers/no-disk-match.yaml
+install_timeout_s: 420
+
+expect:
+ outcome: failure
+ serial_markers:
+ - "Automated installation complete"
+ failure_markers:
+ - "Automated installation FAILED"
diff --git a/tests/integration/scenarios/fail-snapshots-on-ext4.yaml b/tests/integration/scenarios/fail-snapshots-on-ext4.yaml
new file mode 100644
index 00000000..2a2427c7
--- /dev/null
+++ b/tests/integration/scenarios/fail-snapshots-on-ext4.yaml
@@ -0,0 +1,19 @@
+# Regression: snapshots: timeshift-btrfs on a non-btrfs layout must abort at
+# schema validation (the cross-section guard), before any destructive action.
+name: fail-snapshots-on-ext4
+description: timeshift-btrfs snapshots on an ext4 layout fails fast at validation
+
+firmware: bios
+memory_mb: 3072
+cpus: 2
+disk_gb: 10
+
+answer_file: answers/fail-snapshots-on-ext4.yaml
+install_timeout_s: 420
+
+expect:
+ outcome: failure
+ serial_markers:
+ - "Automated installation complete"
+ failure_markers:
+ - "Automated installation FAILED"
diff --git a/tests/integration/scenarios/flatpak.yaml b/tests/integration/scenarios/flatpak.yaml
new file mode 100644
index 00000000..7aca03d1
--- /dev/null
+++ b/tests/integration/scenarios/flatpak.yaml
@@ -0,0 +1,43 @@
+# Flatpak install: Flathub remote + two small popular apps installed at install
+# time (in the chroot), then verified present on the booted system over SSH.
+name: flatpak
+description: Unattended install with Flathub + two flatpak apps, verified on boot
+
+firmware: bios
+tpm: false
+memory_mb: 4096
+cpus: 2
+disk_gb: 25
+
+answer_file: answers/flatpak.yaml
+
+# The runtime + apps are a few hundred MB pulled from Flathub during install.
+install_timeout_s: 2400
+boot_timeout_s: 300
+
+expect:
+ serial_markers:
+ - "Automated installation complete"
+ failure_markers:
+ - "Automated installation FAILED"
+
+ssh:
+ user: testuser
+
+verify:
+ - name: flatpak-installed
+ type: command
+ command: command -v flatpak
+ expect_substring: "flatpak"
+ - name: flathub-remote-added
+ type: command
+ command: flatpak remotes
+ expect_substring: "flathub"
+ - name: flatseal-app-installed
+ type: command
+ command: flatpak list --app --columns=application
+ expect_substring: "com.github.tchx84.Flatseal"
+ - name: calculator-app-installed
+ type: command
+ command: flatpak list --app --columns=application
+ expect_substring: "org.gnome.Calculator"
diff --git a/tests/integration/scenarios/netboot-ipv6-simple.yaml b/tests/integration/scenarios/netboot-ipv6-simple.yaml
new file mode 100644
index 00000000..521a9c2b
--- /dev/null
+++ b/tests/integration/scenarios/netboot-ipv6-simple.yaml
@@ -0,0 +1,40 @@
+# IPv6 data-path netboot: the rootfs squashfs and the answer file are fetched
+# over IPv6, proving the install works on an IPv6 network.
+#
+# This is NOT a PXE boot. Enabling IPv6 in QEMU's user-mode network breaks the
+# firmware PXE boot (slirp has no DHCPv6 boot-URL option and the IPv4 PXE ROM
+# stalls when IPv6 is present), so the kernel/initrd are loaded directly by
+# QEMU instead. From there live-boot's fetch= (rootfs) and the installer's
+# live-installer.auto= (answer file) both use the IPv6 host (fd00::2), so the
+# part our code owns runs over IPv6. A real IPv6 deployment would PXE-boot over
+# IPv6 too (DHCPv6 + UEFI HTTP boot), which no slirp-based CI can emulate.
+
+name: netboot-ipv6-simple
+description: Unattended install fetching rootfs + answer file over IPv6
+
+firmware: bios
+tpm: false
+memory_mb: 8192
+cpus: 2
+disk_gb: 25
+
+netboot: true
+ipv6: true
+
+answer_file: answers/netboot-ipv6.yaml
+
+install_timeout_s: 1800
+boot_timeout_s: 300
+
+# The proof here is that the install completes after fetching the rootfs and
+# the answer file over IPv6. The installed-system boot + SSH verify is the same
+# code the BIOS/UEFI PXE scenarios already cover, and it needs apt (internet
+# DNS), which is out of scope for an IPv6 data-path test — so skip phase 2.
+skip_boot_phase: true
+skip_boot_reason: "IPv6 data-path scenario; phase-2 boot/verify covered by the PXE scenarios"
+
+expect:
+ serial_markers:
+ - "Automated installation complete"
+ failure_markers:
+ - "Automated installation FAILED"
diff --git a/tests/integration/scenarios/pxe-simple.yaml b/tests/integration/scenarios/pxe-simple.yaml
new file mode 100644
index 00000000..6fb7f8c4
--- /dev/null
+++ b/tests/integration/scenarios/pxe-simple.yaml
@@ -0,0 +1,53 @@
+# Network-boot scenario: BIOS firmware, PXE/iPXE, single disk, simple layout.
+#
+# Unlike the other scenarios, no install media is attached. QEMU's user-mode
+# network provides DHCP/TFTP and the NIC's iPXE ROM; the kernel/initrd come
+# over TFTP, live-boot fetches the squashfs over HTTP, and the installer
+# fetches its answer file over HTTP. Proves the unattended install works end
+# to end when everything is network-delivered.
+#
+# Reuses the bios-simple answer file and checks; only the delivery differs.
+
+name: pxe-simple
+description: Unattended BIOS install, network-booted via iPXE, simple layout
+
+firmware: bios
+tpm: false
+# Higher than the media scenarios: live-boot fetches the squashfs into RAM.
+memory_mb: 8192
+cpus: 2
+disk_gb: 25
+
+netboot: true
+
+answer_file: answers/bios-simple.yaml
+
+install_timeout_s: 1800
+boot_timeout_s: 300
+
+expect:
+ serial_markers:
+ - "Automated installation complete"
+ failure_markers:
+ - "Automated installation FAILED"
+
+ssh:
+ user: testuser
+
+verify:
+ - name: os-is-lmde
+ type: command
+ command: ". /etc/os-release && echo $PRETTY_NAME"
+ expect_substring: "LMDE"
+ - name: hostname-applied
+ type: command
+ command: hostname
+ expect_substring: "lmde-test-01"
+ - name: root-on-plain-partition
+ type: mount_source
+ mountpoint: /
+ source_contains: "/dev/"
+ - name: network-manager-running
+ type: command
+ command: systemctl is-active NetworkManager
+ expect_substring: "active"
diff --git a/tests/integration/scenarios/pxe-uefi-simple.yaml b/tests/integration/scenarios/pxe-uefi-simple.yaml
new file mode 100644
index 00000000..a69894f0
--- /dev/null
+++ b/tests/integration/scenarios/pxe-uefi-simple.yaml
@@ -0,0 +1,52 @@
+# Network-boot scenario: UEFI firmware, PXE/iPXE, single disk, simple layout.
+#
+# Same as pxe-simple but under OVMF. UEFI can't run a raw iPXE script the way
+# the BIOS NIC option ROM does, so OVMF PXE-loads an EFI binary (ipxe.efi,
+# built in vm-setup) whose embedded script chainloads the per-run boot.ipxe
+# over TFTP; from there the boot is identical to the BIOS netboot path.
+#
+# Reuses the bios-simple answer file and checks; firmware and delivery differ.
+
+name: pxe-uefi-simple
+description: Unattended UEFI install, network-booted via iPXE, simple layout
+
+firmware: uefi
+tpm: false
+# Higher than the media scenarios: live-boot fetches the squashfs into RAM.
+memory_mb: 8192
+cpus: 2
+disk_gb: 25
+
+netboot: true
+
+answer_file: answers/bios-simple.yaml
+
+install_timeout_s: 1800
+boot_timeout_s: 300
+
+expect:
+ serial_markers:
+ - "Automated installation complete"
+ failure_markers:
+ - "Automated installation FAILED"
+
+ssh:
+ user: testuser
+
+verify:
+ - name: os-is-lmde
+ type: command
+ command: ". /etc/os-release && echo $PRETTY_NAME"
+ expect_substring: "LMDE"
+ - name: hostname-applied
+ type: command
+ command: hostname
+ expect_substring: "lmde-test-01"
+ - name: booted-via-uefi
+ type: command
+ command: "[ -d /sys/firmware/efi ] && echo efi-boot"
+ expect_substring: "efi-boot"
+ - name: network-manager-running
+ type: command
+ command: systemctl is-active NetworkManager
+ expect_substring: "active"
diff --git a/tests/integration/scenarios/raid1-bios.yaml b/tests/integration/scenarios/raid1-bios.yaml
new file mode 100644
index 00000000..01ddc804
--- /dev/null
+++ b/tests/integration/scenarios/raid1-bios.yaml
@@ -0,0 +1,55 @@
+# BIOS software RAID1 install across two disks, booted and verified over SSH.
+# Proves the multi-disk + mdadm + grub-to-every-member path: the system boots
+# from an assembled array with both mirrors healthy.
+name: raid1-bios
+description: Unattended BIOS install onto RAID1 (/boot + /), booted from the array
+
+firmware: bios
+tpm: false
+memory_mb: 4096
+cpus: 2
+
+disks:
+ - {size_gb: 20, serial: raidA}
+ - {size_gb: 20, serial: raidB}
+boot_disk_serial: raidA
+
+answer_file: answers/raid1-bios.yaml
+
+install_timeout_s: 1800
+boot_timeout_s: 360
+
+expect:
+ serial_markers:
+ - "Automated installation complete"
+ failure_markers:
+ - "Automated installation FAILED"
+
+ssh:
+ user: testuser
+
+verify:
+ - name: os-is-lmde
+ type: command
+ command: ". /etc/os-release && echo $PRETTY_NAME"
+ expect_substring: "LMDE"
+ - name: hostname-applied
+ type: command
+ command: hostname
+ expect_substring: "lmde-raid1-01"
+ - name: root-on-md-device
+ type: command
+ command: findmnt -n -o SOURCE /
+ expect_substring: "/dev/md"
+ - name: two-raid1-arrays-active
+ type: command
+ command: "grep -c 'active raid1' /proc/mdstat"
+ expect_substring: "2"
+ # [UU] = both mirror members present and in sync (no degraded array).
+ - name: arrays-healthy
+ type: command
+ command: "grep -c '\\[UU\\]' /proc/mdstat"
+ expect_substring: "2"
+ - name: mdadm-conf-written
+ type: file_exists
+ path: /etc/mdadm/mdadm.conf
diff --git a/tests/integration/scenarios/raid5-lvm-bios.yaml b/tests/integration/scenarios/raid5-lvm-bios.yaml
new file mode 100644
index 00000000..179d78bb
--- /dev/null
+++ b/tests/integration/scenarios/raid5-lvm-bios.yaml
@@ -0,0 +1,53 @@
+# BIOS RAID1 /boot + RAID5 (3 disks) -> LVM -> root. The full multi-level +
+# LVM-on-RAID case, booted from the array and verified over SSH.
+name: raid5-lvm-bios
+description: Unattended BIOS install, RAID1 /boot + RAID5 LVM root, booted from the array
+
+firmware: bios
+tpm: false
+memory_mb: 4096
+cpus: 2
+
+disks:
+ - {size_gb: 16, serial: r5a}
+ - {size_gb: 16, serial: r5b}
+ - {size_gb: 16, serial: r5c}
+boot_disk_serial: r5a
+
+answer_file: answers/raid5-lvm-bios.yaml
+
+install_timeout_s: 1800
+boot_timeout_s: 360
+
+expect:
+ serial_markers:
+ - "Automated installation complete"
+ failure_markers:
+ - "Automated installation FAILED"
+
+ssh:
+ user: testuser
+
+verify:
+ - name: hostname-applied
+ type: command
+ command: hostname
+ expect_substring: "lmde-raid5-01"
+ - name: root-on-lvm
+ type: command
+ command: findmnt -n -o SOURCE /
+ expect_substring: "/dev/mapper/vg0-root"
+ - name: raid1-and-raid5-active
+ type: command
+ command: "grep -cE 'active raid(1|5)' /proc/mdstat"
+ expect_substring: "2"
+ - name: raid5-three-members
+ type: command
+ command: "grep -A2 'active raid5' /proc/mdstat | grep -o '\\[UUU\\]'"
+ expect_substring: "UUU"
+ # The root LV's dependency stack (LV -> md1 -> disks) — proves LVM sits on a
+ # RAID device. lsblk needs no root, unlike pvs.
+ - name: lvm-on-raid-stack
+ type: command
+ command: "lsblk -nso NAME /dev/mapper/vg0-root | tr -d ' '"
+ expect_substring: "md"
diff --git a/tests/integration/scenarios/static-network.yaml b/tests/integration/scenarios/static-network.yaml
new file mode 100644
index 00000000..ad3850fa
--- /dev/null
+++ b/tests/integration/scenarios/static-network.yaml
@@ -0,0 +1,76 @@
+# Static IP + VLAN networking (dual-stack). A second, fixed-MAC NIC is given a
+# static IPv4+IPv6 address and an 802.1Q VLAN on top, all via the answer file's
+# netplan-v2 network: section rendered to NetworkManager keyfiles. The primary
+# NIC stays on DHCP so the harness keeps SSH reachability.
+
+name: static-network
+description: Unattended BIOS install, static dual-stack IP + VLAN on a 2nd NIC
+
+firmware: bios
+tpm: false
+memory_mb: 4096
+cpus: 2
+disk_gb: 25
+
+answer_file: answers/static-network.yaml
+
+# A second NIC with a stable MAC the answer file binds its static config to.
+extra_nics:
+ - "52:54:00:aa:bb:01"
+
+install_timeout_s: 1800
+boot_timeout_s: 300
+
+expect:
+ serial_markers:
+ - "Automated installation complete"
+ failure_markers:
+ - "Automated installation FAILED"
+
+ssh:
+ user: testuser
+
+verify:
+ - name: os-is-lmde
+ type: command
+ command: ". /etc/os-release && echo $PRETTY_NAME"
+ expect_substring: "LMDE"
+ - name: hostname-applied
+ type: command
+ command: hostname
+ expect_substring: "lmde-net-01"
+ # Wait (bounded) for NetworkManager to bring up the static connection, then
+ # assert the static IPv4 address landed on the second NIC.
+ - name: static-ipv4-configured
+ type: command
+ command: >-
+ for i in $(seq 1 30); do ip -4 addr | grep -q 192.168.50.10 && break; sleep 1; done; ip -4 addr
+ expect_substring: "192.168.50.10/24"
+ - name: static-ipv6-configured
+ type: command
+ command: ip -6 addr
+ expect_substring: "2001:db8:50::10"
+ - name: static-ipv4-gateway
+ type: command
+ command: ip -4 route
+ expect_substring: "default via 192.168.50.1"
+ - name: static-ipv6-gateway
+ type: command
+ command: ip -6 route
+ expect_substring: "default via 2001:db8:50::1"
+ - name: vlan-interface-id
+ type: command
+ command: ip -d link show vlan50
+ expect_substring: "id 50"
+ - name: vlan-ipv4-configured
+ type: command
+ command: ip -4 addr show vlan50
+ expect_substring: "10.50.0.5/24"
+ - name: vlan-ipv6-configured
+ type: command
+ command: ip -6 addr show vlan50
+ expect_substring: "2001:db8:5050::5"
+ - name: network-manager-running
+ type: command
+ command: systemctl is-active NetworkManager
+ expect_substring: "active"
diff --git a/tests/integration/scenarios/uefi-luks-prompt.yaml b/tests/integration/scenarios/uefi-luks-prompt.yaml
new file mode 100644
index 00000000..9fd67326
--- /dev/null
+++ b/tests/integration/scenarios/uefi-luks-prompt.yaml
@@ -0,0 +1,67 @@
+# UEFI, LVM-on-LUKS with passphrase_source: prompt-on-first-boot.
+#
+# Exercises the full first-boot rekey dance over serial: the first boot
+# auto-unlocks with the throwaway key embedded in the initramfs, a one-shot
+# prompts the operator to set + confirm the real passphrase, the service
+# rekeys and reboots, the second boot prompts at the initramfs, and the
+# unlocked system is verified over SSH — with no secret ever in the answer file.
+name: uefi-luks-prompt
+description: Unattended UEFI LUKS install, passphrase set + unlocked over serial on first boot
+
+firmware: uefi
+tpm: false
+memory_mb: 4096
+cpus: 2
+disk_gb: 25
+
+answer_file: answers/luks-prompt.yaml
+
+install_timeout_s: 1800
+boot_timeout_s: 360
+
+# Drive the first-boot rekey: set + confirm the passphrase, then unlock the
+# second boot at the initramfs. The passphrase the operator "types" is the
+# same fixture string the keyfile scenario uses.
+first_boot_rekey:
+ set_prompt: "Set the disk-encryption passphrase"
+ confirm_prompt: "Confirm the disk-encryption passphrase"
+ boot_prompt: "Please unlock disk"
+ passphrase_file: answers/luks.key
+ set_timeout_s: 360
+ boot_timeout_s: 360
+
+expect:
+ serial_markers:
+ - "Automated installation complete"
+ failure_markers:
+ - "Automated installation FAILED"
+
+ssh:
+ user: testuser
+
+verify:
+ - name: hostname-applied
+ type: command
+ command: hostname
+ expect_substring: "lmde-luks-prompt-01"
+ - name: root-on-luks-lvm
+ type: mount_source
+ mountpoint: /
+ source_contains: "lvmmint-root"
+ - name: luks-device-open
+ type: command
+ command: "lsblk -rno TYPE | grep -c crypt"
+ expect_substring: "1"
+ # The throwaway key, keyfile, and rekey one-shot must all be gone after rekey.
+ - name: throwaway-keyfile-removed
+ type: command
+ command: "test -e /etc/cryptsetup-keys.d/cryptroot.key && echo present || echo absent"
+ expect_substring: "absent"
+ - name: rekey-service-removed
+ type: command
+ command: "systemctl cat li-luks-rekey.service >/dev/null 2>&1 && echo present || echo absent"
+ expect_substring: "absent"
+ - name: crypttab-prompts
+ type: command
+ command: "awk '$1==\"lvmmint\"{print $3}' /etc/crypttab"
+ expect_substring: "none"
diff --git a/tests/integration/scenarios/uefi-lvm-luks.yaml b/tests/integration/scenarios/uefi-lvm-luks.yaml
new file mode 100644
index 00000000..94032d72
--- /dev/null
+++ b/tests/integration/scenarios/uefi-lvm-luks.yaml
@@ -0,0 +1,49 @@
+# UEFI firmware, LVM-on-LUKS layout, passphrase from a keyfile URL.
+#
+# Full boot/verify: the answer file's kernel.serial_console provisions a
+# serial console on the installed system, so the initramfs LUKS prompt
+# appears on ttyS0; the harness types the passphrase over serial, then
+# verifies the unlocked system over SSH. Exercises luksFormat (passphrase
+# via stdin), LVM-on-LUKS stacking, crypttab, grub-efi, and the
+# serial-console provisioning path end to end.
+name: uefi-lvm-luks
+description: Unattended UEFI install with LUKS-encrypted LVM, unlocked over serial
+
+firmware: uefi
+tpm: false
+memory_mb: 4096
+cpus: 2
+disk_gb: 25
+
+answer_file: answers/uefi-lvm-luks.yaml
+
+install_timeout_s: 1800
+boot_timeout_s: 300
+
+boot_unlock:
+ prompt: "Please unlock disk"
+ passphrase_file: answers/luks.key
+ timeout_s: 180
+
+expect:
+ serial_markers:
+ - "Automated installation complete"
+ failure_markers:
+ - "Automated installation FAILED"
+
+ssh:
+ user: testuser
+
+verify:
+ - name: hostname-applied
+ type: command
+ command: hostname
+ expect_substring: "lmde-luks-01"
+ - name: root-on-luks-lvm
+ type: mount_source
+ mountpoint: /
+ source_contains: "lvmmint-root"
+ - name: luks-device-open
+ type: command
+ command: "lsblk -rno TYPE | grep -c crypt"
+ expect_substring: "1"
diff --git a/tests/integration/scenarios/uefi-lvm.yaml b/tests/integration/scenarios/uefi-lvm.yaml
new file mode 100644
index 00000000..62597725
--- /dev/null
+++ b/tests/integration/scenarios/uefi-lvm.yaml
@@ -0,0 +1,46 @@
+# UEFI firmware, single disk, LVM layout (vg lvmmint, lv root + swap).
+name: uefi-lvm
+description: Unattended UEFI install with the lvm layout preset
+
+firmware: uefi
+tpm: false
+memory_mb: 4096
+cpus: 2
+disk_gb: 25
+
+answer_file: answers/uefi-lvm.yaml
+
+install_timeout_s: 1800
+boot_timeout_s: 300
+
+expect:
+ serial_markers:
+ - "Automated installation complete"
+ failure_markers:
+ - "Automated installation FAILED"
+
+ssh:
+ user: testuser
+
+verify:
+ - name: os-is-lmde
+ type: command
+ command: ". /etc/os-release && echo $PRETTY_NAME"
+ expect_substring: "LMDE"
+ - name: hostname-applied
+ type: command
+ command: hostname
+ expect_substring: "lmde-lvm-01"
+ - name: root-on-lvm
+ type: mount_source
+ mountpoint: /
+ source_contains: "lvmmint-root"
+ - name: swap-on-lvm
+ # lsblk, not swapon: /usr/sbin is not on a non-root SSH user's PATH
+ type: command
+ command: "lsblk -rno NAME,MOUNTPOINTS"
+ expect_substring: "lvmmint-swap [SWAP]"
+ - name: network-manager-running
+ type: command
+ command: systemctl is-active NetworkManager
+ expect_substring: "active"
diff --git a/tests/integration/scenarios/uefi-simple.yaml b/tests/integration/scenarios/uefi-simple.yaml
new file mode 100644
index 00000000..13cf684c
--- /dev/null
+++ b/tests/integration/scenarios/uefi-simple.yaml
@@ -0,0 +1,49 @@
+# Baseline scenario: BIOS firmware, single disk, simple layout.
+#
+# NOTE: the full install phase requires the headless driver and
+# answer-file support in the installer (not yet implemented). Until
+# then this scenario is only runnable in smoke mode:
+#
+# harness/run_scenario.py scenarios/bios-simple.yaml --smoke
+
+name: uefi-simple
+description: Unattended BIOS install, single disk, simple layout, UEFI
+
+firmware: uefi
+tpm: false
+memory_mb: 4096
+cpus: 2
+disk_gb: 25
+
+# Served to the VM over HTTP from this directory (10.0.2.2 = host)
+answer_file: answers/bios-simple.yaml
+
+install_timeout_s: 1800
+boot_timeout_s: 300
+
+expect:
+ serial_markers:
+ - "Automated installation complete"
+ failure_markers:
+ - "Automated installation FAILED"
+
+ssh:
+ user: testuser
+
+verify:
+ - name: os-is-lmde
+ type: command
+ command: ". /etc/os-release && echo $PRETTY_NAME"
+ expect_substring: "LMDE"
+ - name: hostname-applied
+ type: command
+ command: hostname
+ expect_substring: "lmde-test-01"
+ - name: root-on-plain-partition
+ type: mount_source
+ mountpoint: /
+ source_contains: "/dev/"
+ - name: network-manager-running
+ type: command
+ command: systemctl is-active NetworkManager
+ expect_substring: "active"
diff --git a/tests/nfs/README.md b/tests/nfs/README.md
new file mode 100644
index 00000000..1c136f4e
--- /dev/null
+++ b/tests/nfs/README.md
@@ -0,0 +1,29 @@
+# Real NFS transport tests
+
+These exercise the installer's `nfs://` answer-file transport against a **live**
+NFS server, across the full matrix of protocol version × address form:
+
+| | IPv4 (`127.0.0.1`) | IPv6 (`[::1]`) | hostname (`li-nfs-host`) |
+|---|---|---|---|
+| **NFSv3** | ✓ | ✓ | ✓ |
+| **NFSv4.2** | ✓ | ✓ | ✓ |
+
+…plus a negotiated-default case (no `?vers=`).
+
+They are skipped unless `LI_NFS_TEST=1` is set, because they need a provisioned
+NFS server and root (`mount(2)` needs `CAP_SYS_ADMIN`). The normal unit suite
+(`pytest tests/unit`) stays hermetic and rootless.
+
+## Running
+
+In CI this is the `nfs tests` workflow (`.github/workflows/nfs-tests.yml`). To
+run on a disposable Linux host (a VM or container — **not** your workstation,
+the setup script edits `/etc/exports.d`, `/etc/nfs.conf.d`, and `/etc/hosts`):
+
+```sh
+bash tests/nfs/setup-nfs-server.sh
+sudo env LI_NFS_TEST=1 "$(command -v python)" -m pytest tests/nfs -v
+```
+
+The server exports a small valid `answer.yaml` read-only over both NFSv3 and
+NFSv4.2, reachable on IPv4, IPv6, and the `li-nfs-host` hostname.
diff --git a/tests/nfs/conftest.py b/tests/nfs/conftest.py
new file mode 100644
index 00000000..b369f09b
--- /dev/null
+++ b/tests/nfs/conftest.py
@@ -0,0 +1,12 @@
+"""Make the installer modules importable for the real-NFS tests.
+
+Unlike the unit conftest, no GTK/parted stubs are needed: auto_installer (the
+only module these tests touch) imports cleanly on its own.
+"""
+
+import sys
+from pathlib import Path
+
+SOURCE_DIR = Path(__file__).resolve().parents[2] / "usr" / "lib" / "live-installer"
+if str(SOURCE_DIR) not in sys.path:
+ sys.path.insert(0, str(SOURCE_DIR))
diff --git a/tests/nfs/setup-nfs-server.sh b/tests/nfs/setup-nfs-server.sh
new file mode 100755
index 00000000..acfedeb7
--- /dev/null
+++ b/tests/nfs/setup-nfs-server.sh
@@ -0,0 +1,69 @@
+#!/usr/bin/env bash
+# Provision a real NFS server on the CI runner (or any throwaway host) that
+# exports a small answer file over BOTH NFSv3 and NFSv4.2, reachable via IPv4,
+# IPv6, and a hostname. The tests in tests/nfs/test_nfs_real.py then fetch it
+# through the installer's actual nfs:// transport with vers= pinned.
+#
+# Destructive: installs packages and edits /etc/exports.d, /etc/nfs.conf.d, and
+# /etc/hosts. Intended for CI / disposable VMs only — do NOT run on a workstation.
+set -euxo pipefail
+
+EXPORT_DIR=/srv/li-nfs-test
+TEST_HOST=li-nfs-host
+
+sudo apt-get update
+sudo DEBIAN_FRONTEND=noninteractive apt-get install -y \
+ nfs-kernel-server nfs-common rpcbind
+
+# The answer file the tests fetch. A valid, minimal config so a future test
+# could even drive an install from it.
+sudo mkdir -p "$EXPORT_DIR"
+sudo tee "$EXPORT_DIR/answer.yaml" >/dev/null <<'EOF'
+version: 1
+locale: en_US.UTF-8
+timezone: America/Toronto
+users:
+ - name: nfsuser
+ passwd: "$6$rounds=4096$salt$hashhashhashhashhashhashhash"
+storage:
+ target:
+ match:
+ first-non-removable: true
+EOF
+sudo chmod -R a+rX "$EXPORT_DIR"
+
+# Export read-only to everyone on the loopback host. `insecure` allows the
+# client to connect from a non-reserved port; harmless for a localhost test.
+sudo mkdir -p /etc/exports.d
+echo "$EXPORT_DIR *(ro,sync,no_subtree_check,insecure)" \
+ | sudo tee /etc/exports.d/li-nfs.exports >/dev/null
+
+# Explicitly enable v3 and the full v4 range so both ends of the test matrix
+# are actually offered by the server (some images ship with v3 disabled).
+sudo mkdir -p /etc/nfs.conf.d
+sudo tee /etc/nfs.conf.d/li-nfs.conf >/dev/null <<'EOF'
+[nfsd]
+vers3 = y
+vers4 = y
+vers4.0 = y
+vers4.1 = y
+vers4.2 = y
+EOF
+
+# Resolve the hostname test case to loopback on both stacks.
+if ! grep -q "$TEST_HOST" /etc/hosts; then
+ printf '127.0.0.1 %s\n::1 %s\n' "$TEST_HOST" "$TEST_HOST" \
+ | sudo tee -a /etc/hosts >/dev/null
+fi
+
+# (Re)start the services. systemd on modern runners; fall back to service(8).
+sudo systemctl restart rpcbind 2>/dev/null || sudo service rpcbind restart || true
+sudo systemctl restart nfs-server 2>/dev/null \
+ || sudo systemctl restart nfs-kernel-server 2>/dev/null \
+ || sudo service nfs-kernel-server restart
+sudo exportfs -ra
+
+# Diagnostics, so a CI failure is debuggable from the log alone.
+echo "--- exportfs -v ---"; sudo exportfs -v
+echo "--- rpcinfo nfs versions ---"; rpcinfo -p 2>/dev/null | grep -i nfs || true
+echo "NFS server ready: $EXPORT_DIR over v3+v4.2 (IPv4/IPv6/$TEST_HOST)"
diff --git a/tests/nfs/test_nfs_real.py b/tests/nfs/test_nfs_real.py
new file mode 100644
index 00000000..6ec4d37f
--- /dev/null
+++ b/tests/nfs/test_nfs_real.py
@@ -0,0 +1,43 @@
+"""Real NFS transport tests: fetch an answer file through the installer's
+actual nfs:// path against a live NFS server, across the full matrix of
+protocol version x address form.
+
+These need a provisioned NFS server (tests/nfs/setup-nfs-server.sh) and root
+(mount(2) needs CAP_SYS_ADMIN), so they are skipped unless LI_NFS_TEST=1 is
+set — which the nfs-tests CI job does after running the setup script. The
+normal unit suite stays hermetic and rootless.
+"""
+
+import os
+
+import pytest
+
+import auto_installer
+
+pytestmark = pytest.mark.skipif(
+ os.environ.get("LI_NFS_TEST") != "1",
+ reason="real NFS server not provisioned; set LI_NFS_TEST=1 "
+ "(see tests/nfs/setup-nfs-server.sh)")
+
+EXPORT = "/srv/li-nfs-test" # must match the setup script
+MARKER = "version: 1" # a line in the exported answer.yaml
+
+# Address forms the server is reachable by (setup adds li-nfs-host to /etc/hosts
+# on both stacks). nfs:// needs an IPv6 literal bracketed.
+HOSTS = ["127.0.0.1", "[::1]", "li-nfs-host"]
+VERSIONS = ["3", "4.2"]
+
+
+@pytest.mark.parametrize("vers", VERSIONS)
+@pytest.mark.parametrize("host", HOSTS)
+def test_real_nfs_fetch(host, vers):
+ url = f"nfs://{host}{EXPORT}/answer.yaml?vers={vers}"
+ text = auto_installer.fetch_answer_file(url, insecure=True)
+ assert MARKER in text
+
+
+def test_real_nfs_negotiated_default():
+ # No vers= -> mount.nfs negotiates (should land on v4.2 against this server).
+ url = f"nfs://127.0.0.1{EXPORT}/answer.yaml"
+ text = auto_installer.fetch_answer_file(url, insecure=True)
+ assert MARKER in text
diff --git a/tests/requirements-test.txt b/tests/requirements-test.txt
new file mode 100644
index 00000000..a6c3223b
--- /dev/null
+++ b/tests/requirements-test.txt
@@ -0,0 +1,6 @@
+# Test-only dependencies. The installer's runtime deps (pyyaml, pydantic)
+# are declared in debian/control; they are repeated here so the unit
+# suite can run in a bare virtualenv without the .deb installed.
+pytest>=7
+pyyaml>=6
+pydantic>=2
diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py
new file mode 100644
index 00000000..62753886
--- /dev/null
+++ b/tests/unit/conftest.py
@@ -0,0 +1,99 @@
+"""Test scaffolding for importing live-installer modules outside a live session.
+
+The installer modules import GTK (gi), pyparted and the project's own
+dialogs module at import time. None of those are needed by the pure
+helpers under test, so lightweight stubs are installed into sys.modules
+before the real modules are imported. This keeps the unit tests runnable
+on any machine with nothing but Python and pytest.
+"""
+
+import sys
+import types
+from pathlib import Path
+
+SOURCE_DIR = Path(__file__).resolve().parents[2] / "usr" / "lib" / "live-installer"
+
+
+class _StubBase:
+ """Stand-in for any GTK class used as a base class (e.g. Gtk.TreeStore)."""
+
+ def __init__(self, *args, **kwargs):
+ pass
+
+
+def _make_module(name, **attrs):
+ module = types.ModuleType(name)
+ for key, value in attrs.items():
+ setattr(module, key, value)
+ sys.modules[name] = module
+ return module
+
+
+def _install_stubs():
+ if getattr(sys.modules.get("gi"), "_live_installer_test_stub", False):
+ return
+
+ gi = _make_module("gi", require_version=lambda *a, **k: None)
+ gi._live_installer_test_stub = True
+
+ gtk = types.SimpleNamespace(TreeStore=_StubBase)
+ gdk = types.SimpleNamespace()
+ glib = types.SimpleNamespace(idle_add=lambda func, *a, **k: func(*a, **k))
+ repository = _make_module("gi.repository", Gtk=gtk, Gdk=gdk, GLib=glib)
+ gi.repository = repository
+
+ # pyparted: only the constants referenced by partitioning.py are needed.
+ # The exact values are irrelevant to the code under test; they only have
+ # to be distinct so they can serve as dict keys / comparison sentinels.
+ _make_module(
+ "parted",
+ PARTITION_NORMAL=0,
+ PARTITION_LOGICAL=1,
+ PARTITION_EXTENDED=2,
+ PARTITION_FREESPACE=4,
+ PARTITION_METADATA=8,
+ PARTITION_LVM=16,
+ PARTITION_SWAP=32,
+ PARTITION_RAID=64,
+ PARTITION_PALO=128,
+ PARTITION_PREP=256,
+ PARTITION_HPSERVICE=512,
+ PARTITION_MSFT_RESERVED=1024,
+ )
+
+ _make_module("dialogs", QuestionDialog=lambda *a, **k: True)
+
+
+_install_stubs()
+
+if str(SOURCE_DIR) not in sys.path:
+ sys.path.insert(0, str(SOURCE_DIR))
+
+
+# partitioning.py reads /usr/share/live-installer/disk-partitions.html at
+# import time. Redirect reads of the installed resource path to the copy
+# shipped in the repo, but only for the duration of the initial import.
+RESOURCE_PREFIX = "/usr/share/live-installer/"
+REPO_RESOURCE_DIR = Path(__file__).resolve().parents[2] / "usr" / "share" / "live-installer"
+
+
+def _import_with_repo_resources():
+ if "partitioning" in sys.modules:
+ return
+ import builtins
+
+ real_open = builtins.open
+
+ def redirecting_open(file, *args, **kwargs):
+ if isinstance(file, str) and file.startswith(RESOURCE_PREFIX):
+ file = str(REPO_RESOURCE_DIR / file[len(RESOURCE_PREFIX):])
+ return real_open(file, *args, **kwargs)
+
+ builtins.open = redirecting_open
+ try:
+ import partitioning # noqa: F401
+ finally:
+ builtins.open = real_open
+
+
+_import_with_repo_resources()
diff --git a/tests/unit/test_auto_installer.py b/tests/unit/test_auto_installer.py
new file mode 100644
index 00000000..f02a7069
--- /dev/null
+++ b/tests/unit/test_auto_installer.py
@@ -0,0 +1,1130 @@
+"""Unit tests for the headless driver: config mapping, answer-file
+acquisition, and the post-engine failure-policy machinery."""
+
+import os
+import socket
+import textwrap
+import threading
+import types
+
+import pytest
+
+import auto_installer
+import schema
+from test_engine_commands import RecordingRunner
+
+
+def _start_tftp_server(content, *, send_error=False, oack_blksize=None,
+ reject_options=False):
+ """One-shot localhost TFTP read server for tests. Returns (host, port).
+
+ Modes:
+ - default: ignores any options, serves plain RFC 1350 (512-byte blocks).
+ This exercises the client's option-negotiation FALLBACK path.
+ - oack_blksize=N: negotiates blksize=N via an OACK (RFC 2347/2348).
+ - reject_options: answers an options request with ERROR code 8, then
+ serves the client's bare retry as plain RFC 1350.
+ """
+ srv = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ srv.bind(("127.0.0.1", 0))
+ port = srv.getsockname()[1]
+
+ def send_blocks(client, blksize):
+ blocks = [content[i:i + blksize] for i in range(0, len(content), blksize)]
+ if not blocks or len(blocks[-1]) == blksize:
+ blocks.append(b"") # short/empty block terminates the transfer
+ for n, chunk in enumerate(blocks, 1):
+ srv.sendto(b"\x00\x03" + n.to_bytes(2, "big") + chunk, client)
+ try:
+ srv.recvfrom(2048) # ACK
+ except OSError:
+ break
+
+ def serve():
+ srv.settimeout(5)
+ try:
+ rrq, client = srv.recvfrom(2048)
+ except OSError:
+ srv.close()
+ return
+ if send_error:
+ srv.sendto(b"\x00\x05\x00\x01File not found\x00", client)
+ srv.close()
+ return
+ has_options = b"blksize\x00" in rrq
+ if reject_options and has_options:
+ srv.sendto(b"\x00\x05\x00\x08option rejected\x00", client)
+ try:
+ _retry, client = srv.recvfrom(2048) # the bare retry
+ except OSError:
+ srv.close()
+ return
+ send_blocks(client, 512)
+ elif oack_blksize is not None and has_options:
+ srv.sendto(b"\x00\x06blksize\x00%d\x00" % oack_blksize, client)
+ try:
+ srv.recvfrom(2048) # ACK of block 0
+ except OSError:
+ srv.close()
+ return
+ send_blocks(client, oack_blksize)
+ else:
+ send_blocks(client, 512) # ignore options -> RFC 1350 fallback
+ srv.close()
+
+ threading.Thread(target=serve, daemon=True).start()
+ return "127.0.0.1", port
+
+
+def make_config(extra="", logging_dest="/tmp/test-auto-install.log"):
+ return schema.parse_config(textwrap.dedent(f"""\
+ version: 1
+ hostname: ws-01
+ locale: en_CA.UTF-8
+ timezone: America/Toronto
+ users:
+ - name: admin
+ gecos: The Admin
+ passwd: "$6$rounds=4096$salt$hash"
+ autologin: true
+ groups: [sudo]
+ storage:
+ target:
+ match:
+ first-non-removable: true
+ logging:
+ destination: {logging_dest}
+ """) + textwrap.dedent(extra))
+
+
+class TestFetchAnswerFile:
+ def test_local_path(self, tmp_path):
+ f = tmp_path / "answer.yaml"
+ f.write_text("version: 1\n")
+ assert auto_installer.fetch_answer_file(str(f)) == "version: 1\n"
+
+ def test_missing_path(self):
+ with pytest.raises(schema.ConfigError):
+ auto_installer.fetch_answer_file("/nonexistent/answer.yaml")
+
+ def test_plain_http_refused(self):
+ with pytest.raises(schema.ConfigError) as excinfo:
+ auto_installer.fetch_answer_file("http://example.com/a.yaml")
+ assert "plain HTTP" in str(excinfo.value)
+ assert "--insecure" in str(excinfo.value)
+
+ def test_nfs_refused_without_insecure(self):
+ with pytest.raises(schema.ConfigError) as excinfo:
+ auto_installer.fetch_answer_file("nfs://host/export/a.yaml")
+ assert "NFS" in str(excinfo.value)
+ assert "--insecure" in str(excinfo.value)
+
+
+class TestNfsFetch:
+ @pytest.mark.parametrize("url,expected", [
+ ("nfs://host/export/dir/a.yaml", ("host", "/export/dir", "a.yaml", None)),
+ ("nfs://host:2049/export/a.yaml", ("host", "/export", "a.yaml", None)),
+ ("nfs://10.0.0.1/srv/cfg/host.yaml",
+ ("10.0.0.1", "/srv/cfg", "host.yaml", None)),
+ # IPv6 literals: urlsplit strips brackets and any :port
+ ("nfs://[2001:db8::1]/srv/cfg/host.yaml",
+ ("2001:db8::1", "/srv/cfg", "host.yaml", None)),
+ ("nfs://[2001:db8::1]:2049/export/a.yaml",
+ ("2001:db8::1", "/export", "a.yaml", None)),
+ # explicit protocol version
+ ("nfs://host/export/a.yaml?vers=3", ("host", "/export", "a.yaml", "3")),
+ ("nfs://[2001:db8::1]/export/a.yaml?vers=4.2",
+ ("2001:db8::1", "/export", "a.yaml", "4.2")),
+ ])
+ def test_parse_nfs_url(self, url, expected):
+ assert auto_installer._parse_nfs_url(url) == expected
+
+ def test_parse_nfs_url_malformed(self):
+ with pytest.raises(schema.ConfigError):
+ auto_installer._parse_nfs_url("nfs://hostonly")
+
+ def test_parse_nfs_url_bad_vers(self):
+ with pytest.raises(schema.ConfigError) as exc:
+ auto_installer._parse_nfs_url("nfs://host/export/a.yaml?vers=5")
+ assert "vers" in str(exc.value)
+
+ def test_nfs_mount_passes_vers_option(self, monkeypatch):
+ captured = {}
+
+ def fake_run(argv, **kw):
+ captured["argv"] = argv
+ class R: # noqa: D401 - minimal CompletedProcess stand-in
+ returncode = 0
+ stderr = ""
+ return R()
+ monkeypatch.setattr(auto_installer.subprocess, "run", fake_run)
+ monkeypatch.setattr(auto_installer.os, "rmdir", lambda p: None)
+ auto_installer._nfs_mount("host", "/export", vers="4.2")
+ opts = captured["argv"][captured["argv"].index("-o") + 1]
+ assert "vers=4.2" in opts
+ # default (no vers) must not inject a version
+ auto_installer._nfs_mount("host", "/export")
+ opts = captured["argv"][captured["argv"].index("-o") + 1]
+ assert "vers=" not in opts
+
+ @pytest.mark.parametrize("host,export,expected", [
+ ("host", "/export", "host:/export"),
+ ("10.0.0.1", "/srv", "10.0.0.1:/srv"),
+ # an IPv6 literal must be bracketed or mount.nfs reads it as host:port
+ ("2001:db8::1", "/export", "[2001:db8::1]:/export"),
+ ])
+ def test_nfs_mount_source_brackets_ipv6(self, host, export, expected):
+ assert auto_installer._nfs_mount_source(host, export) == expected
+
+ def test_fetch_nfs_mounts_reads_unmounts(self, tmp_path, monkeypatch):
+ # Stand in a real dir for the "mount", assert it is read and unmounted.
+ export = tmp_path / "export"
+ export.mkdir()
+ (export / "a.yaml").write_text("version: 1\n")
+ umounted = []
+ monkeypatch.setattr(auto_installer, "_nfs_mount",
+ lambda host, d, vers=None: str(export))
+ monkeypatch.setattr(auto_installer, "_nfs_umount",
+ lambda mp: umounted.append(mp))
+ text = auto_installer.fetch_answer_file(
+ "nfs://host/export/a.yaml", insecure=True)
+ assert text == "version: 1\n"
+ assert umounted == [str(export)] # always unmounts
+
+
+class TestTftpFetch:
+ @pytest.mark.parametrize("url,expected", [
+ ("tftp://host/install.yaml", ("host", 69, "install.yaml")),
+ ("tftp://host:6900/sub/dir/a.yaml", ("host", 6900, "sub/dir/a.yaml")),
+ ("tftp://[2001:db8::1]/x.yaml", ("2001:db8::1", 69, "x.yaml")),
+ ])
+ def test_parse(self, url, expected):
+ assert auto_installer._parse_tftp_url(url) == expected
+
+ def test_parse_malformed(self):
+ with pytest.raises(schema.ConfigError):
+ auto_installer._parse_tftp_url("tftp://hostonly")
+
+ @pytest.mark.parametrize("content", [
+ b"version: 1\n", # one short block
+ b"x" * 1500, # three blocks
+ b"y" * 512, # exact block boundary (needs the EOF block)
+ b"", # empty file
+ ])
+ def test_fetch_roundtrip(self, content):
+ host, port = _start_tftp_server(content)
+ text = auto_installer.fetch_answer_file(
+ f"tftp://{host}:{port}/anything", insecure=True)
+ assert text == content.decode("utf-8")
+
+ def test_refused_without_insecure(self):
+ with pytest.raises(schema.ConfigError) as excinfo:
+ auto_installer.fetch_answer_file("tftp://host/a.yaml")
+ assert "TFTP" in str(excinfo.value)
+ assert "--insecure" in str(excinfo.value)
+
+ def test_server_error_packet(self):
+ host, port = _start_tftp_server(b"", send_error=True)
+ with pytest.raises(schema.ConfigError) as excinfo:
+ auto_installer.fetch_answer_file(
+ f"tftp://{host}:{port}/missing", insecure=True)
+ assert "TFTP error" in str(excinfo.value)
+
+ def test_large_transfer_warns(self, capsys):
+ # RFC1350 512-byte blocks are slow for big files; warn past the
+ # threshold (review #8). Still completes correctly.
+ big = b"a" * (auto_installer._TFTP_WARN_BYTES + 2048)
+ host, port = _start_tftp_server(big)
+ text = auto_installer.fetch_answer_file(
+ f"tftp://{host}:{port}/big", insecure=True)
+ assert len(text) == len(big)
+ assert "exceeds" in capsys.readouterr().out
+
+ def test_negotiates_blksize_oack(self):
+ # Server OACKs blksize=1024 and serves in 1024-byte blocks. The content
+ # is chosen so its last block (700 bytes) is > 512: a client that did
+ # NOT honour the OACK (still expecting 512) would treat that as a
+ # non-terminal block and hang, so a clean roundtrip proves negotiation.
+ content = b"z" * (1024 * 2 + 700)
+ host, port = _start_tftp_server(content, oack_blksize=1024)
+ text = auto_installer.fetch_answer_file(
+ f"tftp://{host}:{port}/file", insecure=True)
+ assert text == content.decode("utf-8")
+
+ def test_option_rejection_falls_back_to_rfc1350(self):
+ # Server answers the options request with ERROR code 8; the client must
+ # retry without options and still complete over plain RFC 1350.
+ content = b"version: 1\n" + b"k" * 2000
+ host, port = _start_tftp_server(content, reject_options=True)
+ text = auto_installer.fetch_answer_file(
+ f"tftp://{host}:{port}/file", insecure=True)
+ assert text == content.decode("utf-8")
+
+ def test_parse_oack(self):
+ opts = auto_installer._parse_oack(b"blksize\x001428\x00tsize\x004096\x00")
+ assert opts == {"blksize": "1428", "tsize": "4096"}
+
+
+class TestCmdlineSource:
+ def test_present(self, tmp_path):
+ cmdline = tmp_path / "cmdline"
+ cmdline.write_text(
+ "boot=live quiet live-installer.auto=https://cfg/a.yaml splash\n"
+ )
+ assert auto_installer.cmdline_source(str(cmdline)) == "https://cfg/a.yaml"
+
+ def test_absent(self, tmp_path):
+ cmdline = tmp_path / "cmdline"
+ cmdline.write_text("boot=live quiet splash\n")
+ assert auto_installer.cmdline_source(str(cmdline)) is None
+
+ def test_insecure_flag(self, tmp_path):
+ cmdline = tmp_path / "cmdline"
+ cmdline.write_text(
+ "boot=live live-installer.auto=http://h/a.yaml "
+ "live-installer.auto-insecure\n"
+ )
+ assert auto_installer.cmdline_insecure(str(cmdline)) is True
+
+ def test_insecure_flag_absent(self, tmp_path):
+ cmdline = tmp_path / "cmdline"
+ cmdline.write_text("boot=live live-installer.auto=https://h/a.yaml\n")
+ assert auto_installer.cmdline_insecure(str(cmdline)) is False
+
+
+class TestBuildSetup:
+ def test_basic_mapping(self):
+ config = make_config()
+ setup = auto_installer.build_setup(
+ config, disk="/dev/vda", efi=False, is_mint=False)
+ assert setup.automated is True
+ assert setup.language == "en_CA" # encoding suffix stripped
+ assert setup.timezone == "America/Toronto"
+ assert setup.hostname == "ws-01"
+ assert setup.username == "admin" # Setup field name unchanged
+ assert setup.real_name == "The Admin"
+ assert setup.password1 == "$6$rounds=4096$salt$hash"
+ assert setup.password_is_crypted is True
+ assert setup.autologin is True
+ assert setup.lvm is False and setup.luks is False
+ assert setup.disk == "/dev/vda"
+ assert setup.diskname == "vda"
+ assert setup.grub_device == "/dev/vda"
+ assert setup.gptonefi is False
+ assert setup.is_mint is False
+
+ def test_keyboard_multilayout_and_locales(self):
+ config = make_config(extra=textwrap.dedent("""\
+ keyboard:
+ layout: us
+ additional_layouts:
+ - {layout: ca, variant: fr}
+ - {layout: gr}
+ toggle: grp:alt_shift_toggle
+ additional_locales: [fr_CA.UTF-8, de_DE.UTF-8]
+ """))
+ setup = auto_installer.build_setup(
+ config, disk="/dev/vda", efi=False, is_mint=False)
+ # comma-joined XKB lists, primary first
+ assert setup.keyboard_layout == "us,ca,gr"
+ assert setup.keyboard_variant == ",fr,"
+ assert setup.keyboard_options == "grp:alt_shift_toggle"
+ # codeset-stripped, like the primary language
+ assert setup.additional_locales == ["fr_CA", "de_DE"]
+
+ def test_keyboard_single_layout_no_variant_string(self):
+ config = make_config() # default keyboard, no extras
+ setup = auto_installer.build_setup(
+ config, disk="/dev/vda", efi=False, is_mint=False)
+ assert setup.keyboard_layout == "us"
+ assert setup.keyboard_variant == "" # all-empty -> empty, not ","
+ assert setup.keyboard_options is None
+
+ def test_lvm_on_luks_with_keyfile(self, tmp_path):
+ keyfile = tmp_path / "luks.key"
+ keyfile.write_text("sekrit-passphrase\n")
+ config = schema.parse_config(textwrap.dedent(f"""\
+ version: 1
+ locale: en_CA.UTF-8
+ timezone: America/Toronto
+ users:
+ - name: admin
+ passwd: "$6$rounds=4096$salt$hash"
+ storage:
+ layout: lvm-on-luks
+ luks:
+ passphrase_source: keyfile
+ keyfile: {keyfile}
+ target:
+ match:
+ first-non-removable: true
+ """))
+ setup = auto_installer.build_setup(
+ config, disk="/dev/vda", efi=True, is_mint=False)
+ assert setup.lvm is True and setup.luks is True
+ assert setup.passphrase1 == "sekrit-passphrase"
+ assert setup.gptonefi is True
+
+ def test_keyfile_over_plain_http_refused(self):
+ config = schema.parse_config(textwrap.dedent("""\
+ version: 1
+ locale: en_CA.UTF-8
+ timezone: America/Toronto
+ users:
+ - name: admin
+ passwd: "$6$rounds=4096$salt$hash"
+ storage:
+ layout: lvm-on-luks
+ luks:
+ passphrase_source: keyfile
+ keyfile: http://server/luks.key
+ target:
+ match:
+ first-non-removable: true
+ """))
+ with pytest.raises(schema.ConfigError) as excinfo:
+ auto_installer.build_setup(
+ config, disk="/dev/vda", efi=False, is_mint=False)
+ assert "plain HTTP" in str(excinfo.value)
+ # and allowed when insecure is explicitly granted (fetch will then
+ # fail on the unreachable host, which is a different error)
+ with pytest.raises(schema.ConfigError) as excinfo2:
+ auto_installer.build_setup(
+ config, disk="/dev/vda", efi=False, is_mint=False,
+ insecure=True)
+ assert "plain HTTP" not in str(excinfo2.value)
+
+ def _luks_config(self, source_line=""):
+ return schema.parse_config(textwrap.dedent(f"""\
+ version: 1
+ locale: en_CA.UTF-8
+ timezone: America/Toronto
+ users:
+ - name: admin
+ passwd: "$6$rounds=4096$salt$hash"
+ storage:
+ layout: lvm-on-luks
+ luks:
+ {source_line}
+ target:
+ match:
+ first-non-removable: true
+ """)) if source_line else schema.parse_config(textwrap.dedent("""\
+ version: 1
+ locale: en_CA.UTF-8
+ timezone: America/Toronto
+ users:
+ - name: admin
+ passwd: "$6$rounds=4096$salt$hash"
+ storage:
+ layout: lvm-on-luks
+ target:
+ match:
+ first-non-removable: true
+ """))
+
+ def test_prompt_on_first_boot_generates_throwaway_key(self):
+ # Default passphrase_source is prompt-on-first-boot.
+ setup = auto_installer.build_setup(
+ self._luks_config(), disk="/dev/vda", efi=False, is_mint=False)
+ assert setup.luks is True
+ assert setup.luks_rekey_on_first_boot is True
+ # A random, newline-free key, used for both slots.
+ assert setup.passphrase1 and "\n" not in setup.passphrase1
+ assert setup.passphrase1 == setup.passphrase2
+ # Two installs must not share the throwaway key.
+ other = auto_installer.build_setup(
+ self._luks_config(), disk="/dev/vda", efi=False, is_mint=False)
+ assert other.passphrase1 != setup.passphrase1
+
+ def test_tpm2_passphrase_source_still_unimplemented(self):
+ config = self._luks_config("passphrase_source: tpm2")
+ with pytest.raises(schema.ConfigError) as excinfo:
+ auto_installer.build_setup(
+ config, disk="/dev/vda", efi=False, is_mint=False)
+ assert "not yet implemented" in str(excinfo.value)
+
+ def test_default_hostname(self):
+ config = schema.parse_config(textwrap.dedent("""\
+ version: 1
+ locale: en_US.UTF-8
+ timezone: America/Toronto
+ users:
+ - name: admin
+ passwd: "$6$rounds=4096$salt$hash"
+ storage:
+ target:
+ match:
+ first-non-removable: true
+ """))
+ setup = auto_installer.build_setup(
+ config, disk="/dev/vda", efi=False, is_mint=False)
+ assert setup.hostname == "mint"
+
+
+class FakeEngine:
+ def __init__(self, fail_in=None):
+ self.fail_in = fail_in
+ self.calls = []
+ self._error_hook = None
+
+ def set_progress_hook(self, hook):
+ pass
+
+ def set_error_hook(self, hook):
+ self._error_hook = hook
+
+ def start_installation(self):
+ self.calls.append("start")
+ if self.fail_in == "start":
+ self._error_hook(message="boom in start")
+
+ def finish_installation(self, before_unmount_hook=None):
+ self.calls.append("finish")
+ if self.fail_in == "finish":
+ self._error_hook(message="boom in finish")
+ return
+ # mirror the real engine: hook runs while the chroot is mounted
+ if before_unmount_hook is not None:
+ self.calls.append("hook")
+ before_unmount_hook()
+
+
+def make_driver(config, fail_in=None):
+ runner = RecordingRunner()
+ engine = FakeEngine(fail_in)
+ driver = auto_installer.HeadlessDriver(
+ config, runner=runner,
+ engine_factory=lambda setup, r: engine,
+ )
+ return driver, runner, engine
+
+
+def run_driver(config, fail_in=None, **setup_kwargs):
+ import installer
+
+ driver, runner, engine = make_driver(config, fail_in)
+ setup = auto_installer.build_setup(
+ config, disk="/dev/vda", efi=False, is_mint=False)
+ rc = driver.run(setup=setup)
+ return rc, runner, engine
+
+
+class TestEnsureDns:
+ def _driver(self):
+ driver, _runner, _engine = make_driver(make_config())
+ return driver
+
+ def test_repairs_placeholder_from_dhcp_lease(self, tmp_path):
+ resolv = tmp_path / "resolv.conf"
+ resolv.write_text("nameserver dhcp\n") # the netboot placeholder
+ (tmp_path / "net-enp0s3.conf").write_text(
+ "DEVICE=enp0s3\nIPV4DNS0=10.0.2.3\nIPV4DNS1=0.0.0.0\n")
+ self._driver()._ensure_dns(str(resolv), str(tmp_path / "net-*.conf"))
+ assert resolv.read_text() == "nameserver 10.0.2.3\n" # 0.0.0.0 dropped
+
+ def test_noop_when_already_valid(self, tmp_path):
+ resolv = tmp_path / "resolv.conf"
+ resolv.write_text("nameserver 192.0.2.1\n")
+ (tmp_path / "net-x.conf").write_text("IPV4DNS0=10.0.2.3\n")
+ self._driver()._ensure_dns(str(resolv), str(tmp_path / "net-*.conf"))
+ assert resolv.read_text() == "nameserver 192.0.2.1\n" # left untouched
+
+ def test_left_alone_when_no_lease_dns(self, tmp_path):
+ resolv = tmp_path / "resolv.conf"
+ resolv.write_text("nameserver dhcp\n")
+ self._driver()._ensure_dns(str(resolv), str(tmp_path / "absent-*.conf"))
+ assert resolv.read_text() == "nameserver dhcp\n" # nothing to repair with
+
+
+class TestApplyNetwork:
+ NET = textwrap.dedent("""\
+ network:
+ version: 2
+ ethernets:
+ eth0:
+ addresses: [192.168.1.10/24, 2001:db8::5/64]
+ gateway4: 192.168.1.1
+ gateway6: 2001:db8::1
+ vlans:
+ vlan100:
+ id: 100
+ link: eth0
+ addresses: [10.100.0.5/24]
+ """)
+
+ def _run_apply(self, config, tmp_path, monkeypatch):
+ import builtins
+ driver, runner, _engine = make_driver(config)
+ target = tmp_path / "target/etc/NetworkManager/system-connections"
+ prefix = "/target/etc/NetworkManager/system-connections"
+ real_open = builtins.open
+ chmods = {}
+
+ def redir(path, *a, **k):
+ if isinstance(path, str) and path.startswith(prefix):
+ local = tmp_path / ("target" + path[len("/target"):])
+ local.parent.mkdir(parents=True, exist_ok=True)
+ return real_open(local, *a, **k)
+ return real_open(path, *a, **k)
+
+ monkeypatch.setattr(builtins, "open", redir)
+ monkeypatch.setattr(auto_installer.os, "chmod",
+ lambda p, m: chmods.__setitem__(os.path.basename(p), m))
+ driver._apply_network()
+ return target, chmods, runner
+
+ def test_writes_keyfiles_0600(self, tmp_path, monkeypatch):
+ config = make_config(extra=self.NET)
+ target, chmods, runner = self._run_apply(config, tmp_path, monkeypatch)
+ names = sorted(p.name for p in target.iterdir())
+ assert names == ["eth0.nmconnection", "vlan100.nmconnection"]
+ # Keyfiles must be 0600 or NetworkManager ignores them.
+ assert chmods == {"eth0.nmconnection": 0o600,
+ "vlan100.nmconnection": 0o600}
+ eth = (target / "eth0.nmconnection").read_text()
+ assert "address1=192.168.1.10/24" in eth
+ assert "address1=2001:db8::5/64" in eth
+ assert any("mkdir -p" in c and "system-connections" in c
+ for c in runner.commands)
+
+ def test_no_network_section_writes_nothing(self, tmp_path, monkeypatch):
+ config = make_config() # no network:
+ target, chmods, _runner = self._run_apply(config, tmp_path, monkeypatch)
+ assert not target.exists()
+ assert chmods == {}
+
+ WIFI = textwrap.dedent("""\
+ network:
+ version: 2
+ wifis:
+ wlan0:
+ dhcp4: true
+ access-points:
+ "HomeNet": {password: "hunter2pass"}
+ """)
+
+ def test_wifi_psk_keyfile_is_0600(self, tmp_path, monkeypatch):
+ # The wifi keyfile holds the PSK in cleartext, so 0600 is a security
+ # requirement, not just an NM nicety.
+ config = make_config(extra=self.WIFI)
+ target, chmods, _runner = self._run_apply(config, tmp_path, monkeypatch)
+ assert chmods == {"wlan0.nmconnection": 0o600}
+ body = (target / "wlan0.nmconnection").read_text()
+ assert "psk=hunter2pass" in body
+
+
+class TestLuksFirstBootRekey:
+ def _setup(self, key="ThrowAwayKey_no_newline_1234567890"):
+ return types.SimpleNamespace(
+ passphrase1=key, luks_rekey_on_first_boot=True)
+
+ def test_noop_when_flag_unset(self, tmp_path):
+ driver, runner, _e = make_driver(make_config())
+ driver._setup_luks_first_boot_rekey(
+ types.SimpleNamespace(luks_rekey_on_first_boot=False),
+ target=str(tmp_path))
+ assert list(tmp_path.iterdir()) == []
+ assert runner.commands == []
+
+ def test_writes_keyfile_crypttab_hook_and_service(self, tmp_path):
+ driver, runner, _e = make_driver(make_config())
+ key = "ThrowAwayKey_no_newline_1234567890"
+ driver._setup_luks_first_boot_rekey(self._setup(key), target=str(tmp_path))
+
+ # 1. Keyfile holds the LUKS key EXACTLY (no trailing newline), 0600.
+ keyfile = tmp_path / "etc/cryptsetup-keys.d/cryptroot.key"
+ assert keyfile.read_bytes() == key.encode() # byte-exact, no newline
+ assert (keyfile.stat().st_mode & 0o777) == 0o600
+ assert (keyfile.parent.stat().st_mode & 0o777) == 0o700
+
+ # 2. initramfs hook copies the keyfile in and locks down the image.
+ hook = (tmp_path / "etc/cryptsetup-initramfs/conf-hook").read_text()
+ assert 'KEYFILE_PATTERN="/etc/cryptsetup-keys.d/*.key"' in hook
+ conf = (tmp_path / "etc/initramfs-tools/initramfs.conf").read_text()
+ assert "UMASK=0077" in conf
+
+ # 3. Rekey oneshot installed (executable) and enabled.
+ script = tmp_path / "usr/local/sbin/li-luks-rekey"
+ assert script.exists() and (script.stat().st_mode & 0o111)
+ body = script.read_text()
+ assert "luksAddKey" in body and "luksRemoveKey" in body
+ assert "systemctl reboot" in body
+ # new key added with no trailing newline (matches the boot prompt)
+ assert "printf '%s' \"$PASS\" | cryptsetup luksAddKey" in body
+ unit = (tmp_path / "etc/systemd/system/li-luks-rekey.service").read_text()
+ assert "ExecStart=/usr/local/sbin/li-luks-rekey" in unit
+ assert any("systemctl enable li-luks-rekey.service" in c
+ for c in runner.commands)
+
+
+class TestHeadlessDriver:
+ def test_happy_path(self, tmp_path, capsys):
+ config = make_config(logging_dest=str(tmp_path / "auto.log"))
+ rc, runner, engine = run_driver(config)
+ assert rc == 0
+ assert engine.calls == ["start", "finish"]
+ out = capsys.readouterr().out
+ assert auto_installer.FINAL_MARKER in out
+
+ def test_engine_error_aborts(self, tmp_path, capsys):
+ config = make_config(logging_dest=str(tmp_path / "auto.log"))
+ rc, runner, engine = run_driver(config, fail_in="start")
+ assert rc == 1
+ assert engine.calls == ["start"] # finish never runs
+ out = capsys.readouterr().out
+ assert auto_installer.FAILURE_MARKER in out
+
+ def test_progress_is_deduplicated(self, tmp_path, capsys):
+ config = make_config(logging_dest=str(tmp_path / "auto.log"))
+ driver, _, _ = make_driver(config)
+ for _ in range(1000):
+ driver.on_progress(7, False, False, "Copying files...")
+ driver.on_progress(8, False, False, "Copying files...")
+ out = capsys.readouterr().out
+ assert out.count("[ 7%] Copying files...") == 1
+ assert out.count("[ 8%] Copying files...") == 1
+
+ def test_log_file_receives_output(self, tmp_path):
+ log = tmp_path / "auto.log"
+ config = make_config(logging_dest=str(log))
+ rc, _, _ = run_driver(config)
+ assert rc == 0
+ assert auto_installer.FINAL_MARKER in log.read_text()
+
+ def test_package_install_runs_in_chroot(self, tmp_path):
+ config = make_config(
+ extra="""\
+ packages: [openssh-server]
+ """,
+ logging_dest=str(tmp_path / "auto.log"),
+ )
+ rc, runner, _ = run_driver(config)
+ assert rc == 0
+ chroots = [c for c in runner.commands if c.startswith("chroot")]
+ assert any("apt-get update" in c for c in chroots)
+ assert any("apt-get install -y openssh-server" in c for c in chroots)
+ # dpkg state repair must run between update and install (the engine's
+ # offline EFI bootloader install leaves unmet dependencies behind)
+ fix = next(i for i, c in enumerate(chroots) if "install -f -y" in c)
+ install = next(i for i, c in enumerate(chroots)
+ if "install -y openssh-server" in c)
+ assert fix < install
+
+ def _driver(self, kernel_yaml, tmp_path):
+ config = make_config(extra=kernel_yaml,
+ logging_dest=str(tmp_path / "auto.log"))
+ driver, runner, _ = make_driver(config)
+ return driver, runner
+
+ def test_cmdline_extra_snippet(self, tmp_path):
+ driver, _ = self._driver(
+ 'kernel:\n cmdline_extra: "mitigations=off ipv6.disable=1"\n',
+ tmp_path)
+ snippet = driver._build_grub_snippet()
+ assert ('GRUB_CMDLINE_LINUX_DEFAULT="${GRUB_CMDLINE_LINUX_DEFAULT} '
+ 'mitigations=off ipv6.disable=1"' in snippet)
+
+ def test_luks_regenerates_initramfs_with_medium_bound(self, tmp_path):
+ config = schema.parse_config(textwrap.dedent(f"""\
+ version: 1
+ locale: en_US.UTF-8
+ timezone: America/Toronto
+ users:
+ - name: admin
+ passwd: "$6$rounds=4096$salt$hash"
+ storage:
+ layout: lvm-on-luks
+ luks:
+ passphrase_source: keyfile
+ keyfile: {tmp_path / "k"}
+ target:
+ match:
+ first-non-removable: true
+ logging:
+ destination: {tmp_path / "auto.log"}
+ """))
+ (tmp_path / "k").write_text("pass\n")
+ driver, runner, engine = make_driver(config)
+ setup = auto_installer.build_setup(
+ config, disk="/dev/vda", efi=False, is_mint=False)
+ rc = driver.run(setup=setup)
+ assert rc == 0
+ joined = " ".join(runner.commands)
+ # resolves the real (un-diverted) update-initramfs and runs it so
+ # the crypttab lands in the initramfs
+ assert "dpkg-divert --truename /usr/sbin/update-initramfs" in joined
+ assert any("update-initramfs -u -k all" in c for c in runner.commands)
+
+ def test_serial_console_snippet(self, tmp_path):
+ driver, _ = self._driver(
+ 'kernel:\n serial_console: "ttyS0,115200"\n', tmp_path)
+ snippet = driver._build_grub_snippet()
+ # strips plymouth grabbers, adds consoles + plymouth.enable=0,
+ # configures GRUB's serial terminal — sourced last so it wins
+ assert "s/ quiet / /g" in snippet and "s/ splash / /g" in snippet
+ assert "console=tty0 console=ttyS0,115200n8 plymouth.enable=0" in snippet
+ assert 'GRUB_TERMINAL="console serial"' in snippet
+ assert 'GRUB_SERIAL_COMMAND="serial --unit=0 --speed=115200"' in snippet
+
+ def test_kernel_config_writes_snippet_and_runs_update_grub(self, tmp_path):
+ target = tmp_path / "target"
+ (target / "etc/default/grub.d").mkdir(parents=True)
+ driver, runner = self._driver(
+ 'kernel:\n serial_console: "ttyS0"\n', tmp_path)
+ import builtins
+ real_open = builtins.open
+
+ def redir(path, *a, **k):
+ if isinstance(path, str) and path.startswith("/target/"):
+ path = str(target / path[len("/target/"):])
+ return real_open(path, *a, **k)
+
+ builtins.open = redir
+ try:
+ config = make_config(
+ extra='kernel:\n serial_console: "ttyS0"\n',
+ logging_dest=str(tmp_path / "auto.log"))
+ d2, runner2, _ = make_driver(config)
+ setup = auto_installer.build_setup(
+ config, disk="/dev/vda", efi=False, is_mint=False)
+ rc = d2.run(setup=setup)
+ finally:
+ builtins.open = real_open
+ assert rc == 0
+ snippet = (target / "etc/default/grub.d/zz-live-installer.cfg").read_text()
+ assert "console=ttyS0" in snippet
+ assert any("update-grub" in c for c in runner2.commands)
+
+ def test_package_failure_policy_abort(self, tmp_path):
+ config = make_config(
+ extra="""\
+ packages: [doesnotexist]
+ """,
+ logging_dest=str(tmp_path / "auto.log"),
+ )
+ driver, runner, engine = make_driver(config)
+ # make apt-get install fail
+ original_chroot = runner.chroot
+
+ def failing_chroot(command, check=False, target="/target"):
+ runner.commands.append(command)
+ return 100 if "apt-get install" in command else 0
+
+ runner.chroot = failing_chroot
+ setup = auto_installer.build_setup(
+ config, disk="/dev/vda", efi=False, is_mint=False)
+ rc = driver.run(setup=setup)
+ assert rc == 1 # default policy is abort
+
+ def test_package_failure_policy_continue(self, tmp_path):
+ config = make_config(
+ extra="""\
+ packages: [doesnotexist]
+ on_failure:
+ package_install_failure: continue
+ network_unavailable: continue
+ """,
+ logging_dest=str(tmp_path / "auto.log"),
+ )
+ driver, runner, engine = make_driver(config)
+
+ def failing_chroot(command, check=False, target="/target"):
+ runner.commands.append(command)
+ return 100 if "apt-get" in command else 0
+
+ runner.chroot = failing_chroot
+ setup = auto_installer.build_setup(
+ config, disk="/dev/vda", efi=False, is_mint=False)
+ rc = driver.run(setup=setup)
+ assert rc == 0 # policy says continue
+
+
+class TestCheckMode:
+ """--check is the ksvalidator analog: validate the schema and stop
+ before any disk resolution, so it runs anywhere (CI, a dev laptop)."""
+
+ def _valid_file(self, tmp_path):
+ f = tmp_path / "answer.yaml"
+ f.write_text(textwrap.dedent("""\
+ version: 1
+ locale: en_US.UTF-8
+ timezone: America/Toronto
+ users:
+ - name: admin
+ passwd: "$6$rounds=4096$salt$hash"
+ storage:
+ target:
+ match:
+ first-non-removable: true
+ """))
+ return f
+
+ def test_valid_file_passes(self, tmp_path, capsys):
+ rc = auto_installer.main(["--check", "--config",
+ str(self._valid_file(tmp_path))])
+ assert rc == 0
+ assert "OK" in capsys.readouterr().out
+
+ def test_invalid_file_fails(self, tmp_path, capsys):
+ f = tmp_path / "bad.yaml"
+ f.write_text("version: 1\nbogus: yes\n")
+ rc = auto_installer.main(["--check", "--config", str(f)])
+ assert rc == 1
+ assert "validation" in capsys.readouterr().out
+
+ def test_check_resolves_no_disk(self, tmp_path, monkeypatch):
+ # --check must never call into disk resolution.
+ def boom(*a, **k):
+ raise AssertionError("disk resolution must not run under --check")
+
+ monkeypatch.setattr(auto_installer.diskmatch, "resolve_disk", boom)
+ rc = auto_installer.main(["--check", "--config",
+ str(self._valid_file(tmp_path))])
+ assert rc == 0
+
+
+class TestListDisks:
+ def test_format_renders_attributes(self):
+ out = auto_installer.format_disk_list([{
+ "path": "/dev/nvme0n1", "model": "Samsung 980 PRO",
+ "size_bytes": 1_000_000_000_000, "removable": False,
+ "by_id": ["nvme-Samsung_SSD_980_PRO_1TB_S5GX"],
+ "by_path": ["pci-0000:01:00.0-nvme-1"],
+ }])
+ assert "/dev/nvme0n1" in out
+ assert "1.0TB" in out
+ assert "nvme-Samsung_SSD_980_PRO_1TB_S5GX" in out
+ assert "pci-0000:01:00.0-nvme-1" in out
+
+ def test_format_handles_no_disks(self):
+ assert "No installable disks" in auto_installer.format_disk_list([])
+
+ def test_cli_needs_no_config(self, monkeypatch, capsys):
+ # --list-disks is a pre-flight helper: it must run with no answer file.
+ monkeypatch.setattr(auto_installer.diskmatch, "describe_disks",
+ lambda **k: [])
+ rc = auto_installer.main(["--list-disks"])
+ assert rc == 0
+ assert "No installable disks" in capsys.readouterr().out
+
+
+class TestAcquireAnswerText:
+ MINIMAL = textwrap.dedent("""\
+ version: 1
+ locale: en_US.UTF-8
+ timezone: America/Toronto
+ users:
+ - name: admin
+ passwd: "$6$rounds=4096$salt$hash"
+ storage:
+ target:
+ match:
+ first-non-removable: true
+ """)
+
+ def test_plain_source_fetched_directly(self, tmp_path):
+ f = tmp_path / "a.yaml"
+ f.write_text(self.MINIMAL)
+ text = auto_installer.acquire_answer_text(str(f), insecure=False)
+ assert "version: 1" in text
+
+ def test_auto_discovers_by_identity(self, monkeypatch):
+ # The by-serial file exists; the by-mac one does not. Discovery must
+ # walk past the miss and land on the serial-keyed file.
+ served = {
+ "https://cfg/by-serial/SN1.yaml": self.MINIMAL,
+ "https://cfg/default.yaml": "version: 1\n", # would be invalid
+ }
+ monkeypatch.setattr(
+ auto_installer.discovery, "read_machine_identity",
+ lambda *a, **k: {"macs": ["aa:bb:cc:dd:ee:ff"],
+ "serial": "SN1", "uuid": None})
+
+ def fake_fetch(src, insecure=False):
+ if src in served:
+ return served[src]
+ raise schema.ConfigError(f"not found: {src}")
+
+ monkeypatch.setattr(auto_installer, "fetch_answer_file", fake_fetch)
+ text = auto_installer.acquire_answer_text("auto:https://cfg/",
+ insecure=False)
+ assert "admin" in text
+
+ def test_auto_miss_raises_configerror(self, monkeypatch):
+ monkeypatch.setattr(
+ auto_installer.discovery, "read_machine_identity",
+ lambda *a, **k: {"macs": [], "serial": None, "uuid": None})
+ monkeypatch.setattr(
+ auto_installer, "fetch_answer_file",
+ lambda s, insecure=False: (_ for _ in ()).throw(
+ schema.ConfigError("nope")))
+ with pytest.raises(schema.ConfigError):
+ auto_installer.acquire_answer_text("auto:https://cfg/",
+ insecure=False)
+
+
+class TestApplyProxy:
+ def test_writes_apt_and_environment(self, tmp_path):
+ config = make_config(extra="proxy: http://proxy.corp:3128\n")
+ driver, _runner, _e = make_driver(config)
+ driver._apply_proxy(target=str(tmp_path))
+ apt = (tmp_path / "etc/apt/apt.conf.d/00proxy").read_text()
+ assert 'Acquire::http::Proxy "http://proxy.corp:3128";' in apt
+ assert 'Acquire::https::Proxy "http://proxy.corp:3128";' in apt
+ env = (tmp_path / "etc/environment").read_text()
+ assert "http_proxy=http://proxy.corp:3128" in env
+ assert "HTTPS_PROXY=http://proxy.corp:3128" in env
+
+ def test_noop_without_proxy(self, tmp_path):
+ driver, _runner, _e = make_driver(make_config())
+ driver._apply_proxy(target=str(tmp_path))
+ assert list(tmp_path.iterdir()) == []
+
+
+class TestApplyDrivers:
+ def test_noop_when_not_requested(self):
+ driver, runner, _e = make_driver(make_config())
+ driver._apply_drivers()
+ assert runner.commands == []
+
+ def test_installs_when_available(self):
+ config = make_config(extra="drivers: {install: true}\n")
+ driver, runner, _e = make_driver(config)
+ driver._apply_drivers()
+ joined = "\n".join(runner.commands)
+ assert "command -v ubuntu-drivers" in joined
+ assert "ubuntu-drivers install" in joined
+
+ def test_skips_when_unavailable(self):
+ # ubuntu-drivers absent (e.g. LMDE): the `command -v` probe fails, so
+ # the install is skipped rather than erroring.
+ class Runner(RecordingRunner):
+ def chroot(self, command, check=False, target="/target"):
+ self.commands.append(command)
+ return 1 if "command -v ubuntu-drivers" in command else 0
+ config = make_config(extra="drivers: {install: true}\n")
+ driver, _r, engine = make_driver(config)
+ driver.runner = Runner()
+ driver._apply_drivers()
+ joined = "\n".join(driver.runner.commands)
+ assert "command -v ubuntu-drivers" in joined
+ assert "ubuntu-drivers install" not in joined
+
+
+class TestBuildSetupRaid:
+ RAID = textwrap.dedent("""\
+ storage:
+ layout: custom
+ disks:
+ - {match: {by-id: "diskA*"}}
+ - {match: {by-id: "diskB*"}}
+ partitions:
+ - {size: rest, raid: md0}
+ raid:
+ - {name: md0, level: 1, mount: /, filesystem: ext4}
+ """)
+
+ def _config(self):
+ # make_config sets storage.target; build a fresh config with disks
+ base = textwrap.dedent("""\
+ version: 1
+ locale: en_US.UTF-8
+ timezone: America/Toronto
+ users:
+ - {name: a, passwd: "$6$rounds=4096$s$h"}
+ """)
+ return schema.parse_config(base + self.RAID)
+
+ def test_multidisk_and_raid_passthrough(self):
+ setup = auto_installer.build_setup(
+ self._config(), disks=["/dev/vda", "/dev/vdb"], efi=False,
+ is_mint=False)
+ assert setup.disks == ["/dev/vda", "/dev/vdb"]
+ assert setup.disk == "/dev/vda"
+ assert setup.custom_raid[0]["name"] == "md0"
+ assert setup.custom_raid[0]["level"] == 1
+ assert setup.custom_partitions[0]["raid"] == "md0"
+
+
+class TestEarlyCommands:
+ def test_noop_when_empty(self):
+ driver, runner, _e = make_driver(make_config())
+ driver._run_early_commands()
+ assert runner.commands == []
+
+ def test_runs_in_live_env_not_chroot(self):
+ config = make_config(extra="early_commands:\n - mdadm --stop --scan\n")
+ driver, runner, _e = make_driver(config)
+ driver._run_early_commands()
+ # run in the live session: a plain run(), NOT a chroot wrapper
+ assert runner.commands == ["mdadm --stop --scan"]
+ assert not any("chroot" in c for c in runner.commands)
+
+ def test_on_media_script_run_with_sh(self, monkeypatch):
+ config = make_config(
+ extra="early_commands:\n - /cdrom/prep.sh --wipe\n")
+ driver, runner, _e = make_driver(config)
+ monkeypatch.setattr(auto_installer.os.path, "isfile",
+ lambda p: p == "/cdrom/prep.sh")
+ driver._run_early_commands()
+ assert runner.commands == ["sh /cdrom/prep.sh --wipe"]
+
+ def test_failure_applies_policy(self):
+ config = make_config(extra=textwrap.dedent("""\
+ early_commands:
+ - /bin/false
+ on_failure:
+ early_command_failure: abort
+ """))
+
+ class FailRunner(RecordingRunner):
+ def run(self, command, check=False, secrets=(), stdin=None):
+ self.commands.append(command)
+ return 1
+ driver, _r, _e = make_driver(config)
+ driver.runner = FailRunner()
+ with pytest.raises(auto_installer.InstallationFailed):
+ driver._run_early_commands()
+
+
+class TestApplyFlatpak:
+ FLATPAK = textwrap.dedent("""\
+ flatpak:
+ remotes:
+ - {name: flathub, url: "https://flathub.org/repo/flathub.flatpakrepo"}
+ install:
+ - org.gnome.Calculator
+ - com.github.tchx84.Flatseal
+ """)
+
+ def test_noop_when_absent(self):
+ driver, runner, _e = make_driver(make_config())
+ driver._apply_flatpak()
+ assert runner.commands == []
+
+ def test_installs_flatpak_remotes_and_apps_in_chroot(self):
+ driver, runner, _e = make_driver(make_config(extra=self.FLATPAK))
+ driver._apply_flatpak()
+ joined = "\n".join(runner.commands)
+ # all in the chroot (flatpak install runs as root, --noninteractive)
+ assert "apt-get install -y flatpak" in joined
+ assert ("flatpak remote-add --if-not-exists flathub "
+ "https://flathub.org/repo/flathub.flatpakrepo" in joined)
+ assert ("flatpak install --system -y --noninteractive "
+ "org.gnome.Calculator" in joined)
+ assert ("flatpak install --system -y --noninteractive "
+ "com.github.tchx84.Flatseal" in joined)
+ assert all("chroot" in c for c in runner.commands)
+
+ def test_remotes_only_no_install(self):
+ config = make_config(extra=textwrap.dedent("""\
+ flatpak:
+ remotes:
+ - {name: flathub, url: "https://flathub.org/repo/flathub.flatpakrepo"}
+ """))
+ driver, runner, _e = make_driver(config)
+ driver._apply_flatpak()
+ joined = "\n".join(runner.commands)
+ assert "remote-add" in joined
+ assert "flatpak install" not in joined
diff --git a/tests/unit/test_catrust.py b/tests/unit/test_catrust.py
new file mode 100644
index 00000000..9d64268b
--- /dev/null
+++ b/tests/unit/test_catrust.py
@@ -0,0 +1,97 @@
+"""Unit tests for the CA-trust backend (Debian/update-ca-certificates)."""
+
+import pytest
+
+import catrust
+import schema
+from commandrunner import CommandRunner
+
+CERT = ("-----BEGIN CERTIFICATE-----\n"
+ "MIIDcorporaterootca\n"
+ "-----END CERTIFICATE-----")
+
+
+class FakeRunner(CommandRunner):
+ def __init__(self, rc=0):
+ super().__init__(log=lambda *a: None)
+ self.commands = []
+ self.rc = rc
+
+ def run(self, command, check=False, secrets=(), stdin=None):
+ self.commands.append(command)
+ return self.rc
+
+
+def _ca_config(yaml_block):
+ base = (
+ "version: 1\n"
+ "locale: en_US.UTF-8\n"
+ "timezone: America/Toronto\n"
+ 'users: [{name: a, passwd: "$6$rounds=4096$s$h"}]\n'
+ "storage: {target: {match: {first-non-removable: true}}, layout: simple}\n"
+ )
+ return schema.parse_config(base + yaml_block).ca_certs
+
+
+def _backend(runner, target, policy=None):
+ calls = []
+ policy = policy or (lambda m, msg: calls.append((m, msg)))
+ b = catrust.DebianCaTrustBackend(runner, policy, lambda *a: None, target=target)
+ b._policy_calls = calls
+ return b
+
+
+class TestGetBackend:
+ def test_debian(self):
+ b = catrust.get_ca_trust_backend(FakeRunner(), lambda *a: None,
+ lambda *a: None)
+ assert isinstance(b, catrust.DebianCaTrustBackend)
+
+ def test_unknown_family_raises(self):
+ with pytest.raises(ValueError):
+ catrust.get_ca_trust_backend(FakeRunner(), lambda *a: None,
+ lambda *a: None, family="redhat")
+
+
+class TestDebianCaTrust:
+ def test_writes_cert_and_updates(self, tmp_path):
+ cfg = _ca_config('ca_certs:\n trusted:\n - "%s"\n'
+ % CERT.replace("\n", "\\n"))
+ runner = FakeRunner()
+ _backend(runner, str(tmp_path)).apply(cfg)
+ crt = tmp_path / "usr/local/share/ca-certificates/li-ca-1.crt"
+ assert crt.exists()
+ body = crt.read_text()
+ assert "BEGIN CERTIFICATE" in body and body.endswith("\n")
+ assert (crt.stat().st_mode & 0o777) == 0o644
+ # plain update (defaults kept)
+ joined = "\n".join(runner.commands)
+ assert "update-ca-certificates" in joined
+ assert "--fresh" not in joined
+ assert "ca-certificates.conf" not in joined
+
+ def test_multiple_certs_indexed(self, tmp_path):
+ cfg = _ca_config(
+ "ca_certs:\n trusted:\n - \"%s\"\n - \"%s\"\n"
+ % (CERT.replace("\n", "\\n"), CERT.replace("\n", "\\n")))
+ _backend(FakeRunner(), str(tmp_path)).apply(cfg)
+ d = tmp_path / "usr/local/share/ca-certificates"
+ assert sorted(p.name for p in d.iterdir()) == ["li-ca-1.crt", "li-ca-2.crt"]
+
+ def test_remove_defaults_disables_and_refreshes(self, tmp_path):
+ cfg = _ca_config(
+ "ca_certs:\n remove_defaults: true\n trusted:\n - \"%s\"\n"
+ % CERT.replace("\n", "\\n"))
+ runner = FakeRunner()
+ _backend(runner, str(tmp_path)).apply(cfg)
+ joined = "\n".join(runner.commands)
+ assert "ca-certificates.conf" in joined # defaults disabled
+ assert "update-ca-certificates --fresh" in joined
+
+ def test_update_failure_invokes_policy(self, tmp_path):
+ cfg = _ca_config('ca_certs:\n trusted:\n - "%s"\n'
+ % CERT.replace("\n", "\\n"))
+ runner = FakeRunner(rc=1)
+ b = _backend(runner, str(tmp_path))
+ b.apply(cfg)
+ assert any(m == "post_install_script_failure" for m, _ in b._policy_calls)
diff --git a/tests/unit/test_discovery.py b/tests/unit/test_discovery.py
new file mode 100644
index 00000000..1875c9fe
--- /dev/null
+++ b/tests/unit/test_discovery.py
@@ -0,0 +1,194 @@
+"""Unit tests for netboot answer-file auto-discovery."""
+
+import os
+
+import pytest
+
+import discovery
+from discovery import DiscoveryError
+
+
+class TestParseAutoTrigger:
+ def test_plain_auto(self):
+ assert discovery.parse_auto_trigger("auto") == (True, None)
+
+ def test_auto_with_base(self):
+ assert discovery.parse_auto_trigger(
+ "auto:https://cfg.example.com/x/") == (
+ True, "https://cfg.example.com/x/")
+
+ def test_auto_prefix_empty_base(self):
+ assert discovery.parse_auto_trigger("auto:") == (True, None)
+
+ def test_not_discovery(self):
+ assert discovery.parse_auto_trigger(
+ "https://h/file.yaml") == (False, None)
+ assert discovery.parse_auto_trigger("/cdrom/x.yaml") == (False, None)
+
+
+class TestCandidateSources:
+ def test_order_most_specific_first(self):
+ got = discovery.candidate_sources(
+ "https://cfg/base/",
+ macs=["aa:bb:cc:00:11:22", "aa:bb:cc:00:11:33"],
+ serial="SN123", uuid="UUID-9",
+ media_dirs=["/cdrom"])
+ assert got == [
+ "https://cfg/base/by-mac/aa:bb:cc:00:11:22.yaml",
+ "https://cfg/base/by-mac/aa:bb:cc:00:11:33.yaml",
+ "https://cfg/base/by-serial/SN123.yaml",
+ "https://cfg/base/by-uuid/UUID-9.yaml",
+ "https://cfg/base/default.yaml",
+ "/cdrom/auto-install.yaml",
+ ]
+
+ def test_no_base_is_local_media_only(self):
+ got = discovery.candidate_sources(
+ None, macs=["aa:bb:cc:00:11:22"],
+ media_dirs=["/cdrom", "/run/live/medium"])
+ assert got == [
+ "/cdrom/auto-install.yaml",
+ "/run/live/medium/auto-install.yaml",
+ ]
+
+ def test_missing_serial_uuid_skipped(self):
+ got = discovery.candidate_sources(
+ "https://cfg", macs=[], serial=None, uuid=None, media_dirs=[])
+ assert got == ["https://cfg/default.yaml"]
+
+ def test_ipv6_base_url_preserved(self):
+ # A bracketed IPv6 base must survive into the candidate URLs intact.
+ got = discovery.candidate_sources(
+ "https://[2001:db8::1]:8443/cfg/",
+ macs=["aa:bb:cc:00:11:22"], serial="SN1", media_dirs=[])
+ assert got[0] == (
+ "https://[2001:db8::1]:8443/cfg/by-mac/aa:bb:cc:00:11:22.yaml")
+ assert got[-1] == "https://[2001:db8::1]:8443/cfg/default.yaml"
+
+
+class TestReadMachineIdentity:
+ @pytest.fixture
+ def fake_sys(self, tmp_path):
+ net = tmp_path / "net"
+ dmi = tmp_path / "dmi"
+ # eth0: real ethernet; wlan0: wifi (type 803, excluded);
+ # lo: loopback (excluded); bond0: shares eth0's MAC (deduped)
+ for name, typ, addr in [
+ ("eth0", "1", "AA:BB:CC:00:11:22"),
+ ("wlan0", "803", "dd:ee:ff:00:11:22"),
+ ("lo", "772", "00:00:00:00:00:00"),
+ ("bond0", "1", "AA:BB:CC:00:11:22"),
+ ]:
+ d = net / name
+ d.mkdir(parents=True)
+ (d / "type").write_text(typ + "\n")
+ (d / "address").write_text(addr + "\n")
+ dmi.mkdir()
+ (dmi / "product_serial").write_text("SN-12345\n")
+ (dmi / "product_uuid").write_text("4C4C-ABCD\n")
+ return dict(net_dir=str(net), dmi_dir=str(dmi))
+
+ def test_collects_and_normalizes(self, fake_sys):
+ ident = discovery.read_machine_identity(**fake_sys)
+ # only ethernet, lowercased, deduped (bond0 shares eth0's MAC)
+ assert ident["macs"] == ["aa:bb:cc:00:11:22"]
+ assert ident["serial"] == "SN-12345"
+ assert ident["uuid"] == "4C4C-ABCD"
+
+ def test_missing_dmi_is_none(self, tmp_path):
+ ident = discovery.read_machine_identity(
+ net_dir=str(tmp_path / "none"), dmi_dir=str(tmp_path / "none2"))
+ assert ident == {"macs": [], "serial": None, "uuid": None}
+
+ def test_wifi_type1_with_wireless_dir_excluded(self, tmp_path):
+ # Real wifi reports ARPHRD_ETHER (type 1) just like wired, so the type
+ # check alone can't exclude it (review #4); the wireless/ subdir does.
+ net = tmp_path / "net"
+ for name, addr, wireless in [
+ ("eth0", "AA:BB:CC:00:11:22", False),
+ ("wlp2s0", "DD:EE:FF:00:11:22", True),
+ ]:
+ d = net / name
+ d.mkdir(parents=True)
+ (d / "type").write_text("1\n")
+ (d / "address").write_text(addr + "\n")
+ if wireless:
+ (d / "wireless").mkdir()
+ ident = discovery.read_machine_identity(
+ net_dir=str(net), dmi_dir=str(tmp_path / "nodmi"))
+ assert ident["macs"] == ["aa:bb:cc:00:11:22"] # wifi excluded
+
+ def _dmi(self, tmp_path, serial, uuid):
+ dmi = tmp_path / "dmi"
+ dmi.mkdir()
+ (dmi / "product_serial").write_text(serial + "\n")
+ (dmi / "product_uuid").write_text(uuid + "\n")
+ return dict(net_dir=str(tmp_path / "nonet"), dmi_dir=str(dmi))
+
+ def test_sentinel_uuid_dropped_and_logged(self, tmp_path):
+ logs = []
+ ident = discovery.read_machine_identity(
+ log=logs.append,
+ **self._dmi(tmp_path, "SN-9", "00000000-0000-0000-0000-000000000000"))
+ assert ident["uuid"] is None
+ assert ident["serial"] == "SN-9"
+ assert any("product_uuid" in m for m in logs)
+
+ def test_sentinel_uuid_case_insensitive(self, tmp_path):
+ ident = discovery.read_machine_identity(
+ **self._dmi(tmp_path, "SN-9", "FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF"))
+ assert ident["uuid"] is None
+
+ def test_sentinel_serial_dropped(self, tmp_path):
+ ident = discovery.read_machine_identity(
+ **self._dmi(tmp_path, "To Be Filled By O.E.M.",
+ "4c4c4544-1234-5678-9abc-def012345678"))
+ assert ident["serial"] is None
+ assert ident["uuid"] == "4c4c4544-1234-5678-9abc-def012345678" # real, kept
+
+
+class TestDiscover:
+ def test_first_valid_wins(self):
+ store = {"b/default.yaml": "VALID"}
+
+ def fetch(src):
+ name = src
+ if name in store:
+ return store[name]
+ raise KeyError("not found")
+
+ used = []
+ src, text = discovery.discover(
+ ["b/by-mac/x.yaml", "b/default.yaml", "b/other.yaml"],
+ fetch=fetch,
+ validate=lambda t: used.append(t), # validates anything
+ log=lambda _m: None)
+ assert src == "b/default.yaml"
+ assert text == "VALID"
+
+ def test_skips_present_but_invalid(self):
+ store = {"a.yaml": "BAD", "b.yaml": "GOOD"}
+
+ def validate(text):
+ if text == "BAD":
+ raise ValueError("schema error")
+
+ src, text = discovery.discover(
+ ["a.yaml", "b.yaml"],
+ fetch=lambda s: store[s],
+ validate=validate)
+ assert (src, text) == ("b.yaml", "GOOD")
+
+ def test_none_found_reports_attempts(self):
+ with pytest.raises(DiscoveryError) as excinfo:
+ discovery.discover(
+ ["a.yaml", "b.yaml"],
+ fetch=lambda s: (_ for _ in ()).throw(OSError("404")),
+ validate=lambda t: None)
+ msg = str(excinfo.value)
+ assert "a.yaml" in msg and "b.yaml" in msg
+
+ def test_nothing_to_try(self):
+ with pytest.raises(DiscoveryError) as excinfo:
+ discovery.discover([], fetch=lambda s: s, validate=lambda t: None)
+ assert "nothing to try" in str(excinfo.value)
diff --git a/tests/unit/test_diskmatch.py b/tests/unit/test_diskmatch.py
new file mode 100644
index 00000000..32b62d22
--- /dev/null
+++ b/tests/unit/test_diskmatch.py
@@ -0,0 +1,174 @@
+"""Unit tests for stable-attribute disk resolution."""
+
+import os
+from types import SimpleNamespace
+
+import pytest
+
+import diskmatch
+from diskmatch import DiskMatchError
+
+
+def match(**kwargs):
+ defaults = dict(by_id=None, by_path=None, model=None, size_min=None,
+ first_non_removable=False)
+ defaults.update(kwargs)
+ return SimpleNamespace(**defaults)
+
+
+@pytest.fixture
+def fake_tree(tmp_path):
+ """A fake /sys/block + /dev/disk/by-id with three disks and a loop dev.
+
+ nvme0n1: 1TB Samsung 980 PRO (fixed)
+ sda: 500GB Samsung 860 EVO (fixed)
+ sdb: 32GB Kingston USB stick (removable)
+ loop0: excluded device type
+ """
+ sys_block = tmp_path / "sys_block"
+ by_id = tmp_path / "by-id"
+ by_id.mkdir()
+
+ disks = {
+ "nvme0n1": (1_000_000_000_000, "0", "Samsung SSD 980 PRO 1TB"),
+ "sda": (500_000_000_000, "0", "Samsung SSD 860 EVO 500GB"),
+ "sdb": (32_000_000_000, "1", "Kingston DataTraveler 3.0"),
+ "loop0": (10_000_000_000, "0", ""),
+ }
+ for name, (size_bytes, removable, model) in disks.items():
+ d = sys_block / name
+ (d / "device").mkdir(parents=True)
+ (d / "size").write_text(str(size_bytes // 512))
+ (d / "removable").write_text(removable)
+ if model:
+ (d / "device" / "model").write_text(model + "\n")
+
+ links = {
+ "nvme-Samsung_SSD_980_PRO_1TB_S5GXNX0T123456": "nvme0n1",
+ "nvme-eui.0025385b21404566": "nvme0n1", # alias for the same disk
+ "nvme-Samsung_SSD_980_PRO_1TB_S5GXNX0T123456-part1": "nvme0n1p1",
+ "ata-Samsung_SSD_860_EVO_500GB_S3Z8NB0K": "sda",
+ "ata-Samsung_SSD_860_EVO_500GB_S3Z8NB0K-part1": "sda1",
+ "usb-Kingston_DataTraveler_3.0": "sdb",
+ }
+ for link, target in links.items():
+ os.symlink("../../" + target, by_id / link)
+
+ return dict(sys_block=str(sys_block), by_id_dir=str(by_id),
+ by_path_dir=str(tmp_path / "by-path-missing"))
+
+
+class TestParseSize:
+ @pytest.mark.parametrize("text,expected", [
+ ("500GB", 500_000_000_000),
+ ("1TB", 1_000_000_000_000),
+ ("1.5TB", 1_500_000_000_000),
+ ("250 GB", 250_000_000_000),
+ ])
+ def test_valid(self, text, expected):
+ assert diskmatch.parse_size(text) == expected
+
+ def test_invalid(self):
+ with pytest.raises(DiskMatchError):
+ diskmatch.parse_size("lots")
+
+
+class TestListDisks:
+ def test_excludes_virtual_devices(self, fake_tree):
+ names = [d.name for d in diskmatch.list_disks(fake_tree["sys_block"])]
+ assert names == ["nvme0n1", "sda", "sdb"] # no loop0, sorted
+
+ def test_reads_attributes(self, fake_tree):
+ disks = {d.name: d for d in diskmatch.list_disks(fake_tree["sys_block"])}
+ assert disks["sda"].size_bytes == 500_000_000_000
+ assert disks["sda"].removable is False
+ assert disks["sdb"].removable is True
+ assert disks["nvme0n1"].model == "Samsung SSD 980 PRO 1TB"
+
+
+class TestDescribeDisks:
+ def test_collects_links_per_disk(self, fake_tree):
+ described = {d["path"]: d for d in diskmatch.describe_disks(**fake_tree)}
+ nvme = described["/dev/nvme0n1"]
+ # both whole-disk aliases, never the -part1 symlink
+ assert sorted(nvme["by_id"]) == [
+ "nvme-Samsung_SSD_980_PRO_1TB_S5GXNX0T123456",
+ "nvme-eui.0025385b21404566",
+ ]
+ assert described["/dev/sda"]["by_id"] == [
+ "ata-Samsung_SSD_860_EVO_500GB_S3Z8NB0K"
+ ]
+ assert nvme["size_bytes"] == 1_000_000_000_000
+ assert nvme["removable"] is False
+
+ def test_missing_by_path_dir_is_empty_not_error(self, fake_tree):
+ # fake_tree points by_path at a nonexistent dir
+ for d in diskmatch.describe_disks(**fake_tree):
+ assert d["by_path"] == []
+
+
+class TestResolveDisk:
+ def test_by_id_glob(self, fake_tree):
+ result = diskmatch.resolve_disk(
+ match(by_id="nvme-Samsung_SSD_980_PRO_*"), **fake_tree)
+ assert result == "/dev/nvme0n1"
+
+ def test_by_id_aliases_same_disk_not_ambiguous(self, fake_tree):
+ # 'nvme-*' matches two symlinks, but they point at the same disk
+ result = diskmatch.resolve_disk(match(by_id="nvme-*"), **fake_tree)
+ assert result == "/dev/nvme0n1"
+
+ def test_partition_symlinks_ignored(self, fake_tree):
+ result = diskmatch.resolve_disk(
+ match(by_id="ata-Samsung_SSD_860_EVO_500GB_*"), **fake_tree)
+ assert result == "/dev/sda"
+
+ def test_ambiguous_match_aborts(self, fake_tree):
+ with pytest.raises(DiskMatchError) as excinfo:
+ diskmatch.resolve_disk(match(model="Samsung*"), **fake_tree)
+ assert "ambiguous" in str(excinfo.value)
+ assert "Refusing to guess" in str(excinfo.value)
+
+ def test_model_and_size_compose(self, fake_tree):
+ result = diskmatch.resolve_disk(
+ match(model="Samsung*", size_min="1TB"), **fake_tree)
+ assert result == "/dev/nvme0n1"
+
+ def test_size_min_excludes_small_disks(self, fake_tree):
+ with pytest.raises(DiskMatchError) as excinfo:
+ diskmatch.resolve_disk(
+ match(model="Kingston*", size_min="100GB"), **fake_tree)
+ assert "no disk matches" in str(excinfo.value)
+
+ def test_non_removable_excludes_usb_but_refuses_to_guess(self, fake_tree):
+ # The USB stick is never a candidate, but with two internal disks
+ # left it must not pick one — that would be the enumeration-order
+ # guess the module forbids. It aborts as ambiguous, like any other.
+ with pytest.raises(DiskMatchError) as excinfo:
+ diskmatch.resolve_disk(match(first_non_removable=True), **fake_tree)
+ message = str(excinfo.value)
+ assert "2 disks qualify" in message
+ assert "/dev/nvme0n1" in message and "/dev/sda" in message
+ assert "/dev/sdb" not in message # the removable USB was excluded
+
+ def test_non_removable_plus_discriminator_resolves(self, fake_tree):
+ # Adding a matcher that narrows to one internal disk makes it unique.
+ result = diskmatch.resolve_disk(
+ match(first_non_removable=True, size_min="800GB"), **fake_tree)
+ assert result == "/dev/nvme0n1"
+
+ def test_no_match_lists_available_disks(self, fake_tree):
+ with pytest.raises(DiskMatchError) as excinfo:
+ diskmatch.resolve_disk(match(model="WDC*"), **fake_tree)
+ message = str(excinfo.value)
+ assert "available disks" in message
+ assert "/dev/sda" in message
+
+ def test_empty_system(self, tmp_path):
+ with pytest.raises(DiskMatchError):
+ diskmatch.resolve_disk(
+ match(first_non_removable=True),
+ sys_block=str(tmp_path / "nope"),
+ by_id_dir=str(tmp_path / "nope2"),
+ by_path_dir=str(tmp_path / "nope3"),
+ )
diff --git a/tests/unit/test_engine_commands.py b/tests/unit/test_engine_commands.py
new file mode 100644
index 00000000..fe84b2c1
--- /dev/null
+++ b/tests/unit/test_engine_commands.py
@@ -0,0 +1,421 @@
+"""Component tests for InstallerEngine via an injected CommandRunner.
+
+These exercise the engine's command construction without touching the
+system: the recording runner captures every command instead of running
+it, and returns canned output where the engine consumes it.
+"""
+
+import pytest
+
+import installer
+from commandrunner import CommandError, CommandRunner
+
+
+class RecordingRunner(CommandRunner):
+ """Captures commands; runs nothing. Canned output by substring match."""
+
+ def __init__(self, outputs=None):
+ super().__init__(log=lambda *a: None)
+ self.commands = []
+ self.outputs = outputs or {}
+
+ def run(self, command, check=False):
+ self.commands.append(command)
+ return 0
+
+ def output(self, command, check=False):
+ self.commands.append(command)
+ for needle, canned in self.outputs.items():
+ if needle in command:
+ return canned
+ return ""
+
+ def popen(self, command): # pragma: no cover - not used in these tests
+ raise AssertionError(f"unexpected popen: {command}")
+
+
+def make_engine(outputs=None, **setup_attrs):
+ setup = installer.Setup()
+ for key, value in setup_attrs.items():
+ setattr(setup, key, value)
+ runner = RecordingRunner(outputs)
+ return installer.InstallerEngine(setup, runner=runner), runner
+
+
+class TestEngineWiring:
+ def test_default_runner_is_real(self):
+ engine = installer.InstallerEngine(installer.Setup())
+ assert isinstance(engine.runner, CommandRunner)
+
+ def test_setup_timezone_commands(self):
+ engine, runner = make_engine(timezone="America/Toronto")
+ engine.setup_timezone()
+ assert 'echo "America/Toronto" > /target/etc/timezone' in runner.commands
+ assert "rm -f /target/etc/localtime" in runner.commands
+ assert (
+ "ln -s /usr/share/zoneinfo/America/Toronto /target/etc/localtime"
+ in runner.commands
+ )
+
+ def test_do_run_in_chroot_quoting(self):
+ engine, runner = make_engine()
+ engine.do_run_in_chroot('echo "hello world"')
+ assert runner.commands == [
+ "chroot /target/ /bin/sh -c \"echo 'hello world'\""
+ ]
+
+ def test_get_blkid_finds_uuid(self):
+ blkid_output = (
+ '/dev/sda1: UUID="1111-2222" TYPE="vfat" PARTUUID="aa"\n'
+ '/dev/mapper/lvmmint-root: UUID="abcd-ef01" TYPE="ext4"'
+ )
+ engine, runner = make_engine(outputs={"blkid": blkid_output})
+ assert engine.get_blkid("/dev/mapper/lvmmint-root") == "UUID=abcd-ef01"
+
+ def test_get_blkid_falls_back_to_path(self):
+ engine, runner = make_engine(outputs={"blkid": ""})
+ assert engine.get_blkid("/dev/sda9") == "/dev/sda9"
+
+
+class TestEditionPaths:
+ """The engine reads the live filesystem and grub-title script from
+ different locations on Mint (Ubuntu/casper) vs LMDE (Debian/live-boot).
+ Integration tests only ever exercise the LMDE branch, so pin both here
+ — a wrong path on Mint 23 would otherwise go unnoticed until release."""
+
+ def test_lmde_paths(self):
+ setup = installer.Setup()
+ setup.is_mint = False
+ engine = installer.InstallerEngine(setup, runner=RecordingRunner())
+ assert engine.casper == "/run/live/medium/live"
+ assert engine.pool == "/run/live/medium/pool"
+ assert engine.manifest == "/run/live/medium/live/filesystem.packages"
+ assert "debian-system-adjustments" in engine.grub_adjustment_script
+
+ def test_mint_paths(self):
+ setup = installer.Setup()
+ setup.is_mint = True
+ engine = installer.InstallerEngine(setup, runner=RecordingRunner())
+ assert engine.casper == "/cdrom/casper"
+ assert engine.pool == "/cdrom/pool"
+ assert engine.manifest == "/cdrom/casper/filesystem.manifest"
+ assert "ubuntu-system-adjustments" in engine.grub_adjustment_script
+
+ def test_squashfs_path_follows_edition(self):
+ # the media mounted in start_installation is /filesystem.squashfs
+ for is_mint, expected in [
+ (False, "/run/live/medium/live/filesystem.squashfs"),
+ (True, "/cdrom/casper/filesystem.squashfs"),
+ ]:
+ setup = installer.Setup()
+ setup.is_mint = is_mint
+ engine = installer.InstallerEngine(setup, runner=RecordingRunner())
+ assert f"{engine.casper}/filesystem.squashfs" == expected
+
+
+class TestCommandRunner:
+ def test_run_returns_exit_code(self):
+ runner = CommandRunner(log=lambda *a: None)
+ assert runner.run("true") == 0
+ assert runner.run("false") == 1
+
+ def test_run_check_raises(self):
+ runner = CommandRunner(log=lambda *a: None)
+ with pytest.raises(CommandError) as excinfo:
+ runner.run("false", check=True)
+ assert excinfo.value.returncode == 1
+
+ def test_output_strips_single_trailing_newline(self):
+ runner = CommandRunner(log=lambda *a: None)
+ assert runner.output("echo hello") == "hello"
+ assert runner.output("printf 'a\\nb\\n'") == "a\nb"
+
+ def test_output_merges_stderr(self):
+ runner = CommandRunner(log=lambda *a: None)
+ assert runner.output("echo oops >&2") == "oops"
+
+ def test_secrets_are_redacted_from_logs_but_executed(self, tmp_path):
+ logged = []
+ runner = CommandRunner(log=logged.append)
+ out = tmp_path / "out"
+ rc = runner.run(
+ f"echo -n 's3cret pass' > {out}", secrets=["s3cret pass"]
+ )
+ assert rc == 0
+ assert out.read_text() == "s3cret pass" # command ran unredacted
+ assert all("s3cret" not in line for line in logged)
+ assert any("[REDACTED]" in line for line in logged)
+
+ def test_stdin_feeds_command_without_logging_it(self, tmp_path):
+ logged = []
+ runner = CommandRunner(log=logged.append)
+ out = tmp_path / "out"
+ # cat reads the secret from stdin; the command line never carries it
+ rc = runner.run(f"cat > {out}", stdin="luks-passphrase-xyz")
+ assert rc == 0
+ assert out.read_text() == "luks-passphrase-xyz" # reached the command
+ assert all("luks-passphrase-xyz" not in line for line in logged)
+
+ def test_chroot_command_shape(self):
+ captured = []
+ runner = CommandRunner(log=lambda *a: None)
+ runner.run = lambda cmd, check=False: captured.append(cmd) or 0
+ runner.chroot('apt install "thing"')
+ assert captured == ["chroot /target/ /bin/sh -c \"apt install 'thing'\""]
+
+
+class TestLayoutFirmwareCheck:
+ # Review finding #2: esp/bios_grub flags must match the runtime firmware
+ # mode, which the schema can't know. Enforced engine-side before
+ # partitioning.
+ def test_bios_grub_on_uefi_rejected(self):
+ engine, _ = make_engine(gptonefi=True)
+ with pytest.raises(Exception, match="UEFI"):
+ engine._check_layout_matches_firmware([{"flags": ["bios_grub"]}])
+
+ def test_esp_on_bios_rejected(self):
+ engine, _ = make_engine(gptonefi=False)
+ with pytest.raises(Exception, match="BIOS"):
+ engine._check_layout_matches_firmware([{"flags": ["esp"]}])
+
+ def test_esp_on_uefi_ok(self):
+ engine, _ = make_engine(gptonefi=True)
+ engine._check_layout_matches_firmware(
+ [{"flags": ["esp"]}, {"flags": []}])
+
+ def test_bios_grub_on_bios_ok(self):
+ engine, _ = make_engine(gptonefi=False)
+ engine._check_layout_matches_firmware([{"flags": ["bios_grub"]}])
+
+
+class TestCustomPartitions:
+ def test_size_to_mb(self):
+ f = installer.InstallerEngine._size_to_mb
+ assert f("512MB") == 512
+ assert f("40GB") == 40000
+ assert f("1.5TB") == 1500000
+
+ def test_custom_fstab_from_auto_mounts(self, tmp_path):
+ engine, runner = make_engine(automated=True)
+ runner.outputs = {"blkid": (
+ '/dev/vda1: UUID="EFI" TYPE="vfat"\n'
+ '/dev/vg0/root: UUID="ROOT" TYPE="ext4"\n'
+ '/dev/vda2: UUID="SWAP" TYPE="swap"')}
+ engine.auto_mounts = [
+ ("/dev/vda1", "/boot/efi", "vfat", ""),
+ ("/dev/vg0/root", "/", "ext4", ""),
+ ("/dev/vda2", "swap", "swap", ""),
+ ]
+ path = tmp_path / "fstab"
+ engine.write_fstab(str(path))
+ text = path.read_text()
+ assert "UUID=ROOT\t/\text4\trw,errors=remount-ro\t0\t1" in text
+ assert "UUID=EFI\t/boot/efi\tvfat\tdefaults\t0\t1" in text
+ assert "UUID=SWAP none swap sw 0 0" in text
+
+ def test_custom_fstab_btrfs_subvolumes(self, tmp_path):
+ engine, runner = make_engine(automated=True)
+ runner.outputs = {"blkid": '/dev/vda2: UUID="BTR" TYPE="btrfs"'}
+ engine.auto_mounts = [
+ ("/dev/vda2", "/", "btrfs", "@"),
+ ("/dev/vda2", "/home", "btrfs", "@home"),
+ ]
+ path = tmp_path / "fstab"
+ engine.write_fstab(str(path))
+ text = path.read_text()
+ assert "UUID=BTR\t/\tbtrfs\tdefaults,subvol=@\t0\t0" in text
+ assert "UUID=BTR\t/home\tbtrfs\tdefaults,subvol=@home\t0\t0" in text
+
+ def test_create_custom_partitions_command_sequence(self, monkeypatch):
+ engine, runner = make_engine(
+ automated=True, disk="/dev/vda", gptonefi=True,
+ custom_partitions=[
+ {"size": "512MB", "mount": "/boot/efi", "filesystem": "vfat",
+ "flags": ["esp"], "lvm_pv": None},
+ {"size": "2GB", "mount": "swap", "filesystem": "swap",
+ "flags": [], "lvm_pv": None},
+ {"size": "rest", "mount": None, "filesystem": None,
+ "flags": [], "lvm_pv": "vg0"},
+ ],
+ custom_lvm=[
+ {"vg": "vg0", "lv": "root", "size": "40GB", "mount": "/",
+ "filesystem": "ext4"},
+ {"vg": "vg0", "lv": "home", "size": "rest", "mount": "/home",
+ "filesystem": "ext4"},
+ ])
+ engine.set_progress_hook(lambda *a: None)
+ mounts = []
+ engine.do_mount = lambda dev, dest, fs, opts: mounts.append((dev, dest, fs))
+ sys_cmds = []
+ monkeypatch.setattr(installer.os, "system",
+ lambda cmd: sys_cmds.append(cmd) or 0)
+ monkeypatch.setattr(installer.time, "sleep", lambda *a: None)
+ monkeypatch.setattr(installer.os.path, "exists", lambda p: True)
+ fake_dev = type("D", (), {
+ "path": "/dev/vda",
+ "getLength": lambda self, unit: 256 * 10**9,
+ "sectorSize": 512})()
+ monkeypatch.setattr(installer.parted, "getDevice", lambda p: fake_dev,
+ raising=False)
+ monkeypatch.setattr(installer.partitioning,
+ "get_device_naming_scheme_prefix", lambda p: "")
+
+ engine._create_custom_partitions()
+
+ # LVM + filesystems through the runner
+ assert "pvcreate -y /dev/vda3" in runner.commands
+ assert "vgcreate -y vg0 /dev/vda3" in runner.commands
+ assert "lvcreate -y -n root -L 40000M vg0" in runner.commands
+ assert "lvcreate -y -n home -l 100%FREE vg0" in runner.commands
+ assert "mkfs.vfat /dev/vda1 -F 32" in runner.commands
+ assert "mkfs.ext4 -F /dev/vg0/root" in runner.commands
+ assert "mkswap /dev/vda2" in runner.commands
+ # parted through os.system
+ assert any("mklabel gpt" in c for c in sys_cmds)
+ assert any("set 1 esp on" in c for c in sys_cmds)
+ # mounts recorded; root mounted before deeper paths
+ targets = [m[1] for m in mounts]
+ assert targets[0] == "/target" # / first
+ assert "/target/home" in targets
+ assert "/target/boot/efi" in targets
+ assert ("/dev/vg0/root", "/", "ext4", "") in engine.auto_mounts
+
+ def test_create_btrfs_subvolumes(self, monkeypatch):
+ engine, runner = make_engine(
+ automated=True, disk="/dev/vda", gptonefi=True,
+ custom_partitions=[
+ {"size": "512MB", "mount": "/boot/efi", "filesystem": "vfat",
+ "flags": ["esp"], "lvm_pv": None, "subvolumes": []},
+ {"size": "rest", "mount": None, "filesystem": "btrfs",
+ "flags": [], "lvm_pv": None,
+ "subvolumes": [{"name": "@", "mount": "/"},
+ {"name": "@home", "mount": "/home"}]},
+ ],
+ custom_lvm=[])
+ engine.set_progress_hook(lambda *a: None)
+ mounts = []
+ engine.do_mount = lambda d, dest, fs, opts: mounts.append((d, dest, fs, opts))
+ monkeypatch.setattr(installer.os, "system", lambda c: 0)
+ monkeypatch.setattr(installer.time, "sleep", lambda *a: None)
+ monkeypatch.setattr(installer.os.path, "exists", lambda p: True)
+ fake_dev = type("D", (), {
+ "path": "/dev/vda",
+ "getLength": lambda self, unit: 256 * 10**9,
+ "sectorSize": 512})()
+ monkeypatch.setattr(installer.parted, "getDevice", lambda p: fake_dev,
+ raising=False)
+ monkeypatch.setattr(installer.partitioning,
+ "get_device_naming_scheme_prefix", lambda p: "")
+
+ engine._create_custom_partitions()
+
+ assert "mkfs.btrfs -f /dev/vda2" in runner.commands
+ assert "btrfs subvolume create /run/li-btrfs-top/@" in runner.commands
+ assert "btrfs subvolume create /run/li-btrfs-top/@home" in runner.commands
+ assert ("/dev/vda2", "/", "btrfs", "@") in engine.auto_mounts
+ assert ("/dev/vda2", "/home", "btrfs", "@home") in engine.auto_mounts
+ assert ("/dev/vda2", "/target", "btrfs", "subvol=@") in mounts
+ assert ("/dev/vda2", "/target/home", "btrfs", "subvol=@home") in mounts
+
+
+class TestLuksCrypttab:
+ def _engine(self, **flags):
+ engine, runner = make_engine(**flags)
+ engine.auto_root_physical_partition = "/dev/vda3"
+ runner.outputs = {"blkid": '/dev/vda3: UUID="LUKSUUID" TYPE="crypto_LUKS"'}
+ return engine, runner
+
+ def test_rekey_crypttab_uses_keyfile(self):
+ # prompt-on-first-boot: the first boot auto-unlocks via the embedded
+ # keyfile, so crypttab names it instead of `none`.
+ engine, runner = self._engine(luks=True, luks_rekey_on_first_boot=True)
+ engine.write_crypttab("/target/etc/crypttab")
+ cmd = "\n".join(runner.commands)
+ assert "/etc/cryptsetup-keys.d/cryptroot.key" in cmd
+ assert "UUID=LUKSUUID" in cmd
+ assert " none " not in cmd
+
+ def test_normal_crypttab_prompts(self):
+ engine, runner = self._engine(luks=True)
+ engine.write_crypttab("/target/etc/crypttab")
+ cmd = "\n".join(runner.commands)
+ assert "none" in cmd
+ assert "cryptsetup-keys.d" not in cmd
+
+
+class TestLocaleAndKeyboard:
+ def test_setup_locale_generates_additional(self):
+ engine, runner = make_engine(language="en_CA",
+ additional_locales=["fr_CA", "de_DE"])
+ engine.setup_locale()
+ cmd = "\n".join(runner.commands)
+ assert 'echo "en_CA.UTF-8 UTF-8" >> /target/etc/locale.gen' in cmd
+ assert 'echo "fr_CA.UTF-8 UTF-8" >> /target/etc/locale.gen' in cmd
+ assert 'echo "de_DE.UTF-8 UTF-8" >> /target/etc/locale.gen' in cmd
+ assert "locale-gen" in cmd
+ # LANG stays the primary
+ assert "update-locale LANG=en_CA.UTF-8" in cmd
+
+ def test_setup_locale_dedups_primary(self):
+ engine, runner = make_engine(language="en_CA",
+ additional_locales=["en_CA"])
+ engine.setup_locale()
+ # primary listed once; the duplicate additional is skipped
+ assert "\n".join(runner.commands).count("en_CA.UTF-8 UTF-8") == 1
+
+
+class TestRaidEngine:
+ def _part(self, **kw):
+ base = {"size": "1GB", "mount": None, "filesystem": None, "flags": [],
+ "lvm_pv": None, "raid": None, "subvolumes": []}
+ base.update(kw)
+ return base
+
+ def test_create_raid_command_sequence(self, monkeypatch):
+ engine, runner = make_engine(
+ automated=True, disks=["/dev/vda", "/dev/vdb", "/dev/vdc"],
+ gptonefi=False,
+ custom_partitions=[
+ self._part(size="1GB", raid="md0"),
+ self._part(size="rest", raid="md1"),
+ ],
+ custom_raid=[
+ {"name": "md0", "level": 1, "metadata": "1.2", "mount": "/boot",
+ "filesystem": "ext4", "lvm_pv": None, "subvolumes": []},
+ {"name": "md1", "level": 5, "metadata": "1.2", "mount": None,
+ "filesystem": None, "lvm_pv": "vg0", "subvolumes": []},
+ ],
+ custom_lvm=[
+ {"vg": "vg0", "lv": "root", "size": "rest", "mount": "/",
+ "filesystem": "ext4", "subvolumes": []},
+ ])
+ engine.set_progress_hook(lambda *a: None)
+ engine.do_mount = lambda *a: None
+ sys_cmds = []
+ monkeypatch.setattr(installer.os, "system",
+ lambda cmd: sys_cmds.append(cmd) or 0)
+ monkeypatch.setattr(installer.time, "sleep", lambda *a: None)
+ monkeypatch.setattr(installer.os.path, "exists", lambda p: True)
+ fake_dev = type("D", (), {"getLength": lambda self, u: 100 * 10**9,
+ "sectorSize": 512})()
+ monkeypatch.setattr(installer.parted, "getDevice", lambda p: fake_dev,
+ raising=False)
+ monkeypatch.setattr(installer.partitioning,
+ "get_device_naming_scheme_prefix", lambda p: "")
+ engine._create_custom_partitions()
+ cmds = "\n".join(runner.commands)
+ # md0 = RAID1 over the 1GB member on each of the 3 disks
+ assert ("mdadm --create --run --verbose /dev/md0 --level=1 "
+ "--metadata=1.2 --raid-devices=3 /dev/vda1 /dev/vdb1 /dev/vdc1"
+ in cmds)
+ # md1 = RAID5 over the rest member, used as an LVM PV
+ assert ("--level=5 --metadata=1.2 --raid-devices=3 "
+ "/dev/vda2 /dev/vdb2 /dev/vdc2" in cmds)
+ assert "pvcreate -y /dev/md1" in cmds
+ assert "vgcreate -y vg0 /dev/md1" in cmds
+ assert "mkfs.ext4 -F /dev/md0" in cmds # /boot on md0
+ # every disk got a label + the raid partition flag
+ assert sum(1 for c in sys_cmds if "mklabel" in c) == 3
+ assert any("set 1 raid on" in c for c in sys_cmds)
+ assert ("/dev/vg0/root", "/", "ext4", "") in engine.auto_mounts
+ assert ("/dev/md0", "/boot", "ext4", "") in engine.auto_mounts
diff --git a/tests/unit/test_netconfig.py b/tests/unit/test_netconfig.py
new file mode 100644
index 00000000..fa08f486
--- /dev/null
+++ b/tests/unit/test_netconfig.py
@@ -0,0 +1,391 @@
+"""Unit tests for the netplan-v2 -> NetworkManager keyfile renderer."""
+
+import configparser
+
+import netconfig
+import schema
+
+
+def _render(network_yaml):
+ base = (
+ "version: 1\n"
+ "locale: en_US.UTF-8\n"
+ "timezone: America/Toronto\n"
+ "users:\n"
+ ' - {name: admin, passwd: "$6$rounds=4096$salt$hashhashhash"}\n'
+ "storage:\n"
+ " target:\n"
+ " match: {first-non-removable: true}\n"
+ )
+ config = schema.parse_config(base + network_yaml)
+ return netconfig.render(config.network)
+
+
+def _parse_keyfile(text):
+ parser = configparser.ConfigParser()
+ parser.read_string(text)
+ return parser
+
+
+class TestRender:
+ def test_none_renders_nothing(self):
+ assert netconfig.render(None) == {}
+
+ def test_static_dual_stack_ethernet(self):
+ files = _render(
+ "network:\n"
+ " version: 2\n"
+ " ethernets:\n"
+ " eth0:\n"
+ " addresses: [192.168.1.10/24, 2001:db8::5/64]\n"
+ " gateway4: 192.168.1.1\n"
+ " gateway6: 2001:db8::1\n"
+ " nameservers:\n"
+ " addresses: [192.168.1.53, 2001:db8::53]\n"
+ " search: [example.com]\n"
+ )
+ assert set(files) == {"eth0.nmconnection"}
+ kf = _parse_keyfile(files["eth0.nmconnection"])
+ assert kf["connection"]["type"] == "ethernet"
+ assert kf["connection"]["interface-name"] == "eth0"
+ assert kf["ipv4"]["method"] == "manual"
+ assert kf["ipv4"]["address1"] == "192.168.1.10/24"
+ assert kf["ipv4"]["gateway"] == "192.168.1.1"
+ assert kf["ipv4"]["dns"] == "192.168.1.53;"
+ assert kf["ipv4"]["dns-search"] == "example.com;"
+ assert kf["ipv6"]["method"] == "manual"
+ assert kf["ipv6"]["address1"] == "2001:db8::5/64"
+ assert kf["ipv6"]["gateway"] == "2001:db8::1"
+ assert kf["ipv6"]["dns"] == "2001:db8::53;"
+
+ def test_dhcp_only(self):
+ files = _render(
+ "network:\n"
+ " version: 2\n"
+ " ethernets:\n"
+ " eth0: {dhcp4: true}\n"
+ )
+ kf = _parse_keyfile(files["eth0.nmconnection"])
+ assert kf["ipv4"]["method"] == "auto"
+ # No DHCPv6 / no v6 address -> link-local only, predictable default.
+ assert kf["ipv6"]["method"] == "link-local"
+ assert "address1" not in kf["ipv4"]
+
+ def test_ipv4_only_disables_no_address(self):
+ files = _render(
+ "network:\n"
+ " version: 2\n"
+ " ethernets:\n"
+ " eth0: {addresses: [10.0.0.2/24]}\n"
+ )
+ kf = _parse_keyfile(files["eth0.nmconnection"])
+ assert kf["ipv4"]["method"] == "manual"
+ assert kf["ipv6"]["method"] == "link-local"
+
+ def test_match_by_mac_binds_by_hardware(self):
+ files = _render(
+ "network:\n"
+ " version: 2\n"
+ " ethernets:\n"
+ " primary:\n"
+ ' match: {macaddress: "aa:bb:cc:dd:ee:ff"}\n'
+ " dhcp4: true\n"
+ )
+ kf = _parse_keyfile(files["primary.nmconnection"])
+ # Bound by MAC: no interface-name, MAC uppercased in [ethernet].
+ assert "interface-name" not in kf["connection"]
+ assert kf["ethernet"]["mac-address"] == "AA:BB:CC:DD:EE:FF"
+
+ def test_match_by_name_sets_interface_name(self):
+ files = _render(
+ "network:\n"
+ " version: 2\n"
+ " ethernets:\n"
+ " lan:\n"
+ " match: {name: enp3s0}\n"
+ " dhcp4: true\n"
+ )
+ kf = _parse_keyfile(files["lan.nmconnection"])
+ assert kf["connection"]["interface-name"] == "enp3s0"
+
+ def test_extra_route(self):
+ files = _render(
+ "network:\n"
+ " version: 2\n"
+ " ethernets:\n"
+ " eth0:\n"
+ " addresses: [192.168.1.10/24]\n"
+ " routes:\n"
+ " - {to: 10.0.0.0/8, via: 192.168.1.254, metric: 50}\n"
+ )
+ kf = _parse_keyfile(files["eth0.nmconnection"])
+ assert kf["ipv4"]["route1"] == "10.0.0.0/8,192.168.1.254,50"
+
+ def test_default_route_expands_per_family(self):
+ files = _render(
+ "network:\n"
+ " version: 2\n"
+ " ethernets:\n"
+ " eth0:\n"
+ " addresses: [192.168.1.10/24]\n"
+ " routes:\n"
+ " - {to: default, via: 192.168.1.1}\n"
+ )
+ kf = _parse_keyfile(files["eth0.nmconnection"])
+ assert kf["ipv4"]["route1"] == "0.0.0.0/0,192.168.1.1"
+
+ def test_vlan_references_parent_by_uuid(self):
+ files = _render(
+ "network:\n"
+ " version: 2\n"
+ " ethernets:\n"
+ " eth0: {dhcp4: true}\n"
+ " vlans:\n"
+ " vlan100:\n"
+ " id: 100\n"
+ " link: eth0\n"
+ " addresses: [10.100.0.5/24]\n"
+ )
+ eth = _parse_keyfile(files["eth0.nmconnection"])
+ vlan = _parse_keyfile(files["vlan100.nmconnection"])
+ assert vlan["connection"]["type"] == "vlan"
+ assert vlan["vlan"]["id"] == "100"
+ # The parent reference is the ethernet's connection UUID, so it
+ # resolves regardless of how the parent binds (name or MAC).
+ assert vlan["vlan"]["parent"] == eth["connection"]["uuid"]
+ assert vlan["ipv4"]["address1"] == "10.100.0.5/24"
+
+ def test_ipv6_only_static(self):
+ # No v4 address at all: v4 disabled, v6 carries the static config.
+ files = _render(
+ "network:\n"
+ " version: 2\n"
+ " ethernets:\n"
+ " eth0:\n"
+ " addresses: [2001:db8::10/64]\n"
+ " gateway6: 2001:db8::1\n"
+ " nameservers: {addresses: [2001:db8::53]}\n"
+ )
+ kf = _parse_keyfile(files["eth0.nmconnection"])
+ assert kf["ipv4"]["method"] == "disabled"
+ assert kf["ipv6"]["method"] == "manual"
+ assert kf["ipv6"]["address1"] == "2001:db8::10/64"
+ assert kf["ipv6"]["gateway"] == "2001:db8::1"
+ assert kf["ipv6"]["dns"] == "2001:db8::53;"
+ # A v4-disabled section must not carry DNS or addresses.
+ assert "dns" not in kf["ipv4"]
+ assert "address1" not in kf["ipv4"]
+
+ def test_dhcp6_slaac_is_auto(self):
+ files = _render(
+ "network:\n"
+ " version: 2\n"
+ " ethernets:\n"
+ " eth0: {dhcp6: true}\n"
+ )
+ kf = _parse_keyfile(files["eth0.nmconnection"])
+ # dhcp6/SLAAC maps to NM's ipv6 method=auto (accept RA + DHCPv6).
+ assert kf["ipv6"]["method"] == "auto"
+ assert kf["ipv4"]["method"] == "disabled"
+
+ def test_ipv6_default_route(self):
+ files = _render(
+ "network:\n"
+ " version: 2\n"
+ " ethernets:\n"
+ " eth0:\n"
+ " addresses: [2001:db8::10/64]\n"
+ " routes:\n"
+ " - {to: default, via: 2001:db8::1}\n"
+ )
+ kf = _parse_keyfile(files["eth0.nmconnection"])
+ assert kf["ipv6"]["route1"] == "::/0,2001:db8::1"
+ # The IPv6 default route must not leak into the IPv4 section.
+ assert not any(k.startswith("route") for k in kf["ipv4"])
+
+ def test_dual_stack_routes_split_by_family(self):
+ files = _render(
+ "network:\n"
+ " version: 2\n"
+ " ethernets:\n"
+ " eth0:\n"
+ " addresses: [192.168.1.10/24, 2001:db8::10/64]\n"
+ " routes:\n"
+ " - {to: 10.0.0.0/8, via: 192.168.1.254}\n"
+ " - {to: 'fd00::/8', via: '2001:db8::254'}\n"
+ )
+ kf = _parse_keyfile(files["eth0.nmconnection"])
+ assert kf["ipv4"]["route1"] == "10.0.0.0/8,192.168.1.254"
+ assert kf["ipv6"]["route1"] == "fd00::/8,2001:db8::254"
+
+ def test_vlan_carries_ipv6(self):
+ files = _render(
+ "network:\n"
+ " version: 2\n"
+ " ethernets:\n"
+ " eth0: {dhcp4: true}\n"
+ " vlans:\n"
+ " vlan100:\n"
+ " id: 100\n"
+ " link: eth0\n"
+ " addresses: [2001:db8:100::5/64]\n"
+ " gateway6: 2001:db8:100::1\n"
+ )
+ kf = _parse_keyfile(files["vlan100.nmconnection"])
+ assert kf["ipv6"]["method"] == "manual"
+ assert kf["ipv6"]["address1"] == "2001:db8:100::5/64"
+ assert kf["ipv6"]["gateway"] == "2001:db8:100::1"
+
+ def test_wifi_wpa_psk(self):
+ files = _render(
+ "network:\n"
+ " version: 2\n"
+ " wifis:\n"
+ " wlan0:\n"
+ " dhcp4: true\n"
+ " access-points:\n"
+ ' "HomeNet": {password: "hunter2pass"}\n'
+ )
+ assert set(files) == {"wlan0.nmconnection"}
+ kf = _parse_keyfile(files["wlan0.nmconnection"])
+ assert kf["connection"]["type"] == "wifi"
+ assert kf["connection"]["id"] == "HomeNet"
+ assert kf["connection"]["interface-name"] == "wlan0"
+ assert kf["wifi"]["ssid"] == "HomeNet"
+ assert kf["wifi"]["mode"] == "infrastructure"
+ assert kf["wifi-security"]["key-mgmt"] == "wpa-psk"
+ assert kf["wifi-security"]["psk"] == "hunter2pass"
+ assert kf["ipv4"]["method"] == "auto"
+
+ def test_wifi_open_network_has_no_security(self):
+ files = _render(
+ "network:\n"
+ " version: 2\n"
+ " wifis:\n"
+ " wlan0:\n"
+ " dhcp4: true\n"
+ " access-points:\n"
+ ' "Cafe": {}\n'
+ )
+ kf = _parse_keyfile(files["wlan0.nmconnection"])
+ assert not kf.has_section("wifi-security")
+
+ def test_wifi_hidden_and_mac_bind(self):
+ files = _render(
+ "network:\n"
+ " version: 2\n"
+ " wifis:\n"
+ " wlan0:\n"
+ ' match: {macaddress: "aa:bb:cc:dd:ee:01"}\n'
+ " dhcp4: true\n"
+ " access-points:\n"
+ ' "Hidden": {password: "secretpass", hidden: true}\n'
+ )
+ kf = _parse_keyfile(files["wlan0.nmconnection"])
+ assert kf["wifi"]["hidden"] == "true"
+ # bound by MAC -> no interface-name, mac in [wifi]
+ assert "interface-name" not in kf["connection"]
+ assert kf["wifi"]["mac-address"] == "AA:BB:CC:DD:EE:01"
+
+ def test_wifi_static_dual_stack(self):
+ files = _render(
+ "network:\n"
+ " version: 2\n"
+ " wifis:\n"
+ " wlan0:\n"
+ " addresses: [10.0.0.5/24, 2001:db8::5/64]\n"
+ " gateway4: 10.0.0.1\n"
+ " gateway6: 2001:db8::1\n"
+ " access-points:\n"
+ ' "Net": {password: "passw0rd1"}\n'
+ )
+ kf = _parse_keyfile(files["wlan0.nmconnection"])
+ assert kf["ipv4"]["address1"] == "10.0.0.5/24"
+ assert kf["ipv6"]["address1"] == "2001:db8::5/64"
+ assert kf["ipv6"]["gateway"] == "2001:db8::1"
+
+ def test_wifi_multiple_access_points_get_suffixed_files(self):
+ files = _render(
+ "network:\n"
+ " version: 2\n"
+ " wifis:\n"
+ " wlan0:\n"
+ " dhcp4: true\n"
+ " access-points:\n"
+ ' "First": {password: "firstpass"}\n'
+ ' "Second": {password: "secondpass"}\n'
+ )
+ assert set(files) == {"wlan0-1.nmconnection", "wlan0-2.nmconnection"}
+ ids = {_parse_keyfile(c)["connection"]["id"] for c in files.values()}
+ assert ids == {"First", "Second"}
+ # distinct uuids per access point
+ uuids = {_parse_keyfile(c)["connection"]["uuid"] for c in files.values()}
+ assert len(uuids) == 2
+
+ def test_uuid_is_deterministic(self):
+ net = (
+ "network:\n"
+ " version: 2\n"
+ " ethernets:\n"
+ " eth0: {dhcp4: true}\n"
+ )
+ a = _render(net)["eth0.nmconnection"]
+ b = _render(net)["eth0.nmconnection"]
+ assert a == b
+ assert _parse_keyfile(a)["connection"]["uuid"]
+
+
+class TestEap8021x:
+ def test_wired_peap(self):
+ files = _render(
+ "network:\n"
+ " version: 2\n"
+ " ethernets:\n"
+ " lan0:\n"
+ " dhcp4: true\n"
+ " auth:\n"
+ " method: peap\n"
+ " identity: host/ws.example.com\n"
+ " ca-certificate: /etc/ssl/certs/corp.pem\n"
+ " anonymous-identity: anon@example.com\n"
+ " phase2-auth: mschapv2\n"
+ " password: secret123\n"
+ )
+ kf = _parse_keyfile(files["lan0.nmconnection"])
+ assert kf["802-1x"]["eap"] == "peap"
+ assert kf["802-1x"]["identity"] == "host/ws.example.com"
+ assert kf["802-1x"]["ca-cert"] == "/etc/ssl/certs/corp.pem"
+ assert kf["802-1x"]["anonymous-identity"] == "anon@example.com"
+ assert kf["802-1x"]["phase2-auth"] == "mschapv2"
+ assert kf["802-1x"]["password"] == "secret123"
+ assert kf["connection"]["type"] == "ethernet"
+
+ def test_wifi_eap_tls(self):
+ files = _render(
+ "network:\n"
+ " version: 2\n"
+ " wifis:\n"
+ " wlan0:\n"
+ " access-points:\n"
+ ' "CorpTLS":\n'
+ " auth:\n"
+ " method: tls\n"
+ " identity: ws01\n"
+ " ca-certificate: /etc/ssl/certs/corp.pem\n"
+ " client-certificate: /etc/ssl/certs/ws01.pem\n"
+ " client-key: /etc/ssl/private/ws01.key\n"
+ " client-key-password: keypass\n"
+ )
+ kf = _parse_keyfile(files["wlan0.nmconnection"])
+ # EAP wifi uses wpa-eap, not wpa-psk
+ assert kf["wifi-security"]["key-mgmt"] == "wpa-eap"
+ assert "psk" not in kf["wifi-security"]
+ assert kf["802-1x"]["eap"] == "tls"
+ assert kf["802-1x"]["client-cert"] == "/etc/ssl/certs/ws01.pem"
+ assert kf["802-1x"]["private-key"] == "/etc/ssl/private/ws01.key"
+ assert kf["802-1x"]["private-key-password"] == "keypass"
+
+ def test_no_8021x_section_without_auth(self):
+ files = _render(
+ "network:\n version: 2\n ethernets:\n eth0: {dhcp4: true}\n")
+ assert not _parse_keyfile(files["eth0.nmconnection"]).has_section("802-1x")
diff --git a/tests/unit/test_partitioning.py b/tests/unit/test_partitioning.py
new file mode 100644
index 00000000..d1272887
--- /dev/null
+++ b/tests/unit/test_partitioning.py
@@ -0,0 +1,158 @@
+"""Unit tests for the pure helpers in partitioning.py."""
+
+import types
+
+import pytest
+
+import partitioning
+
+
+class TestGetDeviceNamingSchemePrefix:
+ @pytest.mark.parametrize(
+ "device,expected",
+ [
+ ("/dev/sda", ""),
+ ("/dev/sdb", ""),
+ ("/dev/vda", ""),
+ ("/dev/hda", ""),
+ ("/dev/nvme0n1", "p"),
+ ("/dev/mmcblk0", "p"),
+ ("/dev/loop0", "p"),
+ ("/dev/md0", "p"),
+ ],
+ )
+ def test_prefix(self, device, expected):
+ assert partitioning.get_device_naming_scheme_prefix(device) == expected
+
+
+class TestToHumanReadable:
+ @pytest.mark.parametrize(
+ "size,expected",
+ [
+ (0, "0.0 "),
+ (500, "500.0 "),
+ (999, "999.0 "),
+ (1000, "1.0 kB"),
+ (1500, "1.5 kB"),
+ (1_000_000, "1.0 MB"),
+ (1_234_567, "1.2 MB"),
+ (20_000_000_000, "20.0 GB"),
+ (2_000_000_000_000, "2.0 TB"),
+ (3_000_000_000_000_000, "3.0 PB"),
+ ],
+ )
+ def test_decimal_units(self, size, expected):
+ assert partitioning.to_human_readable(size) == expected
+
+
+class TestIsEfiSupported:
+ @pytest.fixture(autouse=True)
+ def no_modprobe(self, monkeypatch):
+ monkeypatch.setattr(partitioning.os, "system", lambda cmd: 0)
+
+ def test_efi_present_sysfs(self, monkeypatch):
+ monkeypatch.setattr(
+ partitioning.os.path, "exists", lambda p: p == "/sys/firmware/efi"
+ )
+ assert partitioning.is_efi_supported() is True
+
+ def test_efi_present_procfs(self, monkeypatch):
+ monkeypatch.setattr(
+ partitioning.os.path, "exists", lambda p: p == "/proc/efi"
+ )
+ assert partitioning.is_efi_supported() is True
+
+ def test_efi_absent(self, monkeypatch):
+ monkeypatch.setattr(partitioning.os.path, "exists", lambda p: False)
+ assert partitioning.is_efi_supported() is False
+
+
+def _fake_parted_partition(
+ *,
+ path="/dev/sda1",
+ number=1,
+ ptype=0, # parted.PARTITION_NORMAL in the stub
+ fs_type="ext4",
+ length_sectors=250_000,
+ length_bytes=128_000_000_000,
+ disk_length_sectors=1_000_000,
+ active=False,
+):
+ device = types.SimpleNamespace(getLength=lambda unit=None: disk_length_sectors)
+ disk = types.SimpleNamespace(device=device)
+ partition = types.SimpleNamespace(
+ path=path,
+ number=number,
+ type=ptype,
+ disk=disk,
+ active=active,
+ getFlagsAsString=lambda: "",
+ )
+ partition.getLength = (
+ lambda unit=None: length_bytes if unit == "B" else length_sectors
+ )
+ if fs_type is not None:
+ partition.fileSystem = types.SimpleNamespace(type=fs_type)
+ else:
+ partition.fileSystem = None
+ return partition
+
+
+class TestPartitionSizeMath:
+ """Partition.__init__ mixes pure math with mount/df side effects; the
+ side effects are stubbed so the math and classification can be asserted."""
+
+ @pytest.fixture(autouse=True)
+ def no_side_effects(self, monkeypatch):
+ monkeypatch.setattr(partitioning.os, "system", lambda cmd: 0)
+
+ def test_mountable_ext4_partition(self, monkeypatch):
+ # df output: blocks free used% mountpoint (1024B blocks)
+ monkeypatch.setattr(
+ partitioning,
+ "getoutput",
+ lambda cmd: "1000000 400000 60% /tmp/live-installer/tmpmount",
+ )
+ p = partitioning.Partition(_fake_parted_partition())
+
+ # 80 * 250000 / 1000000, floored at 1
+ assert p.size_percent == 20.0
+ assert p.type == "ext4"
+ assert p.name == "/dev/sda1"
+ assert p.html_name == "sda1"
+ # df path recalculates size from 1024B blocks
+ assert p.raw_size == 1_000_000 * 1024
+ assert p.size == "1.0 GB"
+ assert p.free_space == "409.6 MB"
+ assert p.used_percent == "60"
+ assert p.mount_as == ""
+ assert p.color == "#21619e"
+
+ def test_swap_partition_is_normalized_and_assigned(self, monkeypatch):
+ # Empty df output forces the ValueError fallback path
+ monkeypatch.setattr(partitioning, "getoutput", lambda cmd: "")
+ p = partitioning.Partition(
+ _fake_parted_partition(fs_type="linux-swap(v1)")
+ )
+
+ assert p.type == "swap"
+ assert p.mount_as == partitioning.SWAP_MOUNT_POINT
+ assert p.description == "swap"
+ assert p.used_percent == 0
+
+ def test_tiny_partition_keeps_minimum_one_percent(self, monkeypatch):
+ monkeypatch.setattr(partitioning, "getoutput", lambda cmd: "")
+ p = partitioning.Partition(
+ _fake_parted_partition(
+ length_sectors=10, disk_length_sectors=1_000_000_000
+ )
+ )
+ assert p.size_percent == 1
+
+ def test_metadata_partition_is_rejected(self):
+ import parted
+
+ with pytest.raises(AssertionError):
+ partitioning.Partition(
+ _fake_parted_partition(ptype=parted.PARTITION_METADATA)
+ )
diff --git a/tests/unit/test_pkgbackend.py b/tests/unit/test_pkgbackend.py
new file mode 100644
index 00000000..654c4ce2
--- /dev/null
+++ b/tests/unit/test_pkgbackend.py
@@ -0,0 +1,187 @@
+"""Unit tests for the swappable package backend (apt today)."""
+
+import pytest
+
+import pkgbackend
+import schema
+from commandrunner import CommandRunner
+
+
+class FakeRunner(CommandRunner):
+ """Records every command; can be told to fail commands matching a
+ substring (so failure-policy paths are exercisable)."""
+
+ def __init__(self, fail_substrings=()):
+ super().__init__(log=lambda *a: None)
+ self.commands = []
+ self.host_commands = [] # run() (not chroot-wrapped)
+ self.fail_substrings = list(fail_substrings)
+
+ def run(self, command, check=False, secrets=(), stdin=None):
+ self.commands.append(command)
+ if "chroot " not in command:
+ self.host_commands.append(command)
+ for bad in self.fail_substrings:
+ if bad in command:
+ return 1
+ return 0
+
+ def output(self, command, check=False):
+ self.commands.append(command)
+ return ""
+
+
+def _apt_config(yaml_sources):
+ base = (
+ "version: 1\n"
+ "locale: en_US.UTF-8\n"
+ "timezone: America/Toronto\n"
+ "users:\n"
+ ' - {name: admin, passwd: "$6$rounds=4096$salt$hashhashhash"}\n'
+ "storage:\n"
+ " target:\n"
+ " match: {first-non-removable: true}\n"
+ )
+ return schema.parse_config(base + yaml_sources)
+
+
+def _backend(runner, target="/target", policy=None):
+ calls = []
+ policy = policy or (lambda mode, msg: calls.append((mode, msg)))
+ backend = pkgbackend.AptBackend(runner, policy, lambda *a: None,
+ target=target)
+ backend._policy_calls = calls
+ return backend
+
+
+def _joined(runner):
+ return "\n".join(runner.commands)
+
+
+class TestGetBackend:
+ def test_debian_is_apt(self):
+ b = pkgbackend.get_backend(FakeRunner(), lambda *a: None, lambda *a: None)
+ assert isinstance(b, pkgbackend.AptBackend)
+
+ def test_unknown_family_raises(self):
+ with pytest.raises(ValueError):
+ pkgbackend.get_backend(FakeRunner(), lambda *a: None,
+ lambda *a: None, family="redhat")
+
+
+class TestAptBackend:
+ def test_source_with_key_url(self):
+ cfg = _apt_config(
+ "apt:\n"
+ " sources:\n"
+ " vendor:\n"
+ ' source: "deb https://example.com/repo trixie main"\n'
+ " key_url: https://example.com/repo.gpg\n"
+ )
+ runner = FakeRunner()
+ _backend(runner).apply(cfg.apt, ["openssh-server"], [])
+ joined = _joined(runner)
+ assert "wget -O /etc/apt/trusted.gpg.d/vendor.asc" in joined
+ assert "https://example.com/repo.gpg" in joined
+ # source line written to a per-name .list
+ assert "/etc/apt/sources.list.d/vendor.list" in joined
+ assert "deb https://example.com/repo trixie main" in joined
+ assert "apt-get update" in joined
+ assert "apt-get install -y openssh-server" in joined
+
+ def test_source_with_keyid_uses_keyserver(self):
+ cfg = _apt_config(
+ "apt:\n"
+ " sources:\n"
+ " ks:\n"
+ ' source: "deb https://other.example/deb stable main"\n'
+ ' keyid: "0xABCDEF0123456789"\n'
+ " keyserver: keys.example.org\n"
+ )
+ runner = FakeRunner()
+ _backend(runner).apply(cfg.apt, [], [])
+ joined = _joined(runner)
+ assert "--keyserver keys.example.org" in joined
+ assert "--recv-keys 0xABCDEF0123456789" in joined
+ assert "/etc/apt/trusted.gpg.d/ks.gpg" in joined
+
+ def test_inline_key_written_to_target(self, tmp_path):
+ cfg = _apt_config(
+ "apt:\n"
+ " sources:\n"
+ " inline:\n"
+ ' source: "deb https://example.com/repo trixie main"\n'
+ " key: |\n"
+ " -----BEGIN PGP PUBLIC KEY BLOCK-----\n"
+ " abc\n"
+ " -----END PGP PUBLIC KEY BLOCK-----\n"
+ )
+ runner = FakeRunner()
+ _backend(runner, target=str(tmp_path)).apply(cfg.apt, [], [])
+ keyfile = tmp_path / "etc/apt/trusted.gpg.d/inline.asc"
+ assert keyfile.exists()
+ assert "BEGIN PGP PUBLIC KEY" in keyfile.read_text()
+ # no key fetch over the network for an inline key
+ assert "wget" not in _joined(runner)
+ assert "recv-keys" not in _joined(runner)
+
+ def test_filename_override(self):
+ cfg = _apt_config(
+ "apt:\n"
+ " sources:\n"
+ " vendor:\n"
+ ' source: "deb https://example.com/repo trixie main"\n'
+ " filename: custom-name\n"
+ )
+ runner = FakeRunner()
+ _backend(runner).apply(cfg.apt, [], [])
+ joined = _joined(runner)
+ assert "/etc/apt/sources.list.d/custom-name.list" in joined
+ assert "/etc/apt/sources.list.d/vendor.list" not in joined
+
+ def test_packages_only_still_updates(self):
+ runner = FakeRunner()
+ _backend(runner).apply(None, ["htop"], [])
+ joined = _joined(runner)
+ assert "apt-get update" in joined
+ assert "apt-get install -y htop" in joined
+
+ def test_remove(self):
+ runner = FakeRunner()
+ _backend(runner).apply(None, [], ["hexchat"])
+ assert "apt-get remove --purge -y hexchat" in _joined(runner)
+ # no source/update work when only removing
+ assert "apt-get update" not in _joined(runner)
+
+ def test_noop_when_nothing_to_do(self):
+ runner = FakeRunner()
+ _backend(runner).apply(None, [], [])
+ assert not any("apt-get" in c for c in runner.commands)
+
+ def test_update_failure_invokes_policy(self):
+ runner = FakeRunner(fail_substrings=["apt-get update"])
+ backend = _backend(runner)
+ backend.apply(None, ["htop"], [])
+ assert ("network_unavailable", "apt-get update failed") \
+ in backend._policy_calls
+
+ def test_install_failure_invokes_policy(self):
+ runner = FakeRunner(fail_substrings=["apt-get install -y"])
+ backend = _backend(runner)
+ backend.apply(None, ["htop"], [])
+ assert ("package_install_failure", "package installation failed") \
+ in backend._policy_calls
+
+ def test_key_fetch_failure_invokes_policy(self):
+ cfg = _apt_config(
+ "apt:\n"
+ " sources:\n"
+ " vendor:\n"
+ ' source: "deb https://example.com/repo trixie main"\n'
+ " key_url: https://example.com/repo.gpg\n"
+ )
+ runner = FakeRunner(fail_substrings=["wget"])
+ backend = _backend(runner)
+ backend.apply(cfg.apt, [], [])
+ assert any(mode == "network_unavailable"
+ for mode, _ in backend._policy_calls)
diff --git a/tests/unit/test_schema.py b/tests/unit/test_schema.py
new file mode 100644
index 00000000..9a3b515a
--- /dev/null
+++ b/tests/unit/test_schema.py
@@ -0,0 +1,1162 @@
+"""Unit tests for the unattended-install answer-file schema."""
+
+import textwrap
+
+import pytest
+
+import schema
+from schema import ConfigError, parse_config
+
+VALID_MINIMAL = textwrap.dedent("""\
+ version: 1
+ locale: en_US.UTF-8
+ timezone: America/Toronto
+ users:
+ - name: admin
+ passwd: "$6$rounds=4096$salt$hashhashhash"
+ storage:
+ target:
+ match:
+ first-non-removable: true
+""")
+
+VALID_FULL = textwrap.dedent("""\
+ version: 1
+ hostname: mint-ws-01
+ locale: en_CA.UTF-8
+ timezone: America/Toronto
+ keyboard:
+ model: pc105
+ layout: us
+ variant: ""
+ users:
+ - name: admin
+ gecos: Workstation Admin
+ passwd: "$6$rounds=4096$salt$hashhashhash"
+ groups: [sudo]
+ ssh_authorized_keys:
+ - "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA admin@host"
+ storage:
+ target:
+ match:
+ by-id: "nvme-Samsung_SSD_980_PRO_*"
+ on_no_match: abort
+ layout: lvm-on-luks
+ luks:
+ passphrase_source: prompt-on-first-boot
+ packages: [openssh-server, build-essential]
+ package_remove: [hexchat]
+ apt:
+ sources:
+ example:
+ source: "deb https://example.com/repo trixie main"
+ key_url: https://example.com/repo.gpg
+ late_commands:
+ - /cdrom/scripts/join-domain.sh
+ - systemctl enable ssh
+ oem:
+ enabled: false
+ on_failure:
+ partition_mismatch: abort
+ network_unavailable: continue
+ package_install_failure: abort
+ post_install_script_failure: abort
+ logging:
+ destination: /var/log/live-installer-auto.log
+ also_serial: ttyS0
+""")
+
+
+class TestValidConfigs:
+ def test_minimal(self):
+ config = parse_config(VALID_MINIMAL)
+ assert config.version == 1
+ assert config.users[0].name == "admin"
+ assert config.locale == "en_US.UTF-8"
+ assert config.timezone == "America/Toronto"
+ assert config.storage.target.match.first_non_removable is True
+ # fail-closed defaults
+ assert config.storage.layout == "simple"
+ assert config.on_failure.partition_mismatch == "abort"
+ assert config.on_failure.network_unavailable == "abort"
+ assert config.keyboard.layout == "us"
+
+ def test_full(self):
+ config = parse_config(VALID_FULL)
+ assert config.hostname == "mint-ws-01"
+ assert config.storage.target.match.by_id == "nvme-Samsung_SSD_980_PRO_*"
+ assert config.storage.luks.passphrase_source == "prompt-on-first-boot"
+ assert config.on_failure.network_unavailable == "continue"
+ assert config.packages == ["openssh-server", "build-essential"]
+ assert config.package_remove == ["hexchat"]
+ assert config.apt.sources["example"].source.startswith("deb https://")
+ assert config.late_commands == ["/cdrom/scripts/join-domain.sh",
+ "systemctl enable ssh"]
+
+ def test_sudo_via_group(self):
+ config = parse_config(VALID_FULL)
+ assert config.users[0].groups == ["sudo"]
+ assert config.users[0].sudo is True # convenience property
+
+ def test_no_sudo_when_not_in_group(self):
+ config = parse_config(VALID_MINIMAL)
+ assert config.users[0].sudo is False
+
+ def test_lvm_on_luks_defaults_to_firstboot_prompt(self):
+ config = parse_config(
+ VALID_MINIMAL.replace(
+ "storage:\n target:",
+ "storage:\n layout: lvm-on-luks\n target:",
+ )
+ )
+ assert config.storage.luks is not None
+ assert config.storage.luks.passphrase_source == "prompt-on-first-boot"
+
+ def test_ssh_authorized_keys_accepted(self):
+ config = parse_config(VALID_FULL)
+ assert config.users[0].ssh_authorized_keys[0].startswith("ssh-ed25519")
+
+ def test_scenario_fixture_parses(self):
+ # the integration-test answer file must always track the schema
+ from pathlib import Path
+
+ fixture = (
+ Path(__file__).resolve().parents[1]
+ / "integration" / "scenarios" / "answers" / "bios-simple.yaml"
+ )
+ config = schema.load_config(fixture)
+ assert config.hostname == "lmde-test-01"
+ assert config.packages == ["openssh-server"]
+
+
+def _expect_error(yaml_text, *fragments):
+ with pytest.raises(ConfigError) as excinfo:
+ parse_config(yaml_text)
+ for fragment in fragments:
+ assert fragment in str(excinfo.value)
+ return excinfo.value
+
+
+class TestRejections:
+ def test_not_yaml(self):
+ _expect_error("{:::", "not valid YAML")
+
+ def test_not_a_mapping(self):
+ _expect_error("- just\n- a\n- list", "must be a YAML mapping")
+
+ def test_missing_version(self):
+ _expect_error(VALID_MINIMAL.replace("version: 1\n", ""), "version")
+
+ def test_wrong_version(self):
+ _expect_error(
+ VALID_MINIMAL.replace("version: 1", "version: 2"),
+ "unsupported schema version",
+ )
+
+ def test_unknown_top_level_key(self):
+ _expect_error(VALID_MINIMAL + "frobnicate: yes\n", "frobnicate")
+
+ def test_plaintext_password_rejected(self):
+ bad = VALID_MINIMAL.replace(
+ '"$6$rounds=4096$salt$hashhashhash"', "hunter2"
+ )
+ _expect_error(bad, "crypt(5)", "Plaintext")
+
+ def test_raw_device_path_rejected(self):
+ bad = VALID_MINIMAL.replace(
+ "first-non-removable: true", 'by-id: "/dev/sda"'
+ )
+ _expect_error(bad, "raw device path", "stable attributes")
+
+ def test_empty_match_rejected(self):
+ bad = VALID_MINIMAL.replace(
+ "match:\n first-non-removable: true",
+ "match: {}",
+ )
+ _expect_error(bad, "at least one matcher")
+
+ def test_partitions_without_custom_layout_rejected(self):
+ bad = VALID_MINIMAL.replace(
+ " first-non-removable: true",
+ " first-non-removable: true\n"
+ " partitions:\n"
+ " - {size: rest, mount: /, filesystem: ext4}",
+ )
+ _expect_error(bad, "only valid with layout: custom")
+
+ def test_luks_without_luks_layout_rejected(self):
+ bad = VALID_FULL.replace("layout: lvm-on-luks", "layout: lvm")
+ _expect_error(bad, "only valid with layout: lvm-on-luks")
+
+ def test_keyfile_source_requires_path(self):
+ bad = VALID_FULL.replace(
+ "passphrase_source: prompt-on-first-boot",
+ "passphrase_source: keyfile",
+ )
+ _expect_error(bad, "requires a 'keyfile' path")
+
+ def test_invalid_on_no_match(self):
+ bad = VALID_FULL.replace("on_no_match: abort", "on_no_match: use-first")
+ _expect_error(bad, "on_no_match")
+
+ def test_invalid_failure_policy(self):
+ bad = VALID_FULL.replace(
+ "network_unavailable: continue", "network_unavailable: retry"
+ )
+ _expect_error(bad, "abort", "continue")
+
+ def test_no_users_rejected(self):
+ bad = VALID_MINIMAL.replace(
+ "users:\n - name: admin\n"
+ ' passwd: "$6$rounds=4096$salt$hashhashhash"\n',
+ "users: []\n",
+ )
+ _expect_error(bad, "users")
+
+ def test_duplicate_usernames_rejected(self):
+ bad = VALID_MINIMAL.replace(
+ "users:",
+ "users:\n"
+ " - name: admin\n"
+ ' passwd: "$6$rounds=4096$salt$other"',
+ )
+ _expect_error(bad, "duplicate usernames")
+
+ def test_two_autologin_users_rejected(self):
+ bad = VALID_MINIMAL.replace(
+ "users:",
+ "users:\n"
+ " - name: kiosk\n"
+ ' passwd: "$6$rounds=4096$salt$other"\n'
+ " autologin: true",
+ ).replace(
+ 'passwd: "$6$rounds=4096$salt$hashhashhash"',
+ 'passwd: "$6$rounds=4096$salt$hashhashhash"\n'
+ " autologin: true",
+ )
+ _expect_error(bad, "autologin")
+
+ def test_bad_username_rejected(self):
+ bad = VALID_MINIMAL.replace("name: admin", "name: Admin User")
+ _expect_error(bad, "not a valid username")
+
+ def test_bad_group_rejected(self):
+ bad = VALID_FULL.replace("groups: [sudo]", "groups: [Bad Group]")
+ _expect_error(bad, "not a valid group name")
+
+ def test_bad_hostname_rejected(self):
+ bad = VALID_FULL.replace("hostname: mint-ws-01", "hostname: -bad-")
+ _expect_error(bad, "not a valid hostname")
+
+ def test_bad_timezone_rejected(self):
+ bad = VALID_MINIMAL.replace(
+ "timezone: America/Toronto", "timezone: Mars/Olympus_Mons"
+ )
+ _expect_error(bad, "timezone")
+
+ def test_http_repo_key_rejected(self):
+ bad = VALID_FULL.replace(
+ "key_url: https://example.com/repo.gpg",
+ "key_url: http://example.com/repo.gpg",
+ )
+ _expect_error(bad, "https://")
+
+ def test_garbage_ssh_key_rejected(self):
+ bad = VALID_FULL.replace(
+ '- "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA admin@host"',
+ '- "not a key at all"',
+ )
+ _expect_error(bad, "OpenSSH public key")
+
+ def test_norway_problem_is_defanged(self):
+ # YAML 1.1 would coerce `no` to boolean false; a boolean is not a
+ # valid keyboard layout string, so strict typing catches it.
+ bad = VALID_FULL.replace("layout: us", "layout: no")
+ _expect_error(bad, "keyboard")
+
+
+CUSTOM = textwrap.dedent("""\
+ version: 1
+ locale: en_US.UTF-8
+ timezone: America/Toronto
+ users:
+ - name: admin
+ passwd: "$6$rounds=4096$salt$hashhashhash"
+ storage:
+ target:
+ match:
+ first-non-removable: true
+ layout: custom
+ partitions:
+ - {size: 512MB, mount: /boot/efi, filesystem: vfat, flags: [esp]}
+ - {size: 2GB, mount: swap, filesystem: swap}
+ - {size: rest, lvm_pv: vg0}
+ lvm:
+ - {vg: vg0, lv: root, size: 40GB, mount: /, filesystem: ext4}
+ - {vg: vg0, lv: home, size: rest, mount: /home, filesystem: ext4}
+""")
+
+
+class TestCustomLayout:
+ def test_valid_plain_plus_lvm(self):
+ config = parse_config(CUSTOM)
+ st = config.storage
+ assert st.layout == "custom"
+ assert st.partitions[0].flags == ["esp"]
+ assert st.partitions[2].lvm_pv == "vg0"
+ assert st.lvm[0].vg == "vg0" and st.lvm[0].mount == "/"
+
+ def test_valid_plain_only(self):
+ text = textwrap.dedent("""\
+ version: 1
+ locale: en_US.UTF-8
+ timezone: America/Toronto
+ users:
+ - name: admin
+ passwd: "$6$rounds=4096$salt$hashhashhash"
+ storage:
+ target:
+ match:
+ first-non-removable: true
+ layout: custom
+ partitions:
+ - {size: 512MB, mount: /boot/efi, filesystem: vfat, flags: [esp]}
+ - {size: 40GB, mount: /, filesystem: ext4}
+ - {size: rest, mount: /home, filesystem: ext4}
+ """)
+ config = parse_config(text)
+ assert config.storage.lvm == []
+ assert any(p.mount == "/" for p in config.storage.partitions)
+
+ def test_no_root_rejected(self):
+ bad = CUSTOM.replace("lv: root, size: 40GB, mount: /,",
+ "lv: root, size: 40GB, mount: /srv,")
+ _expect_error(bad, "exactly one '/' mount")
+
+ def test_two_roots_rejected(self):
+ bad = CUSTOM.replace("lv: home, size: rest, mount: /home,",
+ "lv: home, size: rest, mount: /,")
+ _expect_error(bad, "exactly one '/' mount")
+
+ def test_duplicate_mount_rejected(self):
+ bad = CUSTOM.replace("lv: home, size: rest, mount: /home,",
+ "lv: home, size: rest, mount: /boot/efi,")
+ _expect_error(bad, "duplicate mount")
+
+ def test_two_rest_partitions_rejected(self):
+ bad = CUSTOM.replace("- {size: 2GB, mount: swap, filesystem: swap}",
+ "- {size: rest, mount: /srv, filesystem: ext4}")
+ _expect_error(bad, "one partition may use size: rest")
+
+ def test_pv_vg_without_volumes_rejected(self):
+ # add a PV for a VG that has no logical volumes
+ bad = CUSTOM.replace(
+ " - {size: rest, lvm_pv: vg0}\n",
+ " - {size: 1GB, lvm_pv: vgEmpty}\n"
+ " - {size: rest, lvm_pv: vg0}\n")
+ _expect_error(bad, "no logical volumes")
+
+ def test_lvm_volume_without_pv_rejected(self):
+ bad = CUSTOM.replace("vg: vg0, lv: home", "vg: vgOther, lv: home")
+ _expect_error(bad, "no lvm_pv")
+
+ def test_esp_must_be_vfat_at_boot_efi(self):
+ bad = CUSTOM.replace(
+ "{size: 512MB, mount: /boot/efi, filesystem: vfat, flags: [esp]}",
+ "{size: 512MB, mount: /boot/efi, filesystem: ext4, flags: [esp]}")
+ _expect_error(bad, "esp partition must be filesystem: vfat")
+
+ def test_boot_efi_without_esp_flag_rejected(self):
+ # /boot/efi must carry the esp flag or the GPT entry lacks the ESP
+ # type GUID and UEFI won't boot it (review finding #1).
+ bad = CUSTOM.replace(
+ "{size: 512MB, mount: /boot/efi, filesystem: vfat, flags: [esp]}",
+ "{size: 512MB, mount: /boot/efi, filesystem: vfat}")
+ _expect_error(bad, "esp")
+
+ def test_duplicate_lv_name_in_vg_rejected(self):
+ bad = CUSTOM.replace("lv: home", "lv: root")
+ _expect_error(bad, "duplicate logical-volume name")
+
+ def test_bad_filesystem_rejected(self):
+ bad = CUSTOM.replace("mount: /home, filesystem: ext4",
+ "mount: /home, filesystem: reiserfs")
+ _expect_error(bad, "filesystem")
+
+ def test_plain_partition_needs_mount_and_fs(self):
+ bad = CUSTOM.replace("- {size: 2GB, mount: swap, filesystem: swap}",
+ "- {size: 2GB, filesystem: ext4}")
+ _expect_error(bad, "needs both mount and filesystem")
+
+ def test_swap_mount_needs_swap_fs(self):
+ bad = CUSTOM.replace("- {size: 2GB, mount: swap, filesystem: swap}",
+ "- {size: 2GB, mount: swap, filesystem: ext4}")
+ _expect_error(bad, "swap")
+
+
+BTRFS = textwrap.dedent("""\
+ version: 1
+ locale: en_US.UTF-8
+ timezone: America/Toronto
+ users:
+ - name: admin
+ passwd: "$6$rounds=4096$salt$hashhashhash"
+ storage:
+ target:
+ match:
+ first-non-removable: true
+ layout: custom
+ partitions:
+ - {size: 512MB, mount: /boot/efi, filesystem: vfat, flags: [esp]}
+ - size: rest
+ filesystem: btrfs
+ subvolumes:
+ - {name: "@", mount: /}
+ - {name: "@home", mount: /home}
+""")
+
+
+class TestBtrfsSubvolumes:
+ def test_valid(self):
+ config = parse_config(BTRFS)
+ part = config.storage.partitions[1]
+ assert part.mount is None and part.filesystem == "btrfs"
+ assert [(s.name, s.mount) for s in part.subvolumes] == [
+ ("@", "/"), ("@home", "/home")]
+
+ def test_valid_on_lvm(self):
+ text = textwrap.dedent("""\
+ version: 1
+ locale: en_US.UTF-8
+ timezone: America/Toronto
+ users:
+ - name: admin
+ passwd: "$6$rounds=4096$salt$hashhashhash"
+ storage:
+ target:
+ match:
+ first-non-removable: true
+ layout: custom
+ partitions:
+ - {size: 512MB, mount: /boot/efi, filesystem: vfat, flags: [esp]}
+ - {size: rest, lvm_pv: vg0}
+ lvm:
+ - vg: vg0
+ lv: root
+ size: rest
+ filesystem: btrfs
+ subvolumes:
+ - {name: "@", mount: /}
+ - {name: "@home", mount: /home}
+ """)
+ config = parse_config(text)
+ assert config.storage.lvm[0].subvolumes[0].name == "@"
+
+ def test_subvolumes_require_btrfs(self):
+ bad = BTRFS.replace("filesystem: btrfs", "filesystem: ext4")
+ _expect_error(bad, "subvolumes require filesystem: btrfs")
+
+ def test_subvol_partition_takes_no_mount(self):
+ bad = BTRFS.replace(
+ " - size: rest\n filesystem: btrfs\n",
+ " - size: rest\n mount: /srv\n filesystem: btrfs\n")
+ _expect_error(bad, "takes no mount")
+
+ def test_subvolume_provides_the_root_mount(self):
+ bad = BTRFS.replace(' - {name: "@", mount: /}\n', "")
+ _expect_error(bad, "exactly one '/' mount")
+
+ def test_duplicate_subvol_mount_rejected(self):
+ bad = BTRFS.replace('{name: "@home", mount: /home}',
+ '{name: "@home", mount: /boot/efi}')
+ _expect_error(bad, "duplicate mount")
+
+
+NETWORK = textwrap.dedent("""\
+ version: 1
+ locale: en_US.UTF-8
+ timezone: America/Toronto
+ users:
+ - name: admin
+ passwd: "$6$rounds=4096$salt$hashhashhash"
+ storage:
+ target:
+ match:
+ first-non-removable: true
+ network:
+ version: 2
+ ethernets:
+ primary:
+ match: {macaddress: "aa:bb:cc:dd:ee:ff"}
+ addresses: [192.168.1.10/24, 2001:db8::5/64]
+ gateway4: 192.168.1.1
+ gateway6: 2001:db8::1
+ nameservers:
+ addresses: [192.168.1.53, 2001:db8::53]
+ search: [example.com]
+ routes:
+ - {to: 10.0.0.0/8, via: 192.168.1.254}
+ vlans:
+ vlan100:
+ id: 100
+ link: primary
+ addresses: [10.100.0.5/24]
+""")
+
+
+class TestNetwork:
+ def test_valid(self):
+ config = parse_config(NETWORK)
+ net = config.network
+ assert net.version == 2
+ eth = net.ethernets["primary"]
+ assert eth.match.macaddress == "aa:bb:cc:dd:ee:ff"
+ assert eth.addresses == ["192.168.1.10/24", "2001:db8::5/64"]
+ assert eth.gateway4 == "192.168.1.1"
+ assert eth.nameservers.search == ["example.com"]
+ assert eth.routes[0].to == "10.0.0.0/8"
+ vlan = net.vlans["vlan100"]
+ assert vlan.id == 100 and vlan.link == "primary"
+
+ def test_absent_network_is_none(self):
+ assert parse_config(VALID_MINIMAL).network is None
+
+ def test_dhcp_only(self):
+ text = VALID_MINIMAL + textwrap.dedent("""\
+ network:
+ version: 2
+ ethernets:
+ eth0: {dhcp4: true}
+ """)
+ config = parse_config(text)
+ assert config.network.ethernets["eth0"].dhcp4 is True
+
+ def test_wrong_version_rejected(self):
+ _expect_error(NETWORK.replace("version: 2", "version: 3"),
+ "network.version must be 2")
+
+ def test_bad_mac_rejected(self):
+ _expect_error(NETWORK.replace("aa:bb:cc:dd:ee:ff", "nope"),
+ "valid MAC")
+
+ def test_address_without_prefix_rejected(self):
+ _expect_error(NETWORK.replace("192.168.1.10/24", "192.168.1.10"),
+ "prefix length")
+
+ def test_gateway4_must_be_ipv4(self):
+ _expect_error(NETWORK.replace("gateway4: 192.168.1.1",
+ "gateway4: 2001:db8::1"),
+ "not an IPv4 address")
+
+ def test_gateway_without_matching_address_rejected(self):
+ bad = NETWORK.replace("addresses: [192.168.1.10/24, 2001:db8::5/64]",
+ "addresses: [2001:db8::5/64]")
+ _expect_error(bad, "gateway4 set but no IPv4 address")
+
+ def test_route_family_mismatch_rejected(self):
+ bad = NETWORK.replace("{to: 10.0.0.0/8, via: 192.168.1.254}",
+ "{to: 10.0.0.0/8, via: 'fe80::1'}")
+ _expect_error(bad, "cannot use an IPv6")
+
+ def test_vlan_dangling_link_rejected(self):
+ _expect_error(NETWORK.replace("link: primary", "link: missing0"),
+ "is not a defined")
+
+ def test_vlan_id_range_rejected(self):
+ _expect_error(NETWORK.replace("id: 100", "id: 5000"),
+ "between 0 and 4094")
+
+ def test_bad_interface_id_rejected(self):
+ _expect_error(NETWORK.replace(" primary:", ' "bad/if":'),
+ "valid interface id")
+
+ def test_unknown_key_rejected(self):
+ _expect_error(NETWORK.replace(" version: 2\n",
+ " version: 2\n bogus: 1\n"),
+ "bogus")
+
+
+APT = textwrap.dedent("""\
+ version: 1
+ locale: en_US.UTF-8
+ timezone: America/Toronto
+ users:
+ - name: admin
+ passwd: "$6$rounds=4096$salt$hashhashhash"
+ storage:
+ target:
+ match:
+ first-non-removable: true
+ apt:
+ sources:
+ vendor:
+ source: "deb https://example.com/repo trixie main"
+ key_url: https://example.com/repo.gpg
+ keyserver-repo:
+ source: "deb https://other.example/deb stable main"
+ keyid: "0xABCDEF0123456789"
+""")
+
+
+class TestApt:
+ def test_valid(self):
+ config = parse_config(APT)
+ srcs = config.apt.sources
+ assert set(srcs) == {"vendor", "keyserver-repo"}
+ assert srcs["vendor"].source.startswith("deb https://")
+ assert srcs["vendor"].key_url == "https://example.com/repo.gpg"
+ # keyserver defaults to cloud-init's
+ assert srcs["keyserver-repo"].keyserver == "keyserver.ubuntu.com"
+ assert srcs["keyserver-repo"].keyid == "0xABCDEF0123456789"
+
+ def test_absent_apt_is_none(self):
+ assert parse_config(VALID_MINIMAL).apt is None
+
+ def test_inline_key(self):
+ text = VALID_MINIMAL + textwrap.dedent("""\
+ apt:
+ sources:
+ inline:
+ source: "deb https://example.com/repo trixie main"
+ key: |
+ -----BEGIN PGP PUBLIC KEY BLOCK-----
+ abc
+ -----END PGP PUBLIC KEY BLOCK-----
+ """)
+ config = parse_config(text)
+ assert "BEGIN PGP PUBLIC KEY" in config.apt.sources["inline"].key
+
+ def test_non_deb_source_rejected(self):
+ _expect_error(APT.replace('"deb https://example.com/repo trixie main"',
+ '"ppa:some/ppa"'),
+ "deb ")
+
+ def test_http_key_url_rejected(self):
+ _expect_error(APT.replace("https://example.com/repo.gpg",
+ "http://example.com/repo.gpg"),
+ "https://")
+
+ def test_two_key_sources_rejected(self):
+ bad = APT.replace(
+ ' key_url: https://example.com/repo.gpg',
+ ' key_url: https://example.com/repo.gpg\n'
+ ' keyid: "ABCDEF12"')
+ _expect_error(bad, "at most one signing key")
+
+ def test_bad_keyid_rejected(self):
+ _expect_error(APT.replace('keyid: "0xABCDEF0123456789"',
+ 'keyid: "not-hex!"'),
+ "valid GPG key id")
+
+ def test_bad_source_name_rejected(self):
+ _expect_error(APT.replace(" vendor:", ' "bad/name":'),
+ "valid apt source name")
+
+ def test_bad_filename_rejected(self):
+ bad = APT.replace(
+ ' key_url: https://example.com/repo.gpg',
+ ' filename: "../escape"')
+ _expect_error(bad, "valid filename")
+
+ def test_unknown_key_rejected(self):
+ _expect_error(APT.replace(' key_url: https://example.com/repo.gpg',
+ ' bogus: 1'),
+ "bogus")
+
+
+WIFI = textwrap.dedent("""\
+ version: 1
+ locale: en_US.UTF-8
+ timezone: America/Toronto
+ users:
+ - name: admin
+ passwd: "$6$rounds=4096$salt$hashhashhash"
+ storage:
+ target:
+ match:
+ first-non-removable: true
+ network:
+ version: 2
+ wifis:
+ wlan0:
+ match: {macaddress: "aa:bb:cc:dd:ee:01"}
+ addresses: [10.0.0.5/24, 2001:db8::5/64]
+ gateway4: 10.0.0.1
+ access-points:
+ "Corp-WPA":
+ password: "supersecret123"
+ "OpenGuest":
+ hidden: true
+""")
+
+
+class TestWifi:
+ def test_valid(self):
+ config = parse_config(WIFI)
+ wifi = config.network.wifis["wlan0"]
+ assert wifi.match.macaddress == "aa:bb:cc:dd:ee:01"
+ assert wifi.addresses == ["10.0.0.5/24", "2001:db8::5/64"]
+ aps = wifi.access_points
+ assert aps["Corp-WPA"].password == "supersecret123"
+ assert aps["OpenGuest"].password is None # open network
+ assert aps["OpenGuest"].hidden is True
+
+ def test_dhcp_psk_minimal(self):
+ text = VALID_MINIMAL + textwrap.dedent("""\
+ network:
+ version: 2
+ wifis:
+ wlan0:
+ dhcp4: true
+ access-points:
+ "Home": {password: "passw0rd"}
+ """)
+ config = parse_config(text)
+ assert config.network.wifis["wlan0"].dhcp4 is True
+
+ def test_64_hex_psk_accepted(self):
+ text = VALID_MINIMAL + textwrap.dedent("""\
+ network:
+ version: 2
+ wifis:
+ wlan0:
+ access-points:
+ "Raw": {password: "%s"}
+ """ % ("a" * 64))
+ assert parse_config(text).network.wifis["wlan0"].access_points["Raw"]
+
+ def test_short_psk_rejected(self):
+ _expect_error(WIFI.replace('"supersecret123"', '"short"'),
+ "8..63")
+
+ def test_empty_ssid_rejected(self):
+ _expect_error(WIFI.replace('"Corp-WPA"', '""'), "valid SSID")
+
+ def test_overlong_ssid_rejected(self):
+ _expect_error(WIFI.replace('"Corp-WPA"', '"%s"' % ("x" * 33)),
+ "valid SSID")
+
+ def test_no_access_points_rejected(self):
+ text = VALID_MINIMAL + textwrap.dedent("""\
+ network:
+ version: 2
+ wifis:
+ wlan0: {dhcp4: true}
+ """)
+ _expect_error(text, "at least one access-points")
+
+ def test_unknown_ap_key_rejected(self):
+ _expect_error(WIFI.replace(' hidden: true',
+ ' hidden: true\n'
+ ' bogus: 1'),
+ "bogus")
+
+ def test_id_reused_across_ethernet_and_wifi_rejected(self):
+ text = VALID_MINIMAL + textwrap.dedent("""\
+ network:
+ version: 2
+ ethernets:
+ shared: {dhcp4: true}
+ wifis:
+ shared:
+ access-points:
+ "N": {password: "passw0rd"}
+ """)
+ _expect_error(text, "used for both")
+
+
+KBLOCALE = textwrap.dedent("""\
+ version: 1
+ locale: en_CA.UTF-8
+ timezone: America/Toronto
+ users:
+ - name: admin
+ passwd: "$6$rounds=4096$salt$hashhashhash"
+ storage:
+ target:
+ match:
+ first-non-removable: true
+ keyboard:
+ layout: us
+ additional_layouts:
+ - {layout: ca, variant: fr}
+ toggle: grp:alt_shift_toggle
+ additional_locales: [fr_CA.UTF-8, en_US.UTF-8]
+""")
+
+
+class TestKeyboardAndLocales:
+ def test_valid(self):
+ config = parse_config(KBLOCALE)
+ kb = config.keyboard
+ assert kb.layout == "us"
+ assert [(l.layout, l.variant) for l in kb.additional_layouts] == [("ca", "fr")]
+ assert kb.toggle == "grp:alt_shift_toggle"
+ assert config.additional_locales == ["fr_CA.UTF-8", "en_US.UTF-8"]
+
+ def test_defaults_single_layout_no_extras(self):
+ config = parse_config(VALID_MINIMAL)
+ assert config.keyboard.additional_layouts == []
+ assert config.keyboard.toggle is None
+ assert config.additional_locales == []
+
+ def test_toggle_without_extra_layouts_rejected(self):
+ _expect_error(KBLOCALE.replace(
+ " additional_layouts:\n - {layout: ca, variant: fr}\n", ""),
+ "toggle only applies")
+
+ def test_bad_layout_code_rejected(self):
+ _expect_error(KBLOCALE.replace("{layout: ca, variant: fr}",
+ "{layout: 'BAD!'}"),
+ "valid keyboard layout")
+
+ def test_bad_toggle_rejected(self):
+ _expect_error(KBLOCALE.replace("grp:alt_shift_toggle", "nonsense"),
+ "valid XKB toggle")
+
+ def test_bad_additional_locale_rejected(self):
+ _expect_error(KBLOCALE.replace("fr_CA.UTF-8", "not_a_locale!"),
+ "valid locale")
+
+ def test_unknown_keyboard_key_rejected(self):
+ _expect_error(KBLOCALE.replace(" layout: us\n",
+ " layout: us\n bogus: 1\n"),
+ "bogus")
+
+
+class TestProxy:
+ def test_valid(self):
+ config = parse_config(VALID_MINIMAL + "proxy: http://proxy.corp:3128\n")
+ assert config.proxy == "http://proxy.corp:3128"
+
+ def test_https_proxy(self):
+ config = parse_config(VALID_MINIMAL + "proxy: https://proxy.corp:8080\n")
+ assert config.proxy == "https://proxy.corp:8080"
+
+ def test_absent_is_none(self):
+ assert parse_config(VALID_MINIMAL).proxy is None
+
+ def test_bad_scheme_rejected(self):
+ _expect_error(VALID_MINIMAL + "proxy: ftp://proxy.corp\n", "valid proxy URL")
+
+ def test_no_host_rejected(self):
+ _expect_error(VALID_MINIMAL + 'proxy: "http://"\n', "valid proxy URL")
+
+ def test_whitespace_rejected(self):
+ _expect_error(VALID_MINIMAL + 'proxy: "http://a b"\n', "quotes or whitespace")
+
+
+_PEM = ("-----BEGIN CERTIFICATE-----\n"
+ "MIIDcorporate\n"
+ "-----END CERTIFICATE-----")
+
+
+class TestCaCerts:
+ def _cfg(self, block):
+ return VALID_MINIMAL + block
+
+ def test_valid_inline_cert(self):
+ block = 'ca_certs:\n trusted:\n - "%s"\n' % _PEM.replace("\n", "\\n")
+ config = parse_config(self._cfg(block))
+ assert len(config.ca_certs.trusted) == 1
+ assert config.ca_certs.remove_defaults is False
+
+ def test_absent_is_none(self):
+ assert parse_config(VALID_MINIMAL).ca_certs is None
+
+ def test_private_key_rejected(self):
+ block = ('ca_certs:\n trusted:\n - "-----BEGIN PRIVATE KEY-----'
+ '\\nabc\\n-----END PRIVATE KEY-----"\n')
+ _expect_error(self._cfg(block), "never", "private key")
+
+ def test_non_pem_rejected(self):
+ block = 'ca_certs:\n trusted:\n - "not a certificate"\n'
+ _expect_error(self._cfg(block), "PEM certificate")
+
+ def test_remove_defaults_without_certs_rejected(self):
+ block = "ca_certs:\n remove_defaults: true\n"
+ _expect_error(self._cfg(block), "empty trust store")
+
+ def test_remove_defaults_with_certs_ok(self):
+ block = ('ca_certs:\n remove_defaults: true\n trusted:\n - "%s"\n'
+ % _PEM.replace("\n", "\\n"))
+ config = parse_config(self._cfg(block))
+ assert config.ca_certs.remove_defaults is True
+
+
+EAP_BASE = (
+ "version: 1\n"
+ "locale: en_US.UTF-8\n"
+ "timezone: America/Toronto\n"
+ 'users: [{name: a, passwd: "$6$rounds=4096$s$h"}]\n'
+ "storage: {target: {match: {first-non-removable: true}}, layout: simple}\n"
+)
+
+
+class TestEap:
+ def _net(self, auth_block, where="ethernets"):
+ if where == "ethernets":
+ net = "network:\n version: 2\n ethernets:\n e0:\n dhcp4: true\n auth:\n%s" % auth_block
+ else:
+ net = ("network:\n version: 2\n wifis:\n w0:\n"
+ " access-points:\n \"S\":\n auth:\n%s" % auth_block)
+ return EAP_BASE + net
+
+ def test_valid_peap(self):
+ config = parse_config(self._net(
+ " method: peap\n"
+ " identity: u@x\n"
+ " ca-certificate: /etc/ssl/certs/c.pem\n"
+ " phase2-auth: mschapv2\n"
+ " password: secret\n"))
+ auth = config.network.ethernets["e0"].auth
+ assert auth.method == "peap"
+ assert auth.ca_certificate == "/etc/ssl/certs/c.pem"
+
+ def test_valid_tls_wifi(self):
+ config = parse_config(self._net(
+ " method: tls\n"
+ " identity: ws01\n"
+ " ca-certificate: /c.pem\n"
+ " client-certificate: /cc.pem\n"
+ " client-key: /ck.key\n", where="wifis"))
+ assert config.network.wifis["w0"].access_points["S"].auth.method == "tls"
+
+ def test_no_ca_rejected(self):
+ _expect_error(self._net(
+ " method: peap\n identity: u\n"
+ " phase2-auth: mschapv2\n password: p\n"),
+ "rogue-AP")
+
+ def test_no_ca_allowed_with_escape_hatch(self):
+ config = parse_config(self._net(
+ " method: peap\n identity: u\n"
+ " phase2-auth: mschapv2\n password: p\n"
+ " allow-unvalidated: true\n"))
+ assert config.network.ethernets["e0"].auth.allow_unvalidated is True
+
+ def test_tls_needs_client_cert_key(self):
+ _expect_error(self._net(
+ " method: tls\n ca-certificate: /c.pem\n"),
+ "client-certificate and client-key")
+
+ def test_peap_needs_phase2(self):
+ _expect_error(self._net(
+ " method: peap\n ca-certificate: /c.pem\n"
+ " identity: u\n password: p\n"),
+ "phase2-auth")
+
+ def test_bad_method(self):
+ _expect_error(self._net(
+ " method: leap\n ca-certificate: /c.pem\n"),
+ "EAP method must be one of")
+
+ def test_relative_cert_path_rejected(self):
+ _expect_error(self._net(
+ " method: tls\n ca-certificate: certs/c.pem\n"
+ " client-certificate: /cc\n client-key: /ck\n"),
+ "absolute path")
+
+ def test_wifi_psk_and_eap_both_rejected(self):
+ net = (EAP_BASE + "network:\n version: 2\n wifis:\n w0:\n"
+ " access-points:\n \"S\":\n"
+ " password: pskpass1\n"
+ " auth: {method: peap, ca-certificate: /c, identity: u,"
+ " password: p, phase2-auth: pap}\n")
+ _expect_error(net, "either a WPA-PSK password or EAP")
+
+
+SNAP_BTRFS = textwrap.dedent("""\
+ version: 1
+ locale: en_US.UTF-8
+ timezone: America/Toronto
+ users:
+ - {name: a, passwd: "$6$rounds=4096$s$h"}
+ storage:
+ target: {match: {first-non-removable: true}}
+ layout: custom
+ partitions:
+ - {size: 512MB, mount: /boot/efi, filesystem: vfat, flags: [esp]}
+ - size: rest
+ filesystem: btrfs
+ subvolumes:
+ - {name: "@", mount: /}
+ - {name: "@home", mount: /home}
+""")
+
+
+class TestSnapshots:
+ def test_valid_on_btrfs(self):
+ config = parse_config(
+ SNAP_BTRFS + "snapshots:\n schedule: {boot: true, daily: 5}\n"
+ " initial_snapshot: true\n")
+ assert config.snapshots.backend == "timeshift-btrfs"
+ assert config.snapshots.schedule.daily == 5
+ assert config.snapshots.initial_snapshot is True
+
+ def test_absent_is_none(self):
+ assert parse_config(SNAP_BTRFS).snapshots is None
+
+ def test_btrfs_backend_on_ext4_rejected(self):
+ # The cross-validation guard: enabling timeshift-btrfs without a btrfs
+ # root is the silent-failure class we refuse at validation time.
+ _expect_error(VALID_MINIMAL + "snapshots: {enabled: true}\n",
+ "btrfs root")
+
+ def test_disabled_snapshots_skip_cross_check(self):
+ config = parse_config(VALID_MINIMAL + "snapshots: {enabled: false}\n")
+ assert config.snapshots.enabled is False
+
+ def test_unsupported_backend_rejected(self):
+ _expect_error(
+ SNAP_BTRFS + "snapshots: {backend: timeshift-rsync}\n",
+ "only backend: timeshift-btrfs")
+
+ def test_negative_count_rejected(self):
+ _expect_error(
+ SNAP_BTRFS + "snapshots:\n schedule: {daily: -1}\n", ">= 0")
+
+
+RAID = textwrap.dedent("""\
+ version: 1
+ locale: en_US.UTF-8
+ timezone: America/Toronto
+ users:
+ - {name: a, passwd: "$6$rounds=4096$s$h"}
+ storage:
+ layout: custom
+ disks:
+ - {match: {by-id: "diskA*"}}
+ - {match: {by-id: "diskB*"}}
+ - {match: {by-id: "diskC*"}}
+ partitions:
+ - {size: 512MB, mount: /boot/efi, filesystem: vfat, flags: [esp]}
+ - {size: 1GB, raid: md0}
+ - {size: rest, raid: md1}
+ raid:
+ - {name: md0, level: 1, mount: /boot, filesystem: ext4}
+ - {name: md1, level: 5, lvm_pv: vg0}
+ lvm:
+ - {vg: vg0, lv: root, size: rest, mount: /, filesystem: ext4}
+""")
+
+
+class TestRaid:
+ def test_valid_multilevel_lvm_on_raid(self):
+ config = parse_config(RAID)
+ st = config.storage
+ assert len(st.disks) == 3
+ assert [(a.name, a.level) for a in st.raid] == [("md0", 1), ("md1", 5)]
+ assert st.raid[1].lvm_pv == "vg0"
+ assert st.lvm[0].mount == "/"
+
+ def test_raid1_two_disks(self):
+ text = RAID.replace(' - {match: {by-id: "diskC*"}}\n', "")
+ text = text.replace("level: 5", "level: 1") # RAID1 needs only 2
+ assert parse_config(text).storage.raid[1].level == 1
+
+ def test_raid_requires_disks(self):
+ # raid: present but single-disk target -> rejected
+ text = RAID.replace(
+ " disks:\n"
+ ' - {match: {by-id: "diskA*"}}\n'
+ ' - {match: {by-id: "diskB*"}}\n'
+ ' - {match: {by-id: "diskC*"}}\n',
+ " target: {match: {first-non-removable: true}}\n")
+ _expect_error(text, "RAID requires storage.disks")
+
+ def test_target_and_disks_both_rejected(self):
+ text = RAID.replace(
+ " disks:",
+ " target: {match: {first-non-removable: true}}\n disks:")
+ _expect_error(text, "either storage.target")
+
+ def test_level5_needs_three_disks(self):
+ text = RAID.replace(' - {match: {by-id: "diskC*"}}\n', "")
+ _expect_error(text, "needs at least 3 disks")
+
+ def test_bad_level_rejected(self):
+ _expect_error(RAID.replace("level: 5", "level: 6"), "RAID level must be")
+
+ def test_partition_refs_undefined_array(self):
+ _expect_error(RAID.replace("{size: 1GB, raid: md0}",
+ "{size: 1GB, raid: mdX}"),
+ "no definition")
+
+ def test_array_without_members(self):
+ text = RAID.replace(" - {size: 1GB, raid: md0}\n", "")
+ _expect_error(text, "no member partitions")
+
+ def test_raid_member_with_mount_rejected(self):
+ _expect_error(RAID.replace("{size: 1GB, raid: md0}",
+ "{size: 1GB, raid: md0, mount: /x, filesystem: ext4}"),
+ "takes no mount")
+
+
+class TestEarlyCommands:
+ def test_early_commands_and_policy(self):
+ config = parse_config(VALID_MINIMAL + textwrap.dedent("""\
+ early_commands:
+ - mdadm --stop --scan
+ - /cdrom/prep.sh
+ on_failure:
+ early_command_failure: continue
+ """))
+ assert config.early_commands == ["mdadm --stop --scan", "/cdrom/prep.sh"]
+ assert config.on_failure.early_command_failure == "continue"
+
+ def test_default_empty_and_abort(self):
+ config = parse_config(VALID_MINIMAL)
+ assert config.early_commands == []
+ assert config.on_failure.early_command_failure == "abort"
+
+ def test_bad_policy_rejected(self):
+ _expect_error(
+ VALID_MINIMAL + "on_failure: {early_command_failure: retry}\n",
+ "abort", "continue")
+
+
+class TestFlatpak:
+ def _cfg(self, block):
+ return VALID_MINIMAL + block
+
+ FLATPAK = textwrap.dedent("""\
+ flatpak:
+ remotes:
+ - {name: flathub, url: "https://flathub.org/repo/flathub.flatpakrepo"}
+ install:
+ - org.gnome.Calculator
+ - com.github.tchx84.Flatseal
+ """)
+
+ def test_valid(self):
+ config = parse_config(self._cfg(self.FLATPAK))
+ assert config.flatpak.remotes[0].name == "flathub"
+ assert config.flatpak.install == ["org.gnome.Calculator",
+ "com.github.tchx84.Flatseal"]
+
+ def test_absent_is_none(self):
+ assert parse_config(VALID_MINIMAL).flatpak is None
+
+ def test_install_without_remote_rejected(self):
+ _expect_error(self._cfg(
+ "flatpak:\n install: [org.gnome.Calculator]\n"),
+ "needs at least one remote")
+
+ def test_http_remote_rejected(self):
+ _expect_error(self._cfg(self.FLATPAK.replace("https://", "http://")),
+ "must be https")
+
+ def test_bad_app_id_rejected(self):
+ _expect_error(self._cfg(self.FLATPAK.replace("org.gnome.Calculator",
+ "bad id!")),
+ "valid flatpak app id")
+
+ def test_duplicate_remote_rejected(self):
+ block = textwrap.dedent("""\
+ flatpak:
+ remotes:
+ - {name: flathub, url: "https://a/x.flatpakrepo"}
+ - {name: flathub, url: "https://b/y.flatpakrepo"}
+ """)
+ _expect_error(self._cfg(block), "duplicate flatpak remote")
diff --git a/tests/unit/test_snapshotbackend.py b/tests/unit/test_snapshotbackend.py
new file mode 100644
index 00000000..71b38f93
--- /dev/null
+++ b/tests/unit/test_snapshotbackend.py
@@ -0,0 +1,109 @@
+"""Unit tests for the snapshot backend (Timeshift on btrfs)."""
+
+import json
+
+import pytest
+
+import schema
+import snapshotbackend
+from commandrunner import CommandRunner
+
+BTRFS_STORAGE = """\
+version: 1
+locale: en_US.UTF-8
+timezone: America/Toronto
+users: [{name: a, passwd: "$6$rounds=4096$s$h"}]
+storage:
+ target: {match: {first-non-removable: true}}
+ layout: custom
+ partitions:
+ - {size: 512MB, mount: /boot/efi, filesystem: vfat, flags: [esp]}
+ - size: rest
+ filesystem: btrfs
+ subvolumes:
+ - {name: "@", mount: /}
+ - {name: "@home", mount: /home}
+"""
+
+
+class FakeRunner(CommandRunner):
+ def __init__(self, rc=0):
+ super().__init__(log=lambda *a: None)
+ self.commands = []
+ self.rc = rc
+
+ def run(self, command, check=False, secrets=(), stdin=None):
+ self.commands.append(command)
+ return self.rc
+
+
+def _config(snapshots_block):
+ return schema.parse_config(BTRFS_STORAGE + snapshots_block)
+
+
+def _backend(runner, target):
+ calls = []
+ b = snapshotbackend.TimeshiftBtrfsBackend(
+ runner, lambda m, msg: calls.append((m, msg)), lambda *a: None,
+ target=target)
+ b._policy_calls = calls
+ return b
+
+
+class TestRenderConfig:
+ def test_schedule_translation(self):
+ cfg = _config("snapshots:\n schedule: {boot: true, daily: 5, weekly: 3}\n")
+ b = snapshotbackend.TimeshiftBtrfsBackend(None, None, None)
+ j = b.render_config(cfg.snapshots)
+ assert j["btrfs_mode"] == "true"
+ assert j["schedule_boot"] == "true" and j["count_boot"] == "5"
+ assert j["schedule_daily"] == "true" and j["count_daily"] == "5"
+ assert j["schedule_weekly"] == "true" and j["count_weekly"] == "3"
+ # monthly not requested -> disabled, count 0
+ assert j["schedule_monthly"] == "false" and j["count_monthly"] == "0"
+
+
+class TestApply:
+ def test_writes_json_script_and_service(self, tmp_path):
+ cfg = _config(
+ "snapshots:\n schedule: {daily: 5}\n initial_snapshot: true\n")
+ runner = FakeRunner()
+ _backend(runner, str(tmp_path)).apply(cfg.snapshots, cfg.storage)
+ # timeshift.json
+ conf = tmp_path / "etc/timeshift/timeshift.json"
+ data = json.loads(conf.read_text())
+ assert data["btrfs_mode"] == "true"
+ assert data["count_daily"] == "5"
+ # first-boot one-shot installed + enabled, with the initial snapshot
+ script = tmp_path / "usr/local/sbin/li-timeshift-firstboot"
+ assert script.exists() and (script.stat().st_mode & 0o111)
+ body = script.read_text()
+ assert "timeshift --check" in body
+ assert "timeshift --create" in body # initial_snapshot: true
+ unit = tmp_path / "etc/systemd/system/li-timeshift-firstboot.service"
+ assert "ExecStart=/usr/local/sbin/li-timeshift-firstboot" in unit.read_text()
+ joined = "\n".join(runner.commands)
+ assert "apt-get install -y timeshift" in joined
+ assert "systemctl enable li-timeshift-firstboot.service" in joined
+
+ def test_no_initial_snapshot_omits_create(self, tmp_path):
+ cfg = _config("snapshots:\n schedule: {daily: 5}\n")
+ _backend(FakeRunner(), str(tmp_path)).apply(cfg.snapshots, cfg.storage)
+ body = (tmp_path / "usr/local/sbin/li-timeshift-firstboot").read_text()
+ assert "timeshift --create" not in body
+
+ def test_timeshift_install_failure_invokes_policy(self, tmp_path):
+ cfg = _config("snapshots:\n schedule: {daily: 5}\n")
+ b = _backend(FakeRunner(rc=1), str(tmp_path))
+ b.apply(cfg.snapshots, cfg.storage)
+ assert any(m == "package_install_failure" for m, _ in b._policy_calls)
+ # config not written when the package install failed
+ assert not (tmp_path / "etc/timeshift/timeshift.json").exists()
+
+
+class TestGetBackend:
+ def test_btrfs_ok(self):
+ cfg = _config("snapshots:\n enabled: true\n")
+ b = snapshotbackend.get_snapshot_backend(
+ cfg, FakeRunner(), lambda *a: None, lambda *a: None)
+ assert isinstance(b, snapshotbackend.TimeshiftBtrfsBackend)
diff --git a/usr/lib/live-installer/auto_installer.py b/usr/lib/live-installer/auto_installer.py
new file mode 100644
index 00000000..50a335e8
--- /dev/null
+++ b/usr/lib/live-installer/auto_installer.py
@@ -0,0 +1,1246 @@
+#!/usr/bin/python3
+# coding: utf-8
+"""Headless driver for unattended installation.
+
+Sits beside the GTK InstallerWindow as a second consumer of
+InstallerEngine: it builds a Setup from a validated answer file
+(schema.py), resolves the target disk by stable attributes
+(diskmatch.py), registers console/log implementations of the engine's
+progress and error hooks, and runs the same
+start_installation()/finish_installation() sequence the GUI does.
+After the engine finishes, it re-enters the target to create any
+additional users, apply package changes and run post-install steps,
+honouring the answer file's per-failure-mode abort/continue policy.
+
+Answer-file sources: a local path, or an http(s) URL. Plain HTTP is
+refused (answer files carry password hashes) unless --insecure is
+given. On the kernel command line: live-installer.auto=.
+"""
+
+import argparse
+import glob
+import os
+import re
+import secrets
+import shlex
+import shutil
+import socket
+import subprocess
+import sys
+import tempfile
+import time
+import urllib.error
+import urllib.parse
+import urllib.request
+
+import catrust
+import discovery
+import diskmatch
+import mint_detect
+import netconfig
+import pkgbackend
+import schema
+import snapshotbackend
+from commandrunner import CommandRunner
+
+FINAL_MARKER = "Automated installation complete"
+FAILURE_MARKER = "Automated installation FAILED"
+CMDLINE_KEY = "live-installer.auto="
+# kernel-cmdline equivalent of --insecure (cmdline boots have no argv)
+CMDLINE_INSECURE = "live-installer.auto-insecure"
+
+# First-boot LUKS rekey (passphrase_source: prompt-on-first-boot). The install
+# formats LUKS with a random throwaway key and embeds it in the initramfs so the
+# first boot auto-unlocks; this oneshot then prompts the operator for the real
+# passphrase, swaps it in, removes the throwaway key + keyfile, and restores a
+# normal prompting boot. printf %s feeds the new key with no trailing newline,
+# matching what cryptsetup reads from the boot-time passphrase prompt.
+_LUKS_REKEY_SCRIPT = r"""#!/bin/sh
+# Installed by live-installer for storage.luks.passphrase_source:
+# prompt-on-first-boot. Runs once on first boot, prompting on the console
+# (serial included) for the real passphrase via a plain read on the tty.
+# Deliberately NOT `set -e` (a transient blkid must not kill the rekey) and
+# NOT systemd-ask-password (its agent path is fragile this early / headless).
+KEYFILE=/etc/cryptsetup-keys.d/cryptroot.key
+CRYPTTAB=/etc/crypttab
+HOOK=/etc/cryptsetup-initramfs/conf-hook
+
+[ -f "$KEYFILE" ] || exit 0 # already rekeyed
+
+DEV=$(awk '$1=="lvmmint"{print $2}' "$CRYPTTAB")
+case "$DEV" in
+ UUID=*) RES=$(blkid -U "${DEV#UUID=}" 2>/dev/null) && DEV="$RES" ;;
+esac
+
+stty -echo 2>/dev/null
+while :; do
+ printf '\n>>> Set the disk-encryption passphrase for this system: ' > /dev/console
+ IFS= read -r PASS || PASS=""
+ printf '\n>>> Confirm the disk-encryption passphrase: ' > /dev/console
+ IFS= read -r PASS2 || PASS2=""
+ if [ -n "$PASS" ] && [ "$PASS" = "$PASS2" ]; then
+ break
+ fi
+ printf '\nPassphrases were empty or did not match; try again.\n' > /dev/console
+done
+stty echo 2>/dev/null
+printf '\n' > /dev/console
+
+printf '%s' "$PASS" | cryptsetup luksAddKey --key-file "$KEYFILE" "$DEV" - || exit 1
+cryptsetup luksRemoveKey --key-file "$KEYFILE" "$DEV" || exit 1
+
+shred -u "$KEYFILE" 2>/dev/null || rm -f "$KEYFILE"
+sed -i "s#$KEYFILE#none#" "$CRYPTTAB"
+rm -f "$HOOK"
+update-initramfs -u
+
+systemctl disable li-luks-rekey.service
+rm -f /etc/systemd/system/li-luks-rekey.service /usr/local/sbin/li-luks-rekey
+systemctl reboot
+"""
+
+_LUKS_REKEY_SERVICE = """\
+[Unit]
+Description=First-boot LUKS passphrase setup (live-installer)
+ConditionPathExists=/etc/cryptsetup-keys.d/cryptroot.key
+# Run late (the system is up and the tty layer is ready) but before any getty
+# claims the console, so this owns the serial line for the prompt.
+After=systemd-user-sessions.service
+Before=getty.target serial-getty@ttyS0.service getty@tty1.service
+
+[Service]
+Type=oneshot
+ExecStart=/usr/local/sbin/li-luks-rekey
+StandardInput=tty-force
+StandardOutput=tty
+StandardError=journal+console
+TTYPath=/dev/console
+TTYReset=yes
+TTYVHangup=yes
+RemainAfterExit=no
+
+[Install]
+WantedBy=multi-user.target
+"""
+
+class InstallationFailed(Exception):
+ pass
+
+
+def fetch_answer_file(source, insecure=False):
+ """Return the text of an unattended-install file from a path or URL.
+
+ Used for the answer file and for LUKS keyfiles — both carry secrets,
+ so cleartext transports (plain HTTP, NFS, TFTP) are refused unless
+ explicitly opted into.
+ """
+ scheme = source.split("://", 1)[0] if "://" in source else ""
+ if scheme in ("http", "nfs", "tftp") and not insecure:
+ label = {"http": "plain HTTP", "nfs": "NFS", "tftp": "TFTP"}[scheme]
+ raise schema.ConfigError(
+ f"Refusing to fetch {source} over {label}: unattended-install "
+ "files carry secrets (password hashes, key material). Use "
+ "https://, or pass --insecure / boot with "
+ f"{CMDLINE_INSECURE} if you accept the risk."
+ )
+ if source.startswith(("http://", "https://")):
+ try:
+ with urllib.request.urlopen(source, timeout=60) as response:
+ return response.read().decode("utf-8")
+ except (urllib.error.URLError, OSError) as exc:
+ raise schema.ConfigError(
+ f"Could not fetch {source}: {exc}"
+ )
+ if source.startswith("nfs://"):
+ return _fetch_nfs(source)
+ if source.startswith("tftp://"):
+ return _fetch_tftp(source)
+ try:
+ with open(source, encoding="utf-8") as f:
+ return f.read()
+ except OSError as exc:
+ raise schema.ConfigError(f"Cannot read {source}: {exc}")
+
+
+_NFS_VERSIONS = ("3", "4", "4.0", "4.1", "4.2")
+
+
+def _parse_nfs_url(source):
+ """nfs://host[:port]/export/dir/file.yaml[?vers=N] ->
+ (host, '/export/dir', 'file.yaml', vers_or_None).
+
+ The whole directory is mounted and the file read from it; NFSv4 and most
+ NFSv3 exports allow mounting a subdirectory of an export this way. An
+ optional ?vers= query pins the protocol version (e.g. for a v3-only filer
+ or a policy that requires v4.2); omitted, mount.nfs negotiates.
+ """
+ # urlsplit is IPv6-aware: it strips the brackets from a [2001:db8::1]
+ # literal and separates any :port, which a naive split(":") would mangle.
+ parts = urllib.parse.urlsplit(source)
+ host, path = parts.hostname, parts.path
+ if not host or not path or path == "/":
+ raise schema.ConfigError(
+ f"Malformed NFS URL {source!r}; expected nfs://host/export/file.yaml"
+ )
+ vers = None
+ if parts.query:
+ query = urllib.parse.parse_qs(parts.query)
+ if "vers" in query:
+ vers = query["vers"][-1]
+ if vers not in _NFS_VERSIONS:
+ raise schema.ConfigError(
+ f"Unsupported NFS vers={vers!r} in {source!r}; expected one "
+ f"of {', '.join(_NFS_VERSIONS)}")
+ return host, os.path.dirname(path), os.path.basename(path), vers
+
+
+def _nfs_mount_source(host, export_dir):
+ """The host:export string mount.nfs expects, bracketing IPv6 literals
+ (a bare 2001:db8::1 would be misread as host:port)."""
+ spec_host = f"[{host}]" if ":" in host else host
+ return f"{spec_host}:{export_dir}"
+
+
+def _nfs_mount(host, export_dir, vers=None):
+ """Mount host:export_dir read-only on a fresh temp dir; return its path.
+ Factored out so tests can stub the actual mount. `vers` pins the NFS
+ protocol version when set; otherwise mount.nfs negotiates."""
+ mountpoint = tempfile.mkdtemp(prefix="li-nfs-")
+ spec = _nfs_mount_source(host, export_dir)
+ options = "ro,nolock,soft,timeo=100,retrans=2"
+ if vers:
+ options += f",vers={vers}"
+ result = subprocess.run(
+ ["mount", "-t", "nfs", "-o", options, spec, mountpoint],
+ capture_output=True, text=True,
+ )
+ if result.returncode != 0:
+ os.rmdir(mountpoint)
+ raise schema.ConfigError(
+ f"Could not NFS-mount {spec}: "
+ f"{result.stderr.strip() or result.returncode}"
+ )
+ return mountpoint
+
+
+def _nfs_umount(mountpoint):
+ subprocess.run(["umount", mountpoint], capture_output=True, text=True)
+ try:
+ os.rmdir(mountpoint)
+ except OSError:
+ pass
+
+
+def _parse_tftp_url(source):
+ """tftp://host[:port]/path -> (host, port, path). IPv6-aware via urlsplit."""
+ parts = urllib.parse.urlsplit(source)
+ host, path = parts.hostname, parts.path.lstrip("/")
+ if not host or not path:
+ raise schema.ConfigError(
+ f"Malformed TFTP URL {source!r}; expected tftp://host/path"
+ )
+ return host, parts.port or 69, path
+
+
+# Large TFTP transfers are discouraged regardless of block size; warn past this.
+_TFTP_WARN_BYTES = 256 * 1024
+# RFC 2348 block size we request. 1428 keeps a DATA packet inside a 1500-byte
+# Ethernet MTU (1428 + 4 TFTP + 8 UDP + 20 IPv4 ≈ 1460), avoiding fragmentation.
+_TFTP_BLKSIZE = 1428
+_TFTP_DEFAULT_BLKSIZE = 512 # RFC 1350 default, used until/unless negotiated
+
+
+class _TftpOptionRejected(Exception):
+ """Server returned ERROR code 8 (option negotiation); retry without options."""
+
+
+def _parse_oack(payload):
+ """Parse an OACK option payload (name\\0value\\0... pairs) into a dict with
+ lower-cased option names."""
+ fields = [f for f in payload.split(b"\x00") if f != b""]
+ opts = {}
+ for i in range(0, len(fields) - 1, 2):
+ opts[fields[i].decode(errors="replace").lower()] = \
+ fields[i + 1].decode(errors="replace")
+ return opts
+
+
+def _fetch_tftp(source, timeout=10):
+ """TFTP read client (octet mode), for a small answer file or keyfile.
+
+ Negotiates RFC 2347 options (RFC 2348 ``blksize``, RFC 2349 ``tsize``) for
+ fewer round-trips, and falls back cleanly to RFC 1350 (512-byte blocks)
+ when the server ignores the options or rejects them with ERROR code 8 — so
+ it works against option-aware and option-unaware servers alike. TFTP is
+ cleartext, so it is gated like HTTP."""
+ host, port, path = _parse_tftp_url(source)
+ try:
+ family = socket.getaddrinfo(host, port, type=socket.SOCK_DGRAM)[0][0]
+ addr = (host, port)
+ except OSError as exc:
+ raise schema.ConfigError(f"Cannot resolve {host} for {source}: {exc}")
+ try:
+ return _tftp_read(family, addr, path, source, timeout,
+ request_options=True)
+ except _TftpOptionRejected:
+ # A strict server actively refused our options; retry as plain RFC 1350.
+ return _tftp_read(family, addr, path, source, timeout,
+ request_options=False)
+
+
+def _tftp_read(family, addr, path, source, timeout, request_options):
+ sock = socket.socket(family, socket.SOCK_DGRAM)
+ sock.settimeout(timeout)
+ try:
+ rrq = bytearray(b"\x00\x01" + path.encode() + b"\x00octet\x00")
+ if request_options:
+ rrq += b"blksize\x00%d\x00" % _TFTP_BLKSIZE
+ rrq += b"tsize\x000\x00"
+ sock.sendto(bytes(rrq), addr)
+
+ data = bytearray()
+ blksize = _TFTP_DEFAULT_BLKSIZE
+ expected = 1
+ server = None
+ warned = False
+ negotiated = False # have we seen the first DATA/OACK yet?
+ while True:
+ try:
+ pkt, src = sock.recvfrom(65536)
+ except socket.timeout:
+ raise schema.ConfigError(f"TFTP timed out fetching {source}")
+ if server is None:
+ server = src # the server answers from a fresh transfer port
+ opcode = int.from_bytes(pkt[:2], "big")
+ if opcode == 6 and not negotiated: # OACK
+ opts = _parse_oack(pkt[2:])
+ if "blksize" in opts:
+ try:
+ blksize = int(opts["blksize"])
+ except ValueError:
+ raise schema.ConfigError(
+ f"TFTP server sent a bad blksize for {source}")
+ negotiated = True
+ sock.sendto(b"\x00\x04\x00\x00", server) # ACK block 0
+ continue
+ if opcode == 5: # ERROR
+ code = int.from_bytes(pkt[2:4], "big")
+ msg = pkt[4:].split(b"\x00", 1)[0].decode(errors="replace")
+ if code == 8 and request_options and not negotiated:
+ raise _TftpOptionRejected()
+ raise schema.ConfigError(f"TFTP error for {source}: {msg}")
+ if opcode != 3: # not DATA
+ raise schema.ConfigError(
+ f"TFTP unexpected opcode {opcode} for {source}")
+ # A direct DATA reply means the server ignored our options: fall
+ # back to the 512-byte default already in `blksize`.
+ negotiated = True
+ block = pkt[2:4]
+ if int.from_bytes(block, "big") == expected:
+ chunk = pkt[4:]
+ data.extend(chunk)
+ sock.sendto(b"\x00\x04" + block, server)
+ expected += 1
+ if not warned and len(data) > _TFTP_WARN_BYTES:
+ warned = True
+ print(f"WARNING: TFTP transfer of {source} exceeds "
+ f"{_TFTP_WARN_BYTES // 1024} KiB; TFTP is slow for "
+ "large files — prefer HTTP.")
+ if len(chunk) < blksize:
+ break # short block ends the transfer
+ else: # duplicate; re-ack what we got and wait for the right one
+ sock.sendto(b"\x00\x04" + block, server)
+ return data.decode("utf-8")
+ finally:
+ sock.close()
+
+
+def _fetch_nfs(source):
+ host, export_dir, filename, vers = _parse_nfs_url(source)
+ mountpoint = _nfs_mount(host, export_dir, vers=vers)
+ try:
+ with open(os.path.join(mountpoint, filename), encoding="utf-8") as f:
+ return f.read()
+ except OSError as exc:
+ raise schema.ConfigError(f"Cannot read {source}: {exc}")
+ finally:
+ _nfs_umount(mountpoint)
+
+
+def cmdline_source(cmdline_path="/proc/cmdline"):
+ """Extract the answer-file source from the kernel command line."""
+ try:
+ with open(cmdline_path) as f:
+ cmdline = f.read()
+ except OSError:
+ return None
+ for token in cmdline.split():
+ if token.startswith(CMDLINE_KEY):
+ return token[len(CMDLINE_KEY):]
+ return None
+
+
+def cmdline_insecure(cmdline_path="/proc/cmdline"):
+ """True if the kernel command line opts in to plain-HTTP answer files."""
+ try:
+ with open(cmdline_path) as f:
+ return CMDLINE_INSECURE in f.read().split()
+ except OSError:
+ return False
+
+
+def build_setup(config, *, disk=None, disks=None, efi=None, is_mint=None,
+ insecure=False):
+ """Map a validated AutoInstallConfig onto the engine's Setup object.
+
+ disk/efi/is_mint are injectable for tests; by default the disk is
+ resolved from the config's match expression and EFI/edition are
+ detected from the running system.
+ """
+ import installer
+ import partitioning
+
+ setup = installer.Setup()
+ setup.automated = True
+ setup.skip_mount = False
+ setup.is_mint = mint_detect.is_mint() if is_mint is None else is_mint
+
+ setup.language = config.locale.split(".")[0]
+ # Supplementary locales to also locale-gen (codeset-stripped like language).
+ setup.additional_locales = [loc.split(".")[0]
+ for loc in config.additional_locales]
+ setup.timezone = config.timezone
+ setup.keyboard_model = config.keyboard.model
+ # Comma-joined XKB layout/variant lists (primary first, then extras), which
+ # /etc/default/keyboard expects for a multi-layout, switchable setup.
+ kb = config.keyboard
+ layouts = [kb.layout] + [extra.layout for extra in kb.additional_layouts]
+ variants = [kb.variant] + [extra.variant for extra in kb.additional_layouts]
+ setup.keyboard_layout = ",".join(layouts)
+ setup.keyboard_variant = ",".join(variants) if any(variants) else ""
+ setup.keyboard_options = kb.toggle # XKB switch option, or None
+ setup.hostname = config.hostname or "mint"
+
+ primary = config.users[0]
+ setup.username = primary.name
+ setup.real_name = primary.gecos or primary.name
+ setup.password1 = primary.passwd
+ setup.password2 = primary.passwd
+ setup.password_is_crypted = True
+ setup.autologin = primary.autologin
+ setup.ecryptfs = primary.ecryptfs_home
+
+ setup.layout = config.storage.layout
+ setup.lvm = config.storage.layout in ("lvm", "lvm-on-luks")
+ setup.luks = config.storage.layout == "lvm-on-luks"
+ if config.storage.layout == "custom":
+ # Hand the engine plain dicts; it does not import the schema.
+ setup.custom_partitions = [
+ {"size": p.size, "mount": p.mount, "filesystem": p.filesystem,
+ "flags": list(p.flags), "lvm_pv": p.lvm_pv, "raid": p.raid,
+ "subvolumes": [{"name": s.name, "mount": s.mount}
+ for s in p.subvolumes]}
+ for p in config.storage.partitions
+ ]
+ setup.custom_lvm = [
+ {"vg": v.vg, "lv": v.lv, "size": v.size, "mount": v.mount,
+ "filesystem": v.filesystem,
+ "subvolumes": [{"name": s.name, "mount": s.mount}
+ for s in v.subvolumes]}
+ for v in config.storage.lvm
+ ]
+ setup.custom_raid = [
+ {"name": a.name, "level": a.level, "metadata": a.metadata,
+ "mount": a.mount, "filesystem": a.filesystem, "lvm_pv": a.lvm_pv,
+ "subvolumes": [{"name": s.name, "mount": s.mount}
+ for s in a.subvolumes]}
+ for a in config.storage.raid
+ ]
+ # so write_mtab() runs when the custom layout uses LVM
+ setup.lvm = bool(setup.custom_lvm)
+ if setup.luks:
+ luks = config.storage.luks
+ if luks.passphrase_source == "keyfile":
+ # The keyfile may be a local path (install media) or an http(s)
+ # URL (per-machine keyfiles from a provisioning server); URLs
+ # follow the same HTTPS-only rule as the answer file itself.
+ passphrase = fetch_answer_file(luks.keyfile, insecure).strip()
+ if not passphrase:
+ raise schema.ConfigError(
+ f"LUKS keyfile {luks.keyfile} is empty"
+ )
+ setup.passphrase1 = setup.passphrase2 = passphrase
+ elif luks.passphrase_source == "prompt-on-first-boot":
+ # Format LUKS with a random throwaway key so the install stays
+ # unattended; a keyfile holding it is embedded in the initramfs to
+ # auto-unlock the first boot, where a oneshot service prompts the
+ # operator for the real passphrase and removes the throwaway key.
+ # token_urlsafe gives a newline-free ASCII key (matters: the same
+ # bytes are written to the keyfile, see _setup_luks_first_boot_rekey).
+ setup.passphrase1 = setup.passphrase2 = secrets.token_urlsafe(32)
+ setup.luks_rekey_on_first_boot = True
+ else:
+ raise schema.ConfigError(
+ f"passphrase_source: {luks.passphrase_source} is not yet "
+ "implemented in the headless driver (use 'keyfile' or "
+ "'prompt-on-first-boot')"
+ )
+
+ if config.storage.disks:
+ # Multi-disk (RAID): resolve each match to a distinct disk.
+ if disks is not None:
+ setup.disks = list(disks)
+ else:
+ setup.disks = []
+ for entry in config.storage.disks:
+ resolved = diskmatch.resolve_disk(entry.match)
+ if resolved in setup.disks:
+ raise diskmatch.DiskMatchError(
+ f"storage.disks entries resolve to the same disk "
+ f"({resolved}); each must select a distinct disk")
+ setup.disks.append(resolved)
+ setup.disk = setup.disks[0]
+ else:
+ setup.disk = disk or diskmatch.resolve_disk(config.storage.target.match)
+ setup.disks = [setup.disk]
+ setup.diskname = os.path.basename(setup.disk)
+ setup.grub_device = setup.disk
+ setup.gptonefi = partitioning.is_efi_supported() if efi is None else efi
+ setup.oem_mode = config.oem.enabled
+ return setup
+
+
+class HeadlessDriver:
+ """Drives InstallerEngine without a GUI."""
+
+ def __init__(self, config, runner=None, engine_factory=None,
+ insecure=False):
+ self.config = config
+ self.insecure = insecure
+ self._log_file = None
+ self._serial = None
+ self._failed = False
+ self._last_progress = None
+ self._open_logs()
+ self.runner = runner or CommandRunner(log=self.log)
+ self._engine_factory = engine_factory
+
+ # -- logging / hooks ---------------------------------------------------
+
+ def _open_logs(self):
+ logging_cfg = self.config.logging
+ try:
+ os.makedirs(os.path.dirname(logging_cfg.destination), exist_ok=True)
+ self._log_file = open(logging_cfg.destination, "a", buffering=1)
+ except OSError:
+ self._log_file = None
+ if logging_cfg.also_serial:
+ try:
+ device = logging_cfg.also_serial
+ if not device.startswith("/dev/"):
+ device = "/dev/" + device
+ self._serial = open(device, "w", buffering=1)
+ except OSError:
+ self._serial = None
+
+ def log(self, message):
+ line = str(message)
+ print(line, flush=True)
+ for sink in (self._log_file, self._serial):
+ if sink is not None:
+ try:
+ sink.write(line + "\n")
+ except OSError:
+ pass
+
+ def on_progress(self, percentage, pulse, done, message):
+ # The engine fires this once per copied file during the rsync phase
+ # (hundreds of thousands of calls); only log actual changes or the
+ # serial console becomes the bottleneck of the entire installation.
+ line = f"[{percentage:3d}%] {message}"
+ if line == self._last_progress:
+ return
+ self._last_progress = line
+ self.log(line)
+
+ def on_error(self, message=""):
+ self._failed = True
+ self.log(f"ERROR: {message}")
+
+ # -- failure policy ----------------------------------------------------
+
+ def _policy(self, failure_mode, what):
+ """Apply the answer file's abort/continue policy for a failure."""
+ policy = getattr(self.config.on_failure, failure_mode)
+ if policy == "abort":
+ raise InstallationFailed(f"{what} (on_failure.{failure_mode}: abort)")
+ self.log(f"WARNING: {what} — continuing (on_failure.{failure_mode})")
+
+ # -- post-engine steps -------------------------------------------------
+ # These run via the engine's before_unmount_hook: after the system is
+ # fully configured but while the chroot is still mounted with network.
+
+ def _create_extra_users(self):
+ for user in self.config.users[1:]:
+ self.log(f" --> Creating additional user {user.name}")
+ gecos = (user.gecos or user.name).replace('"', "'")
+ rc = self.runner.chroot(
+ f'adduser --disabled-password --gecos "{gecos}" {user.name}'
+ )
+ if rc != 0:
+ self._policy("post_install_script_failure",
+ f"adduser {user.name} failed")
+ continue
+ # Write the hash via a file, exactly like the engine does for the
+ # primary user: crypt hashes contain '$' and must never pass
+ # through a shell.
+ with open("/target/dev/shm/.passwd", "w") as fp:
+ fp.write(user.name + ":" + user.passwd + "\n")
+ self.runner.chroot("cat /dev/shm/.passwd | chpasswd -e")
+ self.runner.run("rm -f /target/dev/shm/.passwd")
+ for group in user.groups:
+ self.runner.chroot(f"adduser {user.name} {group}")
+
+ def _apply_ssh_keys(self):
+ for user in self.config.users:
+ if not user.ssh_authorized_keys:
+ continue
+ self.log(f" --> Installing SSH keys for {user.name}")
+ ssh_dir = f"/home/{user.name}/.ssh"
+ self.runner.chroot(f"mkdir -p {ssh_dir}")
+ # written via /target to keep key material out of shell commands
+ with open(f"/target{ssh_dir}/authorized_keys", "a") as fp:
+ for key in user.ssh_authorized_keys:
+ fp.write(key.rstrip("\n") + "\n")
+ self.runner.chroot(f"chmod 700 {ssh_dir}")
+ self.runner.chroot(f"chmod 600 {ssh_dir}/authorized_keys")
+ self.runner.chroot(
+ f"chown -R {user.name}:{user.name} {ssh_dir}"
+ )
+
+ @staticmethod
+ def _resolv_has_nameserver(path):
+ try:
+ text = open(path, encoding="utf-8").read()
+ except OSError:
+ return False
+ return bool(re.search(r"(?m)^\s*nameserver\s+\d+\.\d+\.\d+\.\d+", text))
+
+ def _ensure_dns(self, resolv_path="/etc/resolv.conf",
+ lease_glob="/run/net-*.conf"):
+ """Give the live session working DNS before any network step.
+
+ On a netboot the NIC is configured by the initramfs, which
+ NetworkManager leaves unmanaged — so it never DHCPs and never writes
+ DNS, and /etc/resolv.conf keeps the live image's placeholder (observed
+ as 'nameserver dhcp'). apt then can't resolve. A no-op on CD/USB boot
+ where NetworkManager already populated resolv.conf.
+ """
+ if self._resolv_has_nameserver(resolv_path):
+ return
+
+ # Fast path: the DHCP nameservers the initramfs saved, if its
+ # /run/net-*.conf survived the pivot to the live system.
+ servers = []
+ for conf in sorted(glob.glob(lease_glob)):
+ try:
+ text = open(conf, encoding="utf-8").read()
+ except OSError:
+ continue
+ for match in re.finditer(r"(?m)^IPV4DNS\d+=(\d+\.\d+\.\d+\.\d+)", text):
+ ip = match.group(1)
+ if ip != "0.0.0.0" and ip not in servers:
+ servers.append(ip)
+ if servers:
+ try:
+ with open(resolv_path, "w", encoding="utf-8") as fd:
+ fd.writelines(f"nameserver {ip}\n" for ip in servers)
+ self.log(" --> Set DNS from netboot lease: " + ", ".join(servers))
+ return
+ except OSError as exc:
+ self.log(f"WARNING: could not write {resolv_path}: {exc}")
+
+ # The lease is usually gone (the initramfs /run does not survive the
+ # pivot), so drive NetworkManager to take over the boot NIC and DHCP
+ # it, which writes real nameservers into resolv.conf.
+ if self._nm_dhcp_boot_nic(resolv_path):
+ return
+ self.log("WARNING: could not establish DNS in the live session; "
+ "network-dependent install steps may fail")
+
+ def _nm_dhcp_boot_nic(self, resolv_path):
+ """Force NetworkManager to manage and DHCP the boot interface, then
+ write the DNS it learned into resolv.conf ourselves — NM's rc-manager
+ may be configured not to own /etc/resolv.conf, so we cannot rely on it
+ updating the file even once it has the nameservers."""
+ if shutil.which("nmcli") is None:
+ return False
+ dev = self.runner.output(
+ "ip -o route show default 2>/dev/null | awk '{print $5; exit}'")
+ if not dev:
+ dev = self.runner.output(
+ "for d in /sys/class/net/*; do n=${d##*/}; "
+ 'case $n in lo) ;; *) echo "$n"; break ;; esac; done')
+ if not dev:
+ return False
+ d = shlex.quote(dev)
+ self.log(f" --> Asking NetworkManager to configure {dev} for DNS")
+ self.runner.run(f"nmcli device set {d} managed yes")
+ # A plain 'connect' makes NM *assume* the initramfs IP config without
+ # doing DHCP, so it never learns DNS. Disconnect first to force a fresh
+ # DHCP lease on reconnect.
+ self.runner.run(f"nmcli device disconnect {d}")
+ self.runner.output(f"nmcli -w 30 device connect {d} 2>&1")
+
+ info = ""
+ for _ in range(20):
+ info = self.runner.output(
+ f"nmcli -t -f IP4.DNS,DHCP4.OPTION device show {d} 2>/dev/null")
+ servers = []
+ for match in re.finditer(
+ r"(?:IP4\.DNS\[\d+\]:|domain_name_servers\s*=\s*)"
+ r"(\d+\.\d+\.\d+\.\d+)", info):
+ ip = match.group(1)
+ if ip not in servers:
+ servers.append(ip)
+ if servers:
+ try:
+ with open(resolv_path, "w", encoding="utf-8") as fd:
+ fd.writelines(f"nameserver {ip}\n" for ip in servers)
+ self.log(" --> Set DNS from NetworkManager: "
+ + ", ".join(servers))
+ return True
+ except OSError as exc:
+ self.log(f"WARNING: could not write {resolv_path}: {exc}")
+ return False
+ if self._resolv_has_nameserver(resolv_path):
+ return True # NM owns resolv.conf after all
+ time.sleep(1)
+ self.log(f"[nm] no DNS learned; last device show:\n{info}")
+ return False
+
+ def _apply_proxy(self, target="/target"):
+ # Configure a system-wide http(s) proxy (corporate desktops). Written
+ # BEFORE _apply_packages so the install's own chroot apt-get already
+ # goes through it, and it persists for the installed system.
+ proxy = self.config.proxy
+ if not proxy:
+ return
+ self.log(" --> Configuring system proxy")
+ aptdir = target + "/etc/apt/apt.conf.d"
+ os.makedirs(aptdir, exist_ok=True)
+ os.makedirs(target + "/etc", exist_ok=True)
+ with open(aptdir + "/00proxy", "w") as f:
+ f.write('Acquire::http::Proxy "%s";\n' % proxy)
+ f.write('Acquire::https::Proxy "%s";\n' % proxy)
+ # /etc/environment for every other TLS client on the installed system.
+ with open(target + "/etc/environment", "a") as f:
+ f.write("\n# Added by live-installer (proxy)\n")
+ for var in ("http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY"):
+ f.write("%s=%s\n" % (var, proxy))
+
+ def _apply_snapshots(self):
+ # Configure Timeshift (snapshots: timeshift-btrfs). The schema's
+ # cross-validation already guaranteed a btrfs @/@home root. Runs after
+ # packages so apt is ready to install timeshift if needed.
+ snaps = self.config.snapshots
+ if snaps is None or not snaps.enabled:
+ return
+ backend = snapshotbackend.get_snapshot_backend(
+ self.config, self.runner, self._policy, self.log)
+ backend.apply(snaps, self.config.storage)
+
+ def _apply_flatpak(self):
+ # Add flatpak remotes and install the apps in the target chroot. The
+ # apps install at install time (run as root with --noninteractive, so
+ # no polkit/session is needed, and the long install timeout absorbs the
+ # download). Runs after the package phase so apt can install flatpak
+ # itself if it is not already present.
+ fp = self.config.flatpak
+ if fp is None:
+ return
+ self.log(" --> Configuring flatpak")
+ rc = self.runner.chroot(
+ "DEBIAN_FRONTEND=noninteractive apt-get install -y flatpak")
+ if rc != 0:
+ self._policy("package_install_failure", "installing flatpak failed")
+ return
+ for remote in fp.remotes:
+ self.runner.chroot("flatpak remote-add --if-not-exists %s %s"
+ % (shlex.quote(remote.name), shlex.quote(remote.url)))
+ for app in fp.install:
+ self.log(" installing flatpak app: %s" % app)
+ rc = self.runner.chroot(
+ "flatpak install --system -y --noninteractive %s"
+ % shlex.quote(app))
+ if rc != 0:
+ self._policy("package_install_failure",
+ f"flatpak install {app} failed")
+
+ def _apply_drivers(self):
+ # autoinstall's drivers: {install: true} — install recommended
+ # proprietary/DKMS drivers via ubuntu-drivers (from
+ # ubuntu-drivers-common, present on Mint through its Driver Manager).
+ # Runs after the package phase so apt and any added repos are ready.
+ # NOTE: under SecureBoot, a freshly-built DKMS module still needs
+ # interactive MOK enrollment at next boot — that wall is universal and
+ # cannot be automated here (it is not specific to this installer).
+ if not self.config.drivers.install:
+ return
+ self.log(" --> Installing recommended third-party drivers")
+ if self.runner.chroot("command -v ubuntu-drivers >/dev/null 2>&1") != 0:
+ self.log("WARNING: ubuntu-drivers not available (e.g. on LMDE); "
+ "skipping. List specific driver packages under 'packages:' "
+ "instead.")
+ return
+ rc = self.runner.chroot(
+ "DEBIAN_FRONTEND=noninteractive ubuntu-drivers install")
+ if rc != 0:
+ self._policy("package_install_failure",
+ "ubuntu-drivers install failed")
+
+ def _apply_ca_certs(self):
+ # Install CA certificates into the system trust store via a swappable
+ # backend (update-ca-certificates today). Run BEFORE _apply_packages so
+ # a corporate-PKI cert is trusted when apt fetches over HTTPS.
+ if self.config.ca_certs is None:
+ return
+ backend = catrust.get_ca_trust_backend(
+ self.runner, self._policy, self.log)
+ backend.apply(self.config.ca_certs)
+
+ def _apply_packages(self):
+ # Repo config (cloud-init's apt: shape) and the agnostic packages/
+ # package_remove lists are applied by a swappable package backend, so
+ # the install step is not apt-hardcoded. Only apt exists today.
+ backend = pkgbackend.get_backend(self.runner, self._policy, self.log)
+ backend.apply(self.config.apt, self.config.packages,
+ self.config.package_remove)
+
+ def _setup_luks_first_boot_rekey(self, setup, target="/target"):
+ """Prepare the first-boot LUKS rekey (passphrase_source:
+ prompt-on-first-boot): embed the throwaway key in the initramfs so the
+ first boot auto-unlocks, and install the oneshot that prompts the
+ operator for the real passphrase. Runs BEFORE the initramfs is rebuilt
+ (_regenerate_initramfs_if_luks) so the keyfile is baked in."""
+ if not getattr(setup, "luks_rekey_on_first_boot", False):
+ return
+ self.log(" --> Configuring first-boot LUKS passphrase prompt")
+
+ # 1. Write the throwaway key to a root-only keyfile, byte-for-byte the
+ # key LUKS was formatted with (no trailing newline).
+ keydir = target + "/etc/cryptsetup-keys.d"
+ os.makedirs(keydir, exist_ok=True)
+ os.chmod(keydir, 0o700)
+ keypath = os.path.join(keydir, "cryptroot.key")
+ with open(keypath, "w") as f:
+ f.write(setup.passphrase1) # NO newline — must equal the LUKS key
+ os.chmod(keypath, 0o600)
+
+ # 2. Have the cryptsetup initramfs hook copy the keyfile in, and lock
+ # down the initramfs (it holds the throwaway key on /boot until the
+ # first boot completes the rekey).
+ hookdir = target + "/etc/cryptsetup-initramfs"
+ os.makedirs(hookdir, exist_ok=True)
+ with open(hookdir + "/conf-hook", "w") as f:
+ f.write('KEYFILE_PATTERN="/etc/cryptsetup-keys.d/*.key"\n')
+ os.makedirs(target + "/etc/initramfs-tools", exist_ok=True)
+ with open(target + "/etc/initramfs-tools/initramfs.conf", "a") as f:
+ f.write("\n# live-installer: protect the embedded first-boot LUKS "
+ "key\nUMASK=0077\n")
+
+ # 3. Install and enable the first-boot rekey oneshot.
+ sbindir = target + "/usr/local/sbin"
+ os.makedirs(sbindir, exist_ok=True)
+ script = sbindir + "/li-luks-rekey"
+ with open(script, "w") as f:
+ f.write(_LUKS_REKEY_SCRIPT)
+ os.chmod(script, 0o755)
+ os.makedirs(target + "/etc/systemd/system", exist_ok=True)
+ with open(target + "/etc/systemd/system/li-luks-rekey.service", "w") as f:
+ f.write(_LUKS_REKEY_SERVICE)
+ rc = self.runner.chroot("systemctl enable li-luks-rekey.service")
+ if rc != 0:
+ self._policy("post_install_script_failure",
+ "enabling the first-boot LUKS rekey service failed")
+
+ def _regenerate_initramfs_if_luks(self):
+ """Rebuild the target initramfs so it can unlock the encrypted root.
+
+ The target is copied from the live squashfs, which carries
+ live-boot's diverted update-initramfs — a wrapper that no-ops
+ unless the live medium is mounted at /run/live/medium *inside the
+ chroot*. The engine bind-mounts /run but not that submount, so its
+ update-initramfs is skipped and the crypttab never reaches the
+ initramfs; the encrypted root then can't be unlocked at boot
+ (the system drops to an initramfs shell). Plain and LVM installs
+ boot from the stock initramfs and are unaffected, so this only
+ matters for lvm-on-luks. Make the medium visible and regenerate.
+ """
+ if self.config.storage.layout != "lvm-on-luks":
+ return
+ self.log(" --> Regenerating initramfs for the encrypted root")
+ # Remove live-boot's initramfs hook: it references a live-only path
+ # (/usr/lib/live/boot) and fails on the installed system, which made
+ # update-initramfs exit non-zero. The installed system is not a live
+ # system, so the hook has no business in its initramfs.
+ self.runner.run(
+ "rm -f /target/usr/share/initramfs-tools/hooks/live")
+ # Resolve the real update-initramfs (live-tools diverts it to a
+ # wrapper that no-ops on live media) and run it directly so the
+ # crypttab is baked into the initramfs.
+ real = self.runner.output(
+ "chroot /target/ /bin/sh -c "
+ "'dpkg-divert --truename /usr/sbin/update-initramfs'"
+ ).strip() or "/usr/sbin/update-initramfs"
+ rc = self.runner.chroot("%s -u -k all" % real)
+ if rc != 0:
+ self._policy("post_install_script_failure",
+ "regenerating the initramfs for LUKS failed")
+
+ def _build_grub_snippet(self):
+ """Build an /etc/default/grub.d snippet for kernel.* settings.
+
+ Written as a grub.d snippet rather than edits to /etc/default/grub
+ because distro snippets in that directory are sourced AFTER the
+ main file and would otherwise clobber our cmdline. Sourced last
+ (zz- name), this snippet sees the final GRUB_CMDLINE_LINUX_DEFAULT
+ and rewrites it, so our settings always win.
+ """
+ kernel = self.config.kernel
+ serial = kernel.serial_console.strip()
+ extra = kernel.cmdline_extra.strip()
+ lines = ["# Added by live-installer (automated installation)"]
+ if serial:
+ device, _, speed = serial.partition(",")
+ speed = speed or "115200"
+ unit = device[len("ttyS"):]
+ # strip quiet/splash from whatever earlier config set, then add
+ # the serial console + disable plymouth so the boot (and the
+ # LUKS unlock prompt) is a plain-text askpass on the serial line
+ lines.append(
+ 'GRUB_CMDLINE_LINUX_DEFAULT="$(echo " ${GRUB_CMDLINE_LINUX_DEFAULT} "'
+ " | sed -e 's/ quiet / /g' -e 's/ splash / /g'"
+ " -e 's/^ *//' -e 's/ *$//')"
+ f' console=tty0 console={device},{speed}n8 plymouth.enable=0"'
+ )
+ lines.append('GRUB_TERMINAL="console serial"')
+ lines.append(
+ f'GRUB_SERIAL_COMMAND="serial --unit={unit} --speed={speed}"'
+ )
+ if extra:
+ lines.append(
+ f'GRUB_CMDLINE_LINUX_DEFAULT="${{GRUB_CMDLINE_LINUX_DEFAULT}} {extra}"'
+ )
+ return "\n".join(lines) + "\n"
+
+ def _apply_kernel_config(self):
+ kernel = self.config.kernel
+ serial = kernel.serial_console.strip()
+ extra = kernel.cmdline_extra.strip()
+ if not serial and not extra:
+ return
+ if serial:
+ self.log(f" --> Provisioning serial console on {serial}")
+ if extra:
+ self.log(f" --> Appending kernel cmdline: {extra}")
+
+ self.runner.run("mkdir -p /target/etc/default/grub.d")
+ with open("/target/etc/default/grub.d/zz-live-installer.cfg", "w") as f:
+ f.write(self._build_grub_snippet())
+ rc = self.runner.chroot("update-grub")
+ if rc != 0:
+ self._policy("post_install_script_failure", "update-grub failed")
+ # Diagnostic: record the cmdline that actually landed in grub.cfg,
+ # so a serial-console/boot issue is traceable from the install log.
+ self.log(" --> grub.cfg kernel line: " + self.runner.output(
+ "grep -m1 'vmlinuz' /target/boot/grub/grub.cfg | sed 's/^[[:space:]]*//'"))
+
+ def _apply_network(self):
+ # Render the netplan-v2-shaped network: section to NetworkManager
+ # keyfiles in the target. NM is what the installed Mint/LMDE system
+ # uses; we write the keyfiles directly rather than depending on the
+ # netplan binary (which LMDE/Debian does not ship). With no network:
+ # section the system keeps its default (NM-managed DHCP).
+ network = self.config.network
+ if network is None:
+ return
+ files = netconfig.render(network)
+ if not files:
+ return
+ conn_dir = "/target/etc/NetworkManager/system-connections"
+ self.runner.run("mkdir -p %s" % conn_dir)
+ for name, content in files.items():
+ path = os.path.join(conn_dir, name)
+ self.log(" --> Writing NetworkManager connection: %s" % name)
+ with open(path, "w") as f:
+ f.write(content)
+ # Keyfiles may hold secrets and NM refuses world-readable ones.
+ os.chmod(path, 0o600)
+
+ def _run_early_commands(self):
+ # early_commands: shell commands run in the LIVE environment BEFORE
+ # partitioning (kickstart %pre / Ubuntu autoinstall early-commands).
+ # For prepping the install environment (stop stale arrays, mount an
+ # out-of-band source, place a cert/keyfile on the medium). It runs in
+ # the live session, not a chroot — /target does not exist yet — and it
+ # CANNOT change the layout (the config is data; use a per-machine answer
+ # file or auto: discovery for that). A command that is a path to a file
+ # on the install media is run from there.
+ for command in self.config.early_commands:
+ self.log(f" --> early_command: {command}")
+ first = command.split()[0] if command.split() else ""
+ if first and os.path.isfile(first):
+ rc = self.runner.run(f"sh {command}")
+ else:
+ rc = self.runner.run(command)
+ if rc != 0:
+ self._policy("early_command_failure",
+ f"early_command failed (rc={rc}): {command}")
+
+ def _run_commands(self):
+ # late_commands: a list of shell commands run in the target chroot at
+ # the end of the install, like Ubuntu autoinstall's late-commands and
+ # kickstart %post. A command that is a path to a file present on the
+ # install media is copied into the target and run, so on-media scripts
+ # work.
+ for command in self.config.late_commands:
+ self.log(f" --> late_command: {command}")
+ first = command.split()[0] if command.split() else ""
+ if first and os.path.isfile(first):
+ self.runner.run(f"cp {shlex.quote(first)} /target/tmp/")
+ target_path = "/tmp/" + os.path.basename(first)
+ rest = command[len(first):]
+ rc = self.runner.chroot(f"sh {target_path}{rest}")
+ self.runner.run(f"rm -f /target{target_path}")
+ else:
+ rc = self.runner.chroot(command)
+ if rc != 0:
+ self._policy("post_install_script_failure",
+ f"late_command failed (rc={rc}): {command}")
+
+ # -- main flow ----------------------------------------------------------
+
+ def run(self, setup=None):
+ """Run the full unattended installation. Returns an exit code."""
+ import installer
+
+ try:
+ # %pre: prep the live environment before anything is resolved or
+ # partitioned (e.g. assemble an out-of-band disk, place a keyfile).
+ self._run_early_commands()
+ if setup is None:
+ setup = build_setup(self.config, insecure=self.insecure)
+ self.log(f" --> Target disk: {setup.disk}")
+ setup.print_setup()
+
+ # Before the engine copies resolv.conf into the target, make sure
+ # the live session can actually resolve names (netboot leaves a
+ # placeholder resolv.conf; see _ensure_dns).
+ self._ensure_dns()
+
+ if self._engine_factory is not None:
+ engine = self._engine_factory(setup, self.runner)
+ else:
+ engine = installer.InstallerEngine(setup, runner=self.runner)
+ engine.set_progress_hook(self.on_progress)
+ engine.set_error_hook(self.on_error)
+
+ engine.start_installation()
+ if self._failed:
+ raise InstallationFailed("engine error during installation")
+
+ needs_post = (
+ len(self.config.users) > 1
+ or any(user.ssh_authorized_keys for user in self.config.users)
+ or self.config.packages or self.config.package_remove
+ or self.config.apt is not None or self.config.late_commands
+ or self.config.kernel.cmdline_extra.strip()
+ or self.config.kernel.serial_console.strip()
+ or self.config.storage.layout == "lvm-on-luks"
+ or self.config.network is not None
+ or self.config.proxy is not None
+ or self.config.ca_certs is not None
+ or self.config.drivers.install
+ or self.config.flatpak is not None
+ or (self.config.snapshots is not None
+ and self.config.snapshots.enabled)
+ )
+
+ def post_install_hook():
+ self.log(" --> Applying post-install configuration")
+ self._create_extra_users()
+ self._apply_ssh_keys()
+ self._apply_proxy()
+ self._apply_ca_certs()
+ self._apply_packages()
+ self._apply_flatpak()
+ self._apply_drivers()
+ self._apply_snapshots()
+ self._apply_network()
+ self._setup_luks_first_boot_rekey(setup)
+ self._regenerate_initramfs_if_luks()
+ self._apply_kernel_config()
+ self._run_commands()
+
+ engine.finish_installation(
+ before_unmount_hook=post_install_hook if needs_post else None
+ )
+ if self._failed:
+ raise InstallationFailed("engine error during finalization")
+ except (schema.ConfigError, diskmatch.DiskMatchError,
+ InstallationFailed) as exc:
+ self.log(f"ERROR: {exc}")
+ self.log(FAILURE_MARKER)
+ return 1
+ except Exception as exc: # never die silently on a target machine
+ self.log(f"ERROR: unexpected failure: {exc!r}")
+ self.log(FAILURE_MARKER)
+ return 1
+
+ self.log(FINAL_MARKER)
+ return 0
+
+
+def format_disk_list(disks):
+ """Render diskmatch.describe_disks() output as copy-pasteable text, so a
+ user can read the stable attributes off a live machine and build a match
+ expression for the answer file."""
+ if not disks:
+ return "No installable disks found."
+ def human_size(n):
+ if n >= 10**12:
+ return f"{n / 10**12:.1f}TB"
+ if n >= 10**9:
+ return f"{n / 10**9:.0f}GB"
+ return f"{n / 10**6:.0f}MB"
+
+ lines = []
+ for d in disks:
+ removable = "yes" if d["removable"] else "no"
+ lines.append(
+ f"{d['path']} {human_size(d['size_bytes'])} "
+ f"model={d['model']!r} removable={removable}"
+ )
+ # by-path is stable per hardware slot (reusable across identical
+ # machines); by-id is unique to this physical drive.
+ if d["by_path"]:
+ lines.append(" by-path (stable per chassis slot, fleet-reusable):")
+ lines.extend(f" {name}" for name in d["by_path"])
+ if d["by_id"]:
+ lines.append(" by-id (unique to this physical drive):")
+ lines.extend(f" {name}" for name in d["by_id"])
+ if not d["by_path"] and not d["by_id"]:
+ lines.append(" (no by-id/by-path links; use model or size-min)")
+ lines.append("")
+ lines.append(
+ "Put one of these under storage.target.match in the answer file, "
+ "e.g.:\n"
+ " match:\n"
+ ' by-id: ""\n'
+ "Run with --check to validate the file once written."
+ )
+ return "\n".join(lines)
+
+
+def acquire_answer_text(source, insecure, log=lambda _m: None):
+ """Resolve an answer-file source to its text.
+
+ Handles the netboot discovery trigger ('auto' or 'auto:'): the
+ machine's identity (MAC/serial/UUID) is read and a list of candidate
+ sources is tried in order until one fetches and validates. A plain
+ path/URL is fetched directly.
+ """
+ is_auto, base = discovery.parse_auto_trigger(source)
+ if not is_auto:
+ return fetch_answer_file(source, insecure)
+ identity = discovery.read_machine_identity(log=log)
+ candidates = discovery.candidate_sources(
+ base, macs=identity["macs"], serial=identity["serial"],
+ uuid=identity["uuid"])
+ try:
+ _resolved, text = discovery.discover(
+ candidates,
+ fetch=lambda s: fetch_answer_file(s, insecure),
+ validate=schema.parse_config,
+ log=log,
+ )
+ except discovery.DiscoveryError as exc:
+ raise schema.ConfigError(str(exc))
+ return text
+
+
+def main(argv=None):
+ parser = argparse.ArgumentParser(
+ prog="live-installer --automated",
+ description="Unattended installation from a YAML answer file",
+ )
+ parser.add_argument(
+ "--config",
+ help="answer file path or URL (file, http(s)://, nfs://, tftp://), or "
+ "'auto' / 'auto:' to discover it from this machine's "
+ "MAC/serial/UUID. Default: live-installer.auto= from the kernel "
+ "command line.",
+ )
+ parser.add_argument(
+ "--insecure", action="store_true",
+ help="allow fetching the answer file over cleartext transports "
+ "(plain HTTP, NFS, TFTP)",
+ )
+ parser.add_argument(
+ "--list-disks", action="store_true",
+ help="print this machine's disks with the stable attributes "
+ "(by-id, by-path, model, size) usable in a match expression, "
+ "then exit. Boot the live medium and run this to learn the "
+ "names to put in an answer file.",
+ )
+ parser.add_argument(
+ "--check", action="store_true",
+ help="validate the answer file's syntax and schema, then exit. "
+ "Touches no disks, so it runs anywhere (CI, a dev laptop).",
+ )
+ parser.add_argument(
+ "--dry-run", action="store_true",
+ help="validate the answer file and resolve the target disk, "
+ "then exit without installing",
+ )
+ args = parser.parse_args(argv)
+
+ if args.list_disks:
+ print(format_disk_list(diskmatch.describe_disks()), flush=True)
+ return 0
+
+ source = args.config or cmdline_source()
+ if not source:
+ parser.error(
+ f"no answer file: pass --config or boot with {CMDLINE_KEY}"
+ )
+ insecure = args.insecure or cmdline_insecure()
+
+ try:
+ text = acquire_answer_text(
+ source, insecure, log=lambda m: print(m, flush=True))
+ config = schema.parse_config(text)
+ except schema.ConfigError as exc:
+ print(f"ERROR: {exc}", flush=True)
+ print(FAILURE_MARKER, flush=True)
+ return 1
+
+ if args.check:
+ print("Answer file OK", flush=True)
+ return 0
+
+ if args.dry_run:
+ try:
+ setup = build_setup(config, insecure=insecure)
+ except (schema.ConfigError, diskmatch.DiskMatchError) as exc:
+ print(f"ERROR: {exc}", flush=True)
+ return 1
+ print(f"Answer file OK; would install to {setup.disk}", flush=True)
+ return 0
+
+ return HeadlessDriver(config, insecure=insecure).run()
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/usr/lib/live-installer/catrust.py b/usr/lib/live-installer/catrust.py
new file mode 100644
index 00000000..d6fa8290
--- /dev/null
+++ b/usr/lib/live-installer/catrust.py
@@ -0,0 +1,66 @@
+#!/usr/bin/python3
+# coding: utf-8
+"""CA trust backends: install CA certificates into the installed system's
+trust store.
+
+Mirrors pkgbackend.py: the driver holds one CaTrustBackend, picked per distro
+family, and calls it through the same CommandRunner chroot boundary — so it is
+unit-testable with a recording runner and imports no certificate library at
+load time. Only the Debian/Mint backend (update-ca-certificates) exists today;
+the seam keeps RHEL-family (update-ca-trust) addable later.
+
+This configures the SYSTEM trust store (/etc/ssl/certs), consulted by apt,
+curl, and TLS clients — deliberately separate from per-connection 802.1X/EAP
+trust, which lives in NetworkManager profiles.
+"""
+
+import os
+
+
+class CaTrustBackend:
+ def __init__(self, runner, policy, log, target="/target"):
+ self.runner = runner
+ self.policy = policy
+ self.log = log
+ self.target = target
+
+ def apply(self, ca_certs):
+ raise NotImplementedError
+
+
+class DebianCaTrustBackend(CaTrustBackend):
+ # Local certs dropped here are picked up by update-ca-certificates and
+ # merged into /etc/ssl/certs. They MUST end in .crt or the tool ignores them.
+ CERT_DIR = "/usr/local/share/ca-certificates"
+
+ def apply(self, ca_certs):
+ host_dir = self.target + self.CERT_DIR
+ os.makedirs(host_dir, exist_ok=True)
+ for index, cert in enumerate(ca_certs.trusted, 1):
+ path = os.path.join(host_dir, "li-ca-%d.crt" % index)
+ self.log(" --> Installing CA certificate: li-ca-%d.crt" % index)
+ with open(path, "w") as f:
+ f.write(cert if cert.endswith("\n") else cert + "\n")
+ os.chmod(path, 0o644)
+ if ca_certs.remove_defaults:
+ # Disable every bundled (mozilla) cert in the conf, so a --fresh
+ # rebuild trusts ONLY the local certs added above. Validation
+ # already guarantees there is at least one.
+ self.log(" --> Removing default CA certificates from the trust store")
+ self.runner.chroot(
+ r"sed -i 's/^\([^#!]\)/!\1/' /etc/ca-certificates.conf")
+ rc = self.runner.chroot("update-ca-certificates --fresh")
+ else:
+ rc = self.runner.chroot("update-ca-certificates")
+ if rc != 0:
+ self.policy("post_install_script_failure",
+ "update-ca-certificates failed")
+
+
+def get_ca_trust_backend(runner, policy, log, *, family="debian",
+ target="/target"):
+ """Pick the CA-trust backend for the target distro family. apt-family only
+ today; the seam is here so update-ca-trust (RHEL) can be added later."""
+ if family == "debian":
+ return DebianCaTrustBackend(runner, policy, log, target=target)
+ raise ValueError(f"no CA-trust backend for distro family {family!r}")
diff --git a/usr/lib/live-installer/commandrunner.py b/usr/lib/live-installer/commandrunner.py
new file mode 100644
index 00000000..49700c98
--- /dev/null
+++ b/usr/lib/live-installer/commandrunner.py
@@ -0,0 +1,110 @@
+#!/usr/bin/python3
+# coding: utf-8
+"""Shell-command execution boundary for the install engine.
+
+All shell-outs from InstallerEngine go through a CommandRunner instance,
+so tests can substitute a recording/faking runner, and so every command
+is logged in one place. Semantics intentionally match what the engine
+historically did with os.system / subprocess.getoutput / subprocess.Popen:
+failures are logged but do not raise unless check=True is passed.
+"""
+
+import shlex
+import subprocess
+
+
+class CommandError(Exception):
+ """A command run with check=True exited non-zero."""
+
+ def __init__(self, command, returncode, output=None):
+ self.command = command
+ self.returncode = returncode
+ self.output = output
+ super().__init__(
+ f"Command failed (rc={returncode}): {command}"
+ )
+
+
+class CommandRunner:
+ """Runs shell commands for the install engine."""
+
+ def __init__(self, log=print):
+ self.log = log
+
+ def run(self, command, check=False, secrets=(), stdin=None):
+ """Run a shell command; return its exit code (os.system replacement).
+
+ Non-zero exit codes are logged. With check=True a non-zero exit
+ raises CommandError instead of being silently tolerated.
+
+ `stdin`, if given, is written to the command's standard input. This
+ is the safe channel for secrets (key material): unlike a command
+ argument it is never visible to `ps` and never reaches the logs.
+
+ Any strings in `secrets` (and their shell-quoted forms) are
+ replaced with [REDACTED] in everything that gets logged — a
+ belt-and-braces guard for cases where a secret must still appear in
+ the command itself. Prefer `stdin` over `secrets` whenever the tool
+ can read the secret from standard input.
+ """
+ loggable = command
+ for secret in secrets:
+ for needle in (shlex.quote(secret), secret):
+ loggable = loggable.replace(needle, "[REDACTED]")
+ self.log("EXEC: %s" % loggable)
+ if stdin is not None:
+ proc = subprocess.run(command, shell=True,
+ input=stdin.encode("utf-8"))
+ returncode = proc.returncode
+ else:
+ returncode = subprocess.call(command, shell=True)
+ if returncode != 0:
+ self.log("EXEC failed (rc=%d): %s" % (returncode, loggable))
+ if check:
+ raise CommandError(loggable, returncode)
+ return returncode
+
+ def output(self, command, check=False):
+ """Return combined stdout+stderr of a shell command, without the
+ trailing newline (subprocess.getoutput replacement)."""
+ result = subprocess.run(
+ command,
+ shell=True,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ encoding="utf-8",
+ errors="ignore",
+ )
+ if result.returncode != 0:
+ self.log("EXEC failed (rc=%d): %s" % (result.returncode, command))
+ if check:
+ raise CommandError(command, result.returncode, result.stdout)
+ data = result.stdout or ""
+ if data.endswith("\n"):
+ data = data[:-1]
+ return data
+
+ def chroot(self, command, check=False, target="/target"):
+ """Run a command inside the target chroot."""
+ command = command.replace('"', "'").strip()
+ return self.run(
+ 'chroot %s/ /bin/sh -c "%s"' % (target.rstrip("/"), command),
+ check=check,
+ )
+
+ def popen(self, command):
+ """Start a shell command with captured, line-readable output.
+
+ For callers that stream output as it is produced (rsync progress,
+ exec_cmd). stderr is folded into stdout, matching the engine's
+ historical Popen usage.
+ """
+ self.log("EXEC (stream): %s" % command)
+ return subprocess.Popen(
+ command,
+ shell=True,
+ encoding="utf-8",
+ errors="ignore",
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ )
diff --git a/usr/lib/live-installer/discovery.py b/usr/lib/live-installer/discovery.py
new file mode 100644
index 00000000..669feae1
--- /dev/null
+++ b/usr/lib/live-installer/discovery.py
@@ -0,0 +1,159 @@
+#!/usr/bin/python3
+# coding: utf-8
+"""Answer-file auto-discovery for netboot/PXE.
+
+Templating one PXE entry per machine does not scale. Instead a single
+boot entry can point the installer at a *base* and let it find its own
+answer file from the machine's identity:
+
+ live-installer.auto=auto:https://cfg.example.com/configs/
+
+The installer then tries, in order of specificity:
+
+ /by-mac/.yaml (one per NIC, in interface order)
+ /by-serial/.yaml
+ /by-uuid/.yaml
+ /default.yaml (fleet-wide fallback)
+
+and finally the well-known local-media paths, so a USB/remastered-ISO
+boot (live-installer.auto=auto, no base) still works offline. The first
+candidate that both fetches and validates wins; everything else is
+reported so a miss is debuggable.
+
+Identity is read from sysfs/DMI, all paths parameterized so the logic is
+unit-testable against a fake tree. Reading DMI needs root, which the live
+session has.
+"""
+
+import os
+
+AUTO = "auto"
+AUTO_PREFIX = "auto:"
+
+
+class DiscoveryError(Exception):
+ """No candidate source yielded a valid answer file."""
+
+DEFAULT_MEDIA_DIRS = ("/cdrom", "/run/live/medium")
+WELL_KNOWN_NAME = "auto-install.yaml"
+
+# Manufacturers ship machines with placeholder DMI values. A by-uuid/ or
+# by-serial/ answer file built from one of these would match every defective
+# unit in a fleet, so discovery never keys on them. Compared case-folded.
+_SENTINEL_UUIDS = frozenset({
+ "00000000-0000-0000-0000-000000000000",
+ "ffffffff-ffff-ffff-ffff-ffffffffffff",
+ "03000200-0400-0500-0006-000700080009", # a known QEMU/SMBIOS default
+})
+_SENTINEL_SERIALS = frozenset({
+ "system serial number", "default string", "to be filled by o.e.m.",
+ "not specified", "not applicable", "none", "0", "123456789",
+})
+
+
+def parse_auto_trigger(source):
+ """Return (is_discovery, base_or_None) for an answer-file source.
+
+ 'auto' -> (True, None) local media only
+ 'auto:https://host/x/' -> (True, base) base + local media
+ anything else -> (False, None)
+ """
+ if source == AUTO:
+ return True, None
+ if source.startswith(AUTO_PREFIX):
+ base = source[len(AUTO_PREFIX):].strip()
+ return True, (base or None)
+ return False, None
+
+
+def _read(path):
+ try:
+ return open(path, encoding="utf-8").read().strip()
+ except OSError:
+ return ""
+
+
+def read_machine_identity(net_dir="/sys/class/net", dmi_dir="/sys/class/dmi/id",
+ log=lambda _m: None):
+ """Collect the stable identifiers a per-machine answer file is keyed on:
+ wired ethernet MAC(s), SMBIOS serial, SMBIOS UUID. Sentinel/placeholder
+ DMI values are dropped (and logged) so discovery never keys on garbage."""
+ macs = []
+ try:
+ names = sorted(os.listdir(net_dir))
+ except OSError:
+ names = []
+ for name in names:
+ if name == "lo":
+ continue
+ # ARPHRD_ETHER (type 1) excludes loopback/bridges/tunnels but NOT wifi
+ # (wifi is also type 1), so filter wifi explicitly: a wireless NIC has
+ # a wireless/ subdir. by-mac discovery is wired-only — a laptop's wifi
+ # MAC is a poor fleet key (often down in the installer, may randomise).
+ if _read(os.path.join(net_dir, name, "type")) != "1":
+ continue
+ if os.path.isdir(os.path.join(net_dir, name, "wireless")):
+ continue
+ addr = _read(os.path.join(net_dir, name, "address")).lower()
+ if addr and addr != "00:00:00:00:00:00" and addr not in macs:
+ macs.append(addr)
+ serial = _read(os.path.join(dmi_dir, "product_serial")) or None
+ if serial and serial.strip().lower() in _SENTINEL_SERIALS:
+ log(f"auto-discovery: ignoring placeholder product_serial {serial!r}")
+ serial = None
+ uuid = _read(os.path.join(dmi_dir, "product_uuid")) or None
+ if uuid and uuid.strip().lower() in _SENTINEL_UUIDS:
+ log(f"auto-discovery: ignoring placeholder product_uuid {uuid!r}")
+ uuid = None
+ return {"macs": macs, "serial": serial, "uuid": uuid}
+
+
+def candidate_sources(base=None, *, macs=(), serial=None, uuid=None,
+ media_dirs=DEFAULT_MEDIA_DIRS,
+ well_known_name=WELL_KNOWN_NAME):
+ """Ordered list of answer-file sources to try, most specific first."""
+ candidates = []
+ if base:
+ root = base.rstrip("/")
+ for mac in macs:
+ candidates.append(f"{root}/by-mac/{mac}.yaml")
+ if serial:
+ candidates.append(f"{root}/by-serial/{serial}.yaml")
+ if uuid:
+ candidates.append(f"{root}/by-uuid/{uuid}.yaml")
+ candidates.append(f"{root}/default.yaml")
+ for directory in media_dirs:
+ candidates.append(f"{directory.rstrip('/')}/{well_known_name}")
+ return candidates
+
+
+def discover(candidates, *, fetch, validate, log=lambda _m: None):
+ """Try each candidate in order; return (source, text) for the first that
+ both fetches and validates. Raises the supplied error type via fetch/
+ validate semantics if none work.
+
+ fetch(source) -> text, or raises (treated as "not present")
+ validate(text) -> parses, or raises (treated as "present but invalid")
+ """
+ tried = []
+ for source in candidates:
+ try:
+ text = fetch(source)
+ except Exception as exc: # noqa: BLE001 - fetch defines its own error
+ tried.append(f"{source} (not found: {exc})")
+ continue
+ try:
+ validate(text)
+ except Exception as exc: # noqa: BLE001 - validate defines its own error
+ tried.append(f"{source} (invalid: {exc})")
+ continue
+ log(f"auto-discovery: using {source}")
+ return source, text
+ if tried:
+ raise DiscoveryError(
+ "auto-discovery found no valid answer file. Tried:\n "
+ + "\n ".join(tried)
+ )
+ raise DiscoveryError(
+ "auto-discovery had nothing to try (no base URL, no local media)."
+ )
diff --git a/usr/lib/live-installer/diskmatch.py b/usr/lib/live-installer/diskmatch.py
new file mode 100644
index 00000000..50d4df17
--- /dev/null
+++ b/usr/lib/live-installer/diskmatch.py
@@ -0,0 +1,201 @@
+#!/usr/bin/python3
+# coding: utf-8
+"""Stable-attribute disk resolution for unattended installs.
+
+The answer file selects the target disk with match expressions (by-id,
+by-path, model, size-min, first-non-removable) instead of raw /dev
+paths, because kernel device enumeration order is not stable. This
+module turns a match expression into exactly one whole-disk device
+path, or fails loudly. Ambiguity is an error, never a guess.
+
+All filesystem locations are parameterized so the resolver can be unit
+tested against a fake /sys/block and /dev/disk tree.
+"""
+
+import fnmatch
+import os
+import re
+from pathlib import Path
+
+_SIZE_RE = re.compile(r"^(\d+(?:\.\d+)?)\s*([MGT]B)$")
+_SIZE_FACTOR = {"MB": 10**6, "GB": 10**9, "TB": 10**12}
+
+# virtual/optical devices that are never installation targets
+_EXCLUDED_PREFIXES = ("loop", "ram", "zram", "sr", "fd", "dm-", "md")
+
+
+class DiskMatchError(Exception):
+ """No disk (or more than one disk) matched the expression."""
+
+
+def parse_size(text):
+ """'500GB' -> 500_000_000_000 (decimal units, matching disk marketing)."""
+ match = _SIZE_RE.match(text.strip())
+ if not match:
+ raise DiskMatchError(f"unparseable size {text!r} (expected e.g. 500GB)")
+ return int(float(match.group(1)) * _SIZE_FACTOR[match.group(2)])
+
+
+class DiskInfo:
+ def __init__(self, name, size_bytes, removable, model):
+ self.name = name # e.g. "sda", "nvme0n1"
+ self.path = "/dev/" + name
+ self.size_bytes = size_bytes
+ self.removable = removable
+ self.model = model
+
+ def __repr__(self):
+ return (f"{self.path} (model={self.model!r}, "
+ f"size={self.size_bytes // 10**9}GB, "
+ f"removable={self.removable})")
+
+
+def _read(path, default=""):
+ try:
+ return Path(path).read_text().strip()
+ except OSError:
+ return default
+
+
+def list_disks(sys_block="/sys/block"):
+ """Enumerate whole-disk block devices, in stable (sorted) order."""
+ disks = []
+ try:
+ names = sorted(os.listdir(sys_block))
+ except OSError:
+ return []
+ for name in names:
+ if name.startswith(_EXCLUDED_PREFIXES):
+ continue
+ entry = os.path.join(sys_block, name)
+ size_sectors = _read(os.path.join(entry, "size"), "0")
+ disks.append(DiskInfo(
+ name=name,
+ size_bytes=int(size_sectors or 0) * 512,
+ removable=_read(os.path.join(entry, "removable"), "0") == "1",
+ model=_read(os.path.join(entry, "device", "model")),
+ ))
+ return disks
+
+
+def _links_for(disk_names, link_dir):
+ """Map each whole-disk device name to the symlink names under link_dir
+ that point at it (partitions and other devices excluded)."""
+ result = {name: [] for name in disk_names}
+ try:
+ entries = sorted(os.listdir(link_dir))
+ except OSError:
+ return result
+ for entry in entries:
+ name = os.path.basename(os.path.realpath(os.path.join(link_dir, entry)))
+ if name in result:
+ result[name].append(entry)
+ return result
+
+
+def describe_disks(
+ *,
+ sys_block="/sys/block",
+ by_id_dir="/dev/disk/by-id",
+ by_path_dir="/dev/disk/by-path",
+):
+ """Enumerate installable disks with every stable attribute a match
+ expression can use (the data behind --list-disks). Returns a list of
+ dicts, in the same stable order as list_disks."""
+ disks = list_disks(sys_block)
+ names = {d.name for d in disks}
+ by_id = _links_for(names, by_id_dir)
+ by_path = _links_for(names, by_path_dir)
+ return [
+ {
+ "path": d.path,
+ "model": d.model,
+ "size_bytes": d.size_bytes,
+ "removable": d.removable,
+ "by_id": by_id.get(d.name, []),
+ "by_path": by_path.get(d.name, []),
+ }
+ for d in disks
+ ]
+
+
+def _match_symlink_dir(pattern, link_dir, disk_names):
+ """Resolve a glob over /dev/disk/by-id (or by-path) symlink names to
+ the set of whole-disk device names they point at."""
+ matched = set()
+ try:
+ entries = sorted(os.listdir(link_dir))
+ except OSError:
+ entries = []
+ for entry in entries:
+ if not fnmatch.fnmatch(entry, pattern):
+ continue
+ target = os.path.realpath(os.path.join(link_dir, entry))
+ name = os.path.basename(target)
+ if name in disk_names: # whole disks only, never partitions
+ matched.add(name)
+ return matched
+
+
+def resolve_disk(
+ match,
+ *,
+ sys_block="/sys/block",
+ by_id_dir="/dev/disk/by-id",
+ by_path_dir="/dev/disk/by-path",
+):
+ """Resolve a DiskMatch (schema object or equivalent) to one device path.
+
+ All present matchers must agree (logical AND). Raises DiskMatchError
+ if no disk or more than one disk satisfies the expression.
+ """
+ disks = list_disks(sys_block)
+ if not disks:
+ raise DiskMatchError("no block devices found")
+ by_name = {disk.name: disk for disk in disks}
+ candidates = set(by_name)
+
+ tried = []
+ if getattr(match, "by_id", None):
+ tried.append(f"by-id={match.by_id!r}")
+ candidates &= _match_symlink_dir(match.by_id, by_id_dir, set(by_name))
+ if getattr(match, "by_path", None):
+ tried.append(f"by-path={match.by_path!r}")
+ candidates &= _match_symlink_dir(match.by_path, by_path_dir, set(by_name))
+ if getattr(match, "model", None):
+ tried.append(f"model={match.model!r}")
+ candidates = {
+ name for name in candidates
+ if fnmatch.fnmatch(by_name[name].model, match.model)
+ }
+ if getattr(match, "size_min", None):
+ tried.append(f"size-min={match.size_min!r}")
+ minimum = parse_size(match.size_min)
+ candidates = {
+ name for name in candidates
+ if by_name[name].size_bytes >= minimum
+ }
+ if getattr(match, "first_non_removable", False):
+ tried.append("first-non-removable")
+ # A constraint, not a tiebreaker: keep only the non-removable disks
+ # and let the ambiguity check below reject the multi-internal-disk
+ # case. Silently picking the "first" of several would be exactly the
+ # enumeration-order guess this module exists to refuse.
+ candidates = {
+ name for name in candidates if not by_name[name].removable
+ }
+
+ if not candidates:
+ raise DiskMatchError(
+ "no disk matches [%s]; available disks:\n %s"
+ % (", ".join(tried),
+ "\n ".join(repr(d) for d in disks))
+ )
+ if len(candidates) > 1:
+ raise DiskMatchError(
+ "ambiguous match [%s] — %d disks qualify:\n %s\n"
+ "Refusing to guess; add more specific matchers."
+ % (", ".join(tried), len(candidates),
+ "\n ".join(repr(by_name[n]) for n in sorted(candidates)))
+ )
+ return by_name[candidates.pop()].path
diff --git a/usr/lib/live-installer/installer.py b/usr/lib/live-installer/installer.py
index f90a8a49..a3d75602 100644
--- a/usr/lib/live-installer/installer.py
+++ b/usr/lib/live-installer/installer.py
@@ -1,11 +1,12 @@
from glob import glob
import os
-import subprocess
+import re
import time
import gettext
import parted
import partitioning
import shlex
+from commandrunner import CommandRunner
from functools import cmp_to_key
gettext.install("live-installer", "/usr/share/locale")
@@ -13,17 +14,30 @@
class InstallerEngine:
''' This is central to the live installer '''
- def __init__(self, setup):
+ def __init__(self, setup, runner=None):
self.setup = setup
+ self.runner = runner or CommandRunner()
if self.setup.is_mint:
self.casper = "/cdrom/casper"
self.pool = "/cdrom/pool"
+ self.live_files = self.casper
self.manifest = "/cdrom/casper/filesystem.manifest"
self.grub_adjustment_script = "/usr/share/ubuntu-system-adjustments/systemd/adjust-grub-title"
else:
self.casper = "/run/live/medium/live"
self.pool = "/run/live/medium/pool"
- self.manifest = "/run/live/medium/live/filesystem.packages"
+ # The kernel, initrd, and package manifests normally sit next to
+ # the squashfs on the medium. A PXE/netboot image can't: live-boot
+ # fetch= delivers only the squashfs, so a netboot image carries
+ # those files in the rootfs instead, and we read them from there.
+ self.live_files = self.casper
+ _bundled = "/usr/lib/live-installer/netboot-live"
+ if not os.path.exists(f"{self.casper}/vmlinuz") and os.path.isdir(_bundled):
+ self.live_files = _bundled
+ # UEFI installs pull the signed bootloader .debs from the pool,
+ # which the netboot image carries in the bundle too.
+ self.pool = f"{_bundled}/pool"
+ self.manifest = f"{self.live_files}/filesystem.packages"
self.grub_adjustment_script = "/usr/share/debian-system-adjustments/systemd/adjust-grub-title"
def set_progress_hook(self, progresshook):
@@ -52,12 +66,15 @@ def setup_user(self):
fp = open("/target/dev/shm/.passwd", "w")
fp.write(self.setup.username + ":" + self.setup.password1 + "\n")
fp.close()
- self.do_run_in_chroot("cat /dev/shm/.passwd | chpasswd")
- os.system("rm -f /target/dev/shm/.passwd")
+ # password_is_crypted: password1 is a crypt(5) hash (unattended
+ # installs never carry plaintext); -e tells chpasswd it's pre-hashed
+ chpasswd = "chpasswd -e" if self.setup.password_is_crypted else "chpasswd"
+ self.do_run_in_chroot("cat /dev/shm/.passwd | %s" % chpasswd)
+ self.runner.run("rm -f /target/dev/shm/.passwd")
# Set autologin
if self.setup.oem_mode:
- os.system("cp /usr/share/live-installer/lightdm-oem-config.conf /target/etc/lightdm/lightdm.conf.d/90-oem-config.conf")
+ self.runner.run("cp /usr/share/live-installer/lightdm-oem-config.conf /target/etc/lightdm/lightdm.conf.d/90-oem-config.conf")
elif self.setup.autologin:
self.do_run_in_chroot(r"sed -i -r 's/^#?(autologin-user)\s*=.*/\1={user}/' /etc/lightdm/lightdm.conf".format(user=self.setup.username))
@@ -89,39 +106,44 @@ def setup_hostname(self):
def setup_locale(self):
print(" --> Setting the locale")
- os.system("echo \"%s.UTF-8 UTF-8\" >> /target/etc/locale.gen" % self.setup.language)
+ self.runner.run("echo \"%s.UTF-8 UTF-8\" >> /target/etc/locale.gen" % self.setup.language)
+ # Generate any supplementary locales too (e.g. fr_CA alongside en_CA),
+ # so they are available even though LANG stays the primary.
+ for locale in getattr(self.setup, "additional_locales", None) or []:
+ if locale != self.setup.language:
+ self.runner.run("echo \"%s.UTF-8 UTF-8\" >> /target/etc/locale.gen" % locale)
self.do_run_in_chroot("locale-gen")
- os.system("echo \"\" > /target/etc/default/locale")
+ self.runner.run("echo \"\" > /target/etc/default/locale")
self.do_run_in_chroot("update-locale LANG=\"%s.UTF-8\"" % self.setup.language)
self.do_run_in_chroot("update-locale LANG=%s.UTF-8" % self.setup.language)
def setup_timezone(self):
print(" --> Setting the timezone")
- os.system("echo \"%s\" > /target/etc/timezone" % self.setup.timezone)
- os.system("rm -f /target/etc/localtime")
- os.system("ln -s /usr/share/zoneinfo/%s /target/etc/localtime" % self.setup.timezone)
+ self.runner.run("echo \"%s\" > /target/etc/timezone" % self.setup.timezone)
+ self.runner.run("rm -f /target/etc/localtime")
+ self.runner.run("ln -s /usr/share/zoneinfo/%s /target/etc/localtime" % self.setup.timezone)
def setup_localization(self):
print(" --> Localizing packages")
if self.setup.oem_mode:
# Copy the l10n pkgs from the ISO to the target so oem-config can install from it later on.
- os.system("mkdir -p /target/oem/l10n_pkgs")
- l10ns = subprocess.getoutput(f"find {self.pool} | grep 'l10n-\\|hunspell-'")
+ self.runner.run("mkdir -p /target/oem/l10n_pkgs")
+ l10ns = self.runner.output(f"find {self.pool} | grep 'l10n-\\|hunspell-'")
for l10n in l10ns.split("\n"):
- os.system(f"cp {l10n} /target/oem/l10n_pkgs/")
+ self.runner.run(f"cp {l10n} /target/oem/l10n_pkgs/")
elif self.setup.language != "en_US":
- os.system("mkdir -p /target/debs")
+ self.runner.run("mkdir -p /target/debs")
language_code = self.setup.language
if "_" in self.setup.language:
language_code = self.setup.language.split("_")[0]
if self.setup.oem_config:
- l10ns = subprocess.getoutput("find /oem/l10n_pkgs | grep 'l10n-%s\\|hunspell-%s'" % (language_code, language_code))
+ l10ns = self.runner.output("find /oem/l10n_pkgs | grep 'l10n-%s\\|hunspell-%s'" % (language_code, language_code))
else:
- l10ns = subprocess.getoutput(f"find {self.pool} | grep 'l10n-%s\\|hunspell-%s'" % (language_code, language_code))
+ l10ns = self.runner.output(f"find {self.pool} | grep 'l10n-%s\\|hunspell-%s'" % (language_code, language_code))
for l10n in l10ns.split("\n"):
- os.system("cp %s /target/debs/" % l10n)
+ self.runner.run("cp %s /target/debs/" % l10n)
self.do_run_in_chroot("dpkg -i /debs/*")
- os.system("rm -rf /target/debs")
+ self.runner.run("rm -rf /target/debs")
def setup_keyboard(self):
print(" --> Setting the keyboard")
@@ -153,7 +175,10 @@ def setup_keyboard(self):
elif(line.startswith("XKBVARIANT=") and self.setup.keyboard_variant is not None and self.setup.keyboard_variant != ""):
newconsolefh.write("XKBVARIANT=\"%s\"\n" % self.setup.keyboard_variant)
elif(line.startswith("XKBOPTIONS=")):
- newconsolefh.write("XKBOPTIONS=grp:win_space_toggle")
+ # A multi-layout setup needs a switch option; honour an explicit
+ # keyboard.toggle, else keep the historical default.
+ options = self.setup.keyboard_options or "grp:win_space_toggle"
+ newconsolefh.write("XKBOPTIONS=\"%s\"\n" % options)
else:
newconsolefh.write("%s\n" % line)
consolefh.close()
@@ -163,7 +188,7 @@ def setup_keyboard(self):
def clean_apt(self):
print(" --> Cleaning APT")
- os.system("chroot /target/ /bin/sh -c \"dpkg --configure -a\"")
+ self.runner.run("chroot /target/ /bin/sh -c \"dpkg --configure -a\"")
self.do_run_in_chroot("sed -i 's/^deb cdrom/#deb cdrom/' /etc/apt/sources.list")
self.do_run_in_chroot("apt-get -y --force-yes autoremove")
@@ -171,7 +196,7 @@ def perform_oem_config(self):
print(" --> Performing OEM config")
# Setup a /target -> / link, this is so we can reuse code used for the live installation.
- os.system("ln -s / /target")
+ self.runner.run("ln -s / /target")
self.update_progress(10, False, False, _("Adding new user to the system"))
self.setup_user()
@@ -197,12 +222,12 @@ def perform_oem_config(self):
# OEM Config cleanup
print(" --> Cleaning up OEM config")
self.update_progress(80, True, False, _("Cleaning OEM configuration"))
- os.system("rm -f /etc/lightdm/lightdm.conf.d/90-oem-config.conf")
- os.system("rm -rf /root/.config/gtk-3.0")
- os.system("apt-get remove --purge --yes --force-yes `cat /oem/live-packages.list`")
- os.system("rm -f /oem/live-packages.list")
- os.system("rm -f /target")
- os.system("touch /oem/done.flag") # Leave a flag for mintsystem to clean up the OEM user account
+ self.runner.run("rm -f /etc/lightdm/lightdm.conf.d/90-oem-config.conf")
+ self.runner.run("rm -rf /root/.config/gtk-3.0")
+ self.runner.run("apt-get remove --purge --yes --force-yes `cat /oem/live-packages.list`")
+ self.runner.run("rm -f /oem/live-packages.list")
+ self.runner.run("rm -f /target")
+ self.runner.run("touch /oem/done.flag") # Leave a flag for mintsystem to clean up the OEM user account
self.update_progress(100, False, True, _("Installation finished"))
print(" --> All done")
@@ -220,13 +245,13 @@ def start_installation(self):
if(not os.path.exists("/source")):
os.mkdir("/source")
- os.system("umount --force /target/sys/firmware/efi/efivars")
- os.system("umount --force /target/dev/shm")
- os.system("umount --force /target/dev/pts")
- os.system("umount --force /target/dev/")
- os.system("umount --force /target/sys/")
- os.system("umount --force /target/proc/")
- os.system("umount --force /target/run/")
+ self.runner.run("umount --force /target/sys/firmware/efi/efivars")
+ self.runner.run("umount --force /target/dev/shm")
+ self.runner.run("umount --force /target/dev/pts")
+ self.runner.run("umount --force /target/dev/")
+ self.runner.run("umount --force /target/sys/")
+ self.runner.run("umount --force /target/proc/")
+ self.runner.run("umount --force /target/run/")
# Mount the installation media
print(" --> Mounting partitions")
@@ -271,13 +296,12 @@ def rootiest_first(parta, partb):
EXCLUDE_DIRS = "home/* dev/* proc/* sys/* tmp/* run/* mnt/* media/* lost+found source target".split()
num_copied = 0
# (Valid) assumption: num-of-files-to-copy ~= num-of-used-inodes-on-/
- num_files = int(subprocess.getoutput("df --inodes /{src} | awk 'END{{ print $3 }}'".format(src=SOURCE.strip('/'))))
+ num_files = int(self.runner.output("df --inodes /{src} | awk 'END{{ print $3 }}'".format(src=SOURCE.strip('/'))))
print(f" --> Copying {num_files} files")
rsync_filter = ' '.join('--exclude=' + SOURCE + d for d in EXCLUDE_DIRS)
- rsync = subprocess.Popen("rsync --verbose --archive --no-D --acls "
- "--hard-links --xattrs {rsync_filter} "
- "{src}* {dst}".format(src=SOURCE, dst=DEST, rsync_filter=rsync_filter),
- shell=True, encoding='utf-8', errors='ignore', stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
+ rsync = self.runner.popen("rsync --verbose --archive --no-D --acls "
+ "--hard-links --xattrs {rsync_filter} "
+ "{src}* {dst}".format(src=SOURCE, dst=DEST, rsync_filter=rsync_filter))
while rsync.poll() is None:
line = rsync.stdout.readline()
if not line: # still copying the previous file, just wait
@@ -291,25 +315,25 @@ def rootiest_first(parta, partb):
# chroot
print(" --> Chrooting")
self.update_progress(10, False, False, _("Entering the system..."))
- os.system("mount --bind /dev/ /target/dev/")
- os.system("mount --bind /dev/shm /target/dev/shm")
- os.system("mount --bind /dev/pts /target/dev/pts")
- os.system("mount --bind /sys/ /target/sys/")
- os.system("mount --bind /proc/ /target/proc/")
- os.system("mount --bind /run/ /target/run/")
- os.system("mv /target/etc/resolv.conf /target/etc/resolv.conf.bk")
- os.system("cp -f /etc/resolv.conf /target/etc/resolv.conf")
+ self.runner.run("mount --bind /dev/ /target/dev/")
+ self.runner.run("mount --bind /dev/shm /target/dev/shm")
+ self.runner.run("mount --bind /dev/pts /target/dev/pts")
+ self.runner.run("mount --bind /sys/ /target/sys/")
+ self.runner.run("mount --bind /proc/ /target/proc/")
+ self.runner.run("mount --bind /run/ /target/run/")
+ self.runner.run("mv /target/etc/resolv.conf /target/etc/resolv.conf.bk")
+ self.runner.run("cp -f /etc/resolv.conf /target/etc/resolv.conf")
if os.path.exists("/sys/firmware/efi/efivars"):
- os.system("mkdir -p /target/sys/firmware/efi/efivars")
- os.system("mount --bind /sys/firmware/efi/efivars /target/sys/firmware/efi/efivars/")
+ self.runner.run("mkdir -p /target/sys/firmware/efi/efivars")
+ self.runner.run("mount --bind /sys/firmware/efi/efivars /target/sys/firmware/efi/efivars/")
- kernelversion= subprocess.getoutput("uname -r")
- os.system(f"cp {self.casper}/vmlinuz /target/boot/vmlinuz-{kernelversion}")
+ kernelversion= self.runner.output("uname -r")
+ self.runner.run(f"cp {self.live_files}/vmlinuz /target/boot/vmlinuz-{kernelversion}")
found_initrd = False
- for initrd in [f"{self.casper}/initrd.img", f"{self.casper}/initrd.lz"]:
+ for initrd in [f"{self.live_files}/initrd.img", f"{self.live_files}/initrd.lz"]:
if os.path.exists(initrd):
- os.system("cp %s /target/boot/initrd.img-%s" % (initrd, kernelversion))
+ self.runner.run("cp %s /target/boot/initrd.img-%s" % (initrd, kernelversion))
found_initrd = True
break
@@ -318,7 +342,7 @@ def rootiest_first(parta, partb):
if self.setup.grub_device and self.setup.gptonefi:
print(" --> Installing signed boot loader")
- os.system("mkdir -p /target/debs")
+ self.runner.run("mkdir -p /target/debs")
EFI_PACKAGES = [
"grub-efi-amd64",
"grub-efi-amd64-bin",
@@ -330,25 +354,33 @@ def rootiest_first(parta, partb):
matches = glob(f"{self.pool}/main/**/{pkg}_*.deb", recursive=True)
if not matches:
raise Exception(f"Missing EFI package: {pkg}")
- os.system(f"cp {max(matches)} /target/debs/")
+ self.runner.run(f"cp {max(matches)} /target/debs/")
self.do_run_in_chroot("DEBIAN_FRONTEND=noninteractive apt-get remove --purge --yes grub-pc grub-pc-bin grub-gfxpayload-lists")
self.do_run_in_chroot("dpkg -i /debs/*")
- os.system("rm -rf /target/debs")
+ self.runner.run("rm -rf /target/debs")
# remove the MOK key generated by shim-signed. We want debconf to trigger the enrollment
# of a new one when the user installs the first proprietary driver package that needs it
- os.system("rm -rf /target/var/lib/shim-signed/mok/*")
+ self.runner.run("rm -rf /target/var/lib/shim-signed/mok/*")
# Prepare directory for oem_config
if self.setup.oem_mode:
- os.system("mkdir -p /target/oem/")
+ self.runner.run("mkdir -p /target/oem/")
# remove live-packages (or w/e)
print(" --> Removing live packages")
self.update_progress(20, False, False, _("Removing live configuration (packages)"))
- with open(f"{self.manifest}-remove", "r") as fd:
- line = fd.read().replace('\n', ' ')
- if self.setup.oem_mode:
+ remove_manifest = f"{self.manifest}-remove"
+ if not os.path.exists(remove_manifest):
+ print(f"WARNING: no live-package manifest at {remove_manifest}; "
+ "skipping live-package removal")
+ line = ""
+ else:
+ with open(remove_manifest, "r") as fd:
+ line = fd.read().replace('\n', ' ')
+ if not line.strip():
+ pass # nothing to remove
+ elif self.setup.oem_mode:
# in OEM mode don't remove pkgs just yet
self.do_run_in_chroot(f'echo "{line}" > /oem/live-packages.list')
self.do_run_in_chroot("rm -f /usr/share/applications/debian-installer-launcher.desktop")
@@ -379,7 +411,216 @@ def rootiest_first(parta, partb):
self.write_mtab()
self.write_crypttab()
+ # -- custom partition layouts (automated) -------------------------------
+
+ @staticmethod
+ def _size_to_mb(size):
+ """'512MB' / '40GB' / '1.5TB' -> integer MB (decimal units, matching
+ parted)."""
+ num = float(re.match(r"^(\d+(?:\.\d+)?)", size.strip()).group(1))
+ factor = {"MB": 1, "GB": 1000, "TB": 1000000}[size.strip()[-2:]]
+ return int(round(num * factor))
+
+ def _wait_for_node(self, path):
+ # Each parted call triggers a partition-table rescan; udev briefly
+ # removes and recreates the device node. Wait or mkfs races it.
+ for _ in range(6):
+ os.system("udevadm settle 2>/dev/null")
+ if os.path.exists(path):
+ return
+ os.system("sync")
+ time.sleep(1)
+ raise Exception(_("The partition %s could not be created. The "
+ "installation will stop. Restart the computer and "
+ "try again.") % path)
+
+ def _format_device(self, fs, device):
+ if fs == "swap":
+ cmd = "mkswap %s" % device
+ elif fs in ("ext2", "ext3", "ext4"):
+ cmd = "mkfs.%s -F %s" % (fs, device)
+ elif fs in ("btrfs", "xfs"):
+ cmd = "mkfs.%s -f %s" % (fs, device)
+ elif fs == "vfat":
+ cmd = "mkfs.vfat %s -F 32" % device
+ elif fs == "f2fs":
+ cmd = "mkfs.f2fs -f %s" % device
+ else:
+ cmd = "mkfs.%s %s" % (fs, device)
+ if self.runner.run(cmd) != 0:
+ raise Exception(_("The partition %s could not be formatted. The "
+ "installation will stop. Restart the computer "
+ "and try again.") % device)
+
+ def _check_layout_matches_firmware(self, parts):
+ """Reject flags that don't match the detected firmware mode. The schema
+ can't catch this — firmware mode is only known at runtime — so it is
+ enforced here, before any partition is created. An esp partition on a
+ BIOS machine never gets used; a bios_grub partition on a UEFI machine
+ wastes space and can leave the install unbootable. Fail loudly instead.
+ """
+ uefi = bool(self.setup.gptonefi)
+ for part in parts:
+ flags = part.get("flags", [])
+ if uefi and "bios_grub" in flags:
+ raise Exception(_(
+ "This is a UEFI system, but the partition layout declares a "
+ "bios_grub partition (only used for BIOS/GPT boot). Use an "
+ "esp partition mounted at /boot/efi instead."))
+ if not uefi and "esp" in flags:
+ raise Exception(_(
+ "This is a BIOS system, but the partition layout declares an "
+ "esp (EFI System Partition). Use a bios_grub partition "
+ "instead, or install in UEFI mode."))
+
+ def _create_custom_partitions(self):
+ """Automated install of an explicit partition layout (layout: custom):
+ a list of partitions (some of which may be LVM PVs or RAID members)
+ plus md arrays and logical volumes, across one or more disks. Builds
+ self.auto_mounts = [(device, mount, filesystem, subvol), ...], which the
+ mount step and write_fstab then drive."""
+ disks = self.setup.disks or [self.setup.disk]
+ parts = self.setup.custom_partitions
+ lvm = self.setup.custom_lvm
+ raid = self.setup.custom_raid
+ self._check_layout_matches_firmware(parts)
+
+ self.update_progress(25, False, False, _("Creating partitions"))
+ print(" --> Creating custom partitions on %s" % ", ".join(disks))
+ os.system("swapoff -a")
+ os.system("umount -R /target 2>/dev/null || true")
+ if raid:
+ # mdadm must be present in the LIVE environment to build the arrays
+ # (it is reinstalled into the target later for boot). Not guaranteed
+ # on every live image, so make sure it is here first.
+ os.system("mdadm --version >/dev/null 2>&1 || "
+ "apt-get install -y mdadm >/dev/null 2>&1 || true")
+ os.system("mdadm --stop --scan 2>/dev/null || true")
+
+ has_esp = any("esp" in p["flags"] for p in parts)
+ # Partition every disk identically (RAID members and mirrored ESP/boot
+ # live on each disk). part_paths[(disk_index, part_number)] -> device.
+ part_paths = {}
+ for di, device_path in enumerate(disks):
+ prefix = partitioning.get_device_naming_scheme_prefix(device_path)
+ disk_device = parted.getDevice(device_path)
+ if self.setup.badblocks:
+ self.runner.run("badblocks -c 10240 -s -w -t random -v %s" % device_path)
+ # Clear any stale md/fs signatures so mklabel/mdadm start clean.
+ os.system("wipefs -a %s 2>/dev/null || true" % device_path)
+ use_gpt = (has_esp or self.setup.gptonefi
+ or disk_device.getLength('B') > 2**32 * .9 * disk_device.sectorSize)
+ label = "gpt" if use_gpt else "msdos"
+ if os.system("parted -s %s mklabel %s" % (device_path, label)) != 0:
+ raise Exception(_("The partition table couldn't be written for %s. Restart the computer and try again.") % device_path)
+ run_parted = lambda cmd, dp=device_path: os.system(
+ 'parted --script --align optimal %s %s ; sync' % (dp, cmd))
+ start_mb = 2
+ for num, part in enumerate(parts, 1):
+ size = part["size"]
+ end = "100%" if size == "rest" else "%dMB" % (
+ start_mb + self._size_to_mb(size))
+ run_parted("mkpart primary %dMB %s" % (start_mb, end))
+ part_path = "%s%s%d" % (device_path, prefix, num)
+ self._wait_for_node(part_path)
+ for flag in part["flags"]:
+ if flag == "esp":
+ run_parted("set %d esp on" % num)
+ run_parted("set %d boot on" % num)
+ elif flag == "bios_grub":
+ run_parted("set %d bios_grub on" % num)
+ if part.get("raid"):
+ run_parted("set %d raid on" % num)
+ part_paths[(di, num)] = part_path
+ if size != "rest":
+ start_mb += self._size_to_mb(size) + 1
+
+ mounts = [] # (device, mount, filesystem, subvol)
+ pvs_by_vg = {} # vg name -> [device path]
+
+ # Assemble the md arrays over their member partitions (one per disk).
+ for array in raid:
+ members = [part_paths[(di, num)]
+ for di in range(len(disks))
+ for num, part in enumerate(parts, 1)
+ if part.get("raid") == array["name"]]
+ mddev = "/dev/%s" % array["name"]
+ print(" --> Creating RAID%s %s over %s" % (array["level"], mddev, " ".join(members)))
+ self.runner.run(
+ "yes | mdadm --create --run --verbose %s --level=%s "
+ "--metadata=%s --raid-devices=%d %s"
+ % (mddev, array["level"], array["metadata"], len(members),
+ " ".join(members)))
+ self._wait_for_node(mddev)
+ if array.get("lvm_pv"):
+ self.runner.run("pvcreate -y %s" % mddev)
+ pvs_by_vg.setdefault(array["lvm_pv"], []).append(mddev)
+ elif array.get("subvolumes"):
+ mounts.extend(self._btrfs_subvol_mounts(mddev, array["subvolumes"]))
+ else:
+ self._format_device(array["filesystem"], mddev)
+ mounts.append((mddev, array["mount"], array["filesystem"], ""))
+
+ # Non-RAID partitions are used from the first disk (the ESP/boot copies
+ # on the other disks exist only for bootloader redundancy).
+ for num, part in enumerate(parts, 1):
+ if part.get("raid"):
+ continue
+ part_path = part_paths[(0, num)]
+ if part.get("lvm_pv"):
+ self.runner.run("pvcreate -y %s" % part_path)
+ pvs_by_vg.setdefault(part["lvm_pv"], []).append(part_path)
+ elif part.get("subvolumes"):
+ mounts.extend(self._btrfs_subvol_mounts(part_path, part["subvolumes"]))
+ elif "bios_grub" not in part["flags"]:
+ self._format_device(part["filesystem"], part_path)
+ mounts.append((part_path, part["mount"], part["filesystem"], ""))
+
+ for vg, pvs in pvs_by_vg.items():
+ self.runner.run("vgcreate -y %s %s" % (vg, " ".join(pvs)))
+ for vol in lvm:
+ size = vol["size"]
+ if size == "rest":
+ self.runner.run("lvcreate -y -n %s -l 100%%FREE %s" % (vol["lv"], vol["vg"]))
+ else:
+ self.runner.run("lvcreate -y -n %s -L %dM %s" % (vol["lv"], self._size_to_mb(size), vol["vg"]))
+ lv_path = "/dev/%s/%s" % (vol["vg"], vol["lv"])
+ self._wait_for_node("/dev/mapper/%s-%s" % (vol["vg"], vol["lv"]))
+ if vol.get("subvolumes"):
+ mounts.extend(self._btrfs_subvol_mounts(lv_path, vol["subvolumes"]))
+ else:
+ self._format_device(vol["filesystem"], lv_path)
+ mounts.append((lv_path, vol["mount"], vol["filesystem"], ""))
+
+ self.auto_mounts = mounts
+ # Mount parents before children: / first, then by path depth.
+ for device, mount, fs, subvol in sorted(
+ mounts, key=lambda m: (m[1] != "/", m[1].count("/"))):
+ if fs == "swap" or mount == "swap":
+ self.runner.run("swapon %s" % device)
+ continue
+ target = "/target" if mount == "/" else "/target" + mount
+ if mount != "/":
+ self.runner.run("mkdir -p %s" % target)
+ options = ("subvol=%s" % subvol) if subvol else None
+ self.do_mount(device, target, fs, options)
+
+ def _btrfs_subvol_mounts(self, device, subvolumes):
+ """Format `device` btrfs, create its subvolumes, and return the mount
+ entries (device, mount, 'btrfs', subvol) that point each subvolume at
+ its mountpoint."""
+ self._format_device("btrfs", device)
+ top = "/run/li-btrfs-top"
+ self.runner.run("mkdir -p %s" % top)
+ self.runner.run("mount %s %s" % (device, top))
+ for sv in subvolumes:
+ self.runner.run("btrfs subvolume create %s/%s" % (top, sv["name"]))
+ self.runner.run("umount %s" % top)
+ return [(device, sv["mount"], "btrfs", sv["name"]) for sv in subvolumes]
+
def create_partitions(self):
+ if getattr(self.setup, "layout", None) == "custom":
+ return self._create_custom_partitions()
# Create partitions on the selected disk (automated installation)
partition_prefix = partitioning.get_device_naming_scheme_prefix(self.setup.disk)
if self.setup.luks:
@@ -434,49 +675,51 @@ def create_partitions(self):
if self.setup.badblocks:
self.update_progress(25, False, False, _("Filling disk with random data (please be patient, this can take hours...)"))
print(" --> Filling %s with random data" % self.setup.disk)
- os.system("badblocks -c 10240 -s -w -t random -v %s" % self.setup.disk)
+ self.runner.run("badblocks -c 10240 -s -w -t random -v %s" % self.setup.disk)
# Create partitions
self.update_progress(25, False, False, _("Creating partitions on %s") % self.setup.disk)
print(" --> Creating partitions on %s" % self.setup.disk)
disk_device = parted.getDevice(self.setup.disk)
- partitioning.full_disk_format(disk_device, create_boot=(self.auto_boot_partition is not None), create_swap=(self.auto_swap_partition is not None))
+ partitioning.full_disk_format(disk_device, self.setup, create_boot=(self.auto_boot_partition is not None), create_swap=(self.auto_swap_partition is not None))
- # Encrypt root partition
+ # Encrypt root partition. The passphrase is fed to cryptsetup on
+ # stdin (--key-file -), never as a command argument: this keeps it
+ # out of the process table (ps) and out of every log sink.
if self.setup.luks:
print(" --> Encrypting root partition %s" % self.auto_root_partition)
- os.system("echo -n %s | cryptsetup luksFormat -c aes-xts-plain64 -h sha256 -s 512 %s" % (shlex.quote(self.setup.passphrase1), self.auto_root_partition))
+ self.runner.run("cryptsetup luksFormat -c aes-xts-plain64 -h sha256 -s 512 --key-file - %s" % self.auto_root_partition, stdin=self.setup.passphrase1)
print(" --> Opening root partition %s" % self.auto_root_partition)
- os.system("echo -n %s | cryptsetup luksOpen %s lvmmint" % (shlex.quote(self.setup.passphrase1), self.auto_root_partition))
+ self.runner.run("cryptsetup luksOpen --key-file - %s lvmmint" % self.auto_root_partition, stdin=self.setup.passphrase1)
self.auto_root_partition = "/dev/mapper/lvmmint"
# Setup LVM
if self.setup.lvm:
print(" --> LVM: Creating PV")
- os.system("pvcreate -y %s" % self.auto_root_partition)
+ self.runner.run("pvcreate -y %s" % self.auto_root_partition)
print(" --> LVM: Creating VG")
- os.system("vgcreate -y lvmmint %s" % self.auto_root_partition)
+ self.runner.run("vgcreate -y lvmmint %s" % self.auto_root_partition)
print(" --> LVM: Creating LV root")
- os.system("lvcreate -y -n root -L 1GB lvmmint")
+ self.runner.run("lvcreate -y -n root -L 1GB lvmmint")
print(" --> LVM: Creating LV swap")
- os.system("lvcreate -y -n swap -L 2GB lvmmint")
+ self.runner.run("lvcreate -y -n swap -L 2GB lvmmint")
print(" --> LVM: Extending LV root")
- os.system(r"lvextend -l 100%FREE /dev/lvmmint/root")
+ self.runner.run(r"lvextend -l 100%FREE /dev/lvmmint/root")
print(" --> LVM: Formatting LV root")
- os.system("mkfs.ext4 /dev/mapper/lvmmint-root -FF")
+ self.runner.run("mkfs.ext4 /dev/mapper/lvmmint-root -FF")
print(" --> LVM: Formatting LV swap")
- os.system("mkswap -f /dev/mapper/lvmmint-swap")
+ self.runner.run("mkswap -f /dev/mapper/lvmmint-swap")
print(" --> LVM: Enabling LV swap")
- os.system("swapon /dev/mapper/lvmmint-swap")
+ self.runner.run("swapon /dev/mapper/lvmmint-swap")
self.auto_root_partition = "/dev/mapper/lvmmint-root"
self.auto_swap_partition = "/dev/mapper/lvmmint-swap"
self.do_mount(self.auto_root_partition, "/target", "ext4", None)
if (self.auto_boot_partition is not None):
- os.system("mkdir -p /target/boot")
+ self.runner.run("mkdir -p /target/boot")
self.do_mount(self.auto_boot_partition, "/target/boot", "ext4", None)
if (self.auto_efi_partition is not None):
- os.system("mkdir -p /target/boot/efi")
+ self.runner.run("mkdir -p /target/boot/efi")
self.do_mount(self.auto_efi_partition, "/target/boot/efi", "vfat", None)
def format_partitions(self):
@@ -517,19 +760,19 @@ def mount_partitions(self):
self.do_mount(partition.path, "/target", fs, None)
if fs == "btrfs":
# Create subvolumes for Btrfs
- os.system("btrfs subvolume create /target/@")
- os.system("btrfs subvolume list -p /target")
+ self.runner.run("btrfs subvolume create /target/@")
+ self.runner.run("btrfs subvolume list -p /target")
print(" ------ Umount btrfs to remount subvolume @")
- os.system("umount --force /target")
+ self.runner.run("umount --force /target")
self.do_mount(partition.path, "/target", fs, "subvol=@")
if not self.setup_has_dedicated_home():
# If there is no dedicated home partition, add a @home subvolume to /
- os.system("mkdir -p /target/home")
+ self.runner.run("mkdir -p /target/home")
self.do_mount(partition.path, "/target/home", fs, None)
- os.system("btrfs subvolume create /target/home/@home")
- os.system("btrfs subvolume list -p /target/home")
+ self.runner.run("btrfs subvolume create /target/home/@home")
+ self.runner.run("btrfs subvolume list -p /target/home")
print(" ------- Umount btrfs to remount subvolume @home")
- os.system("umount --force /target/home")
+ self.runner.run("umount --force /target/home")
self.do_mount(partition.path, "/target/home", fs, "subvol=@home")
break
@@ -537,22 +780,22 @@ def mount_partitions(self):
for partition in self.setup.partitions:
if(partition.mount_as is not None and partition.mount_as != "" and partition.mount_as != "/" and partition.mount_as != "swap"):
print(" ------ Mounting %s on %s" % (partition.path, "/target" + partition.mount_as))
- os.system("mkdir -p /target" + partition.mount_as)
+ self.runner.run("mkdir -p /target" + partition.mount_as)
fs = partition.type
if fs == "fat16" or fs == "fat32":
fs = "vfat"
self.do_mount(partition.path, "/target" + partition.mount_as, fs, None)
if partition.mount_as == "/home" and fs == "btrfs":
# Dedicated home partition with Btrfs, needs a @home subvolume
- os.system("btrfs subvolume create /target/home/@home")
- os.system("btrfs subvolume list -p /target/home")
+ self.runner.run("btrfs subvolume create /target/home/@home")
+ self.runner.run("btrfs subvolume list -p /target/home")
print(" ------- Umount btrfs to remount subvolume @home")
- os.system("umount --force /target/home")
+ self.runner.run("umount --force /target/home")
self.do_mount(partition.path, "/target/home", fs, "subvol=@home")
def get_blkid(self, path):
uuid = path # If we can't find the UUID we use the path
- blkid = subprocess.getoutput('blkid').split('\n')
+ blkid = self.runner.output('blkid').split('\n')
for blkid_line in blkid:
blkid_elements = blkid_line.split(':')
if blkid_elements[0] == path:
@@ -581,7 +824,23 @@ def write_fstab(self, path="/target/etc/fstab"):
fstab.write("#### Static Filesystem Table File\n")
fstab.write("proc\t/proc\tproc\tdefaults\t0\t0\n")
if(not self.setup.skip_mount):
- if self.setup.automated:
+ if self.setup.automated and getattr(self, "auto_mounts", None):
+ for device, mount, fs, subvol in self.auto_mounts:
+ uuid = self.get_blkid(device)
+ if fs == "swap":
+ fstab.write("# %s\n" % device)
+ fstab.write("%s none swap sw 0 0\n" % uuid)
+ else:
+ fsck = "1" if fs != "btrfs" and mount in ("/", "/boot", "/boot/efi") else "0"
+ if subvol:
+ opts = "defaults,subvol=%s" % subvol
+ elif fs.startswith("ext"):
+ opts = "rw,errors=remount-ro"
+ else:
+ opts = "defaults"
+ fstab.write("# %s\n" % device)
+ fstab.write("%s\t%s\t%s\t%s\t0\t%s\n" % (uuid, mount, fs, opts, fsck))
+ elif self.setup.automated:
fstab.write("# %s\n" % self.auto_root_partition)
fstab.write("%s / ext4 defaults 0 1\n" % self.get_blkid(self.auto_root_partition))
fstab.write("# %s\n" % self.auto_swap_partition)
@@ -624,13 +883,21 @@ def write_fstab(self, path="/target/etc/fstab"):
def write_mtab(self, fstab="/target/etc/fstab", mtab="/target/etc/mtab"):
if self.setup.lvm:
- os.system(f"grep -v swap {fstab} > {mtab}")
+ self.runner.run(f"grep -v swap {fstab} > {mtab}")
def write_crypttab(self, path="/target/etc/crypttab"):
if self.setup.luks:
- os.system(f"echo 'lvmmint {self.get_blkid(self.auto_root_physical_partition)} none luks,discard,tries=3' >> {path}")
+ uuid = self.get_blkid(self.auto_root_physical_partition)
+ if self.setup.luks_rekey_on_first_boot:
+ # Auto-unlock the FIRST boot with the throwaway keyfile that the
+ # driver embeds in the initramfs; the first-boot rekey service
+ # then restores `none` (prompt) here and rebuilds the initramfs.
+ keyref = "/etc/cryptsetup-keys.d/cryptroot.key"
+ self.runner.run(f"echo 'lvmmint {uuid} {keyref} luks,discard' >> {path}")
+ else:
+ self.runner.run(f"echo 'lvmmint {uuid} none luks,discard,tries=3' >> {path}")
- def finish_installation(self):
+ def finish_installation(self, before_unmount_hook=None):
self.update_progress(50, False, False, _("Setting hostname"))
self.setup_hostname()
@@ -650,21 +917,21 @@ def finish_installation(self):
self.update_progress(70, False, False, _("Installing drivers"))
# Broadcom
- drivers = subprocess.getoutput("mint-drivers")
+ drivers = self.runner.output("mint-drivers")
if "broadcom-sta-dkms" in drivers:
try:
- os.system("mkdir -p /target/debs")
- os.system(f"cp {self.pool}/non-free/b/broadcom-sta/*.deb /target/debs/")
+ self.runner.run("mkdir -p /target/debs")
+ self.runner.run(f"cp {self.pool}/non-free/b/broadcom-sta/*.deb /target/debs/")
self.do_run_in_chroot("dpkg -i /debs/*")
self.do_run_in_chroot("modprobe wl")
- os.system("rm -rf /target/debs")
+ self.runner.run("rm -rf /target/debs")
except:
print("Failed to install Broadcom drivers")
# NVIDIA
driver = "/usr/share/live-installer/nvidia-driver.tar.gz"
if os.path.exists(driver):
- if "install-nvidia" in subprocess.getoutput("cat /proc/cmdline"):
+ if "install-nvidia" in self.runner.output("cat /proc/cmdline"):
print(" --> Installing NVIDIA driver")
try:
self.do_run_in_chroot("tar zxvf %s" % driver)
@@ -673,7 +940,7 @@ def finish_installation(self):
except Exception as e:
print("Failed to install NVIDIA driver: ", e)
- os.system("rm -f /target/usr/share/live-installer/nvidia-driver.tar.gz")
+ self.runner.run("rm -f /target/usr/share/live-installer/nvidia-driver.tar.gz")
# set the keyboard options..
self.update_progress(75, False, False, _("Setting keyboard options"))
@@ -695,12 +962,32 @@ def finish_installation(self):
f.write('GRUB_CMDLINE_LINUX="cryptdevice=%s:lvmmint root=/dev/mapper/lvmmint-root resume=/dev/mapper/lvmmint-swap"\n' % self.get_blkid(self.auto_root_physical_partition))
self.do_run_in_chroot("echo 'w /sys/power/disk - - - - shutdown' > /etc/tmpfiles.d/encrypted-swap.conf")
+ # Software RAID: bake the array config + modules into the initramfs so
+ # the root array assembles at boot.
+ if getattr(self.setup, "custom_raid", None):
+ print(" --> Configuring software RAID for boot")
+ self.do_run_in_chroot(
+ "DEBIAN_FRONTEND=noninteractive apt-get install -y mdadm")
+ self.do_run_in_chroot("mkdir -p /etc/mdadm")
+ self.do_run_in_chroot(
+ "mdadm --detail --scan >> /etc/mdadm/mdadm.conf")
+ for module in ("md_mod", "raid0", "raid1", "raid456", "raid10"):
+ self.do_run_in_chroot(
+ "echo %s >> /etc/initramfs-tools/modules" % module)
+
# write MBR (grub)
print(" --> Configuring Grub")
if(self.setup.grub_device is not None):
self.update_progress(80, False, False, _("Installing bootloader"))
print(" --> Running grub-install")
- self.do_run_in_chroot("grub-install --force %s" % self.setup.grub_device)
+ # On a RAID install, put the bootloader on EVERY member disk so the
+ # machine still boots if one disk fails.
+ grub_targets = (self.setup.disks
+ if getattr(self.setup, "custom_raid", None)
+ and len(self.setup.disks) > 1
+ else [self.setup.grub_device])
+ for target in grub_targets:
+ self.do_run_in_chroot("grub-install --force %s" % target)
# Remove memtest86+ package (it provides multiple memtest unwanted grub entries)
self.do_run_in_chroot("apt-get remove --purge --yes --force-yes memtest86+")
#fix not add windows grub entry
@@ -718,31 +1005,37 @@ def finish_installation(self):
print(" --> Configuring Initramfs")
self.do_run_in_chroot("/usr/sbin/update-initramfs -t -u -k all")
self.do_run_in_chroot(self.grub_adjustment_script)
- kernelversion= subprocess.getoutput("uname -r")
+ kernelversion= self.runner.output("uname -r")
self.do_run_in_chroot("/usr/bin/sha1sum /boot/initrd.img-%s > /var/lib/initramfs-tools/%s" % (kernelversion,kernelversion))
# Clean APT
self.update_progress(95, True, False, _("Cleaning APT"))
self.clean_apt()
+ # Give the caller a chance to act on the fully-configured system
+ # while the chroot is still mounted and has network access (the
+ # headless driver applies extra users / packages / scripts here)
+ if before_unmount_hook is not None:
+ before_unmount_hook()
+
# now unmount it
print(" --> Unmounting partitions")
if os.path.exists("/target/sys/firmware/efi/efivars"):
- os.system("umount --force /target/sys/firmware/efi/efivars")
+ self.runner.run("umount --force /target/sys/firmware/efi/efivars")
- os.system("umount --force /target/dev/shm")
- os.system("umount --force /target/dev/pts")
+ self.runner.run("umount --force /target/dev/shm")
+ self.runner.run("umount --force /target/dev/pts")
if self.setup.gptonefi:
- os.system("umount --force /target/boot/efi")
- os.system("umount --force /target/media/cdrom")
- os.system("umount --force /target/boot")
- os.system("umount --force /target/dev/")
- os.system("umount --force /target/sys/")
- os.system("umount --force /target/proc/")
- os.system("umount --force /target/run/")
- os.system("rm -f /target/etc/resolv.conf")
- os.system("mv /target/etc/resolv.conf.bk /target/etc/resolv.conf")
+ self.runner.run("umount --force /target/boot/efi")
+ self.runner.run("umount --force /target/media/cdrom")
+ self.runner.run("umount --force /target/boot")
+ self.runner.run("umount --force /target/dev/")
+ self.runner.run("umount --force /target/sys/")
+ self.runner.run("umount --force /target/proc/")
+ self.runner.run("umount --force /target/run/")
+ self.runner.run("rm -f /target/etc/resolv.conf")
+ self.runner.run("mv /target/etc/resolv.conf.bk /target/etc/resolv.conf")
if(not self.setup.skip_mount):
for partition in self.setup.partitions:
if(partition.mount_as is not None and partition.mount_as != "" and partition.mount_as != "/" and partition.mount_as != "swap"):
@@ -759,15 +1052,13 @@ def finish_installation(self):
print(" --> All done")
def do_run_in_chroot(self, command):
- command = command.replace('"', "'").strip()
- print("chroot /target/ /bin/sh -c \"%s\"" % command)
- os.system("chroot /target/ /bin/sh -c \"%s\"" % command)
+ self.runner.chroot(command)
def do_configure_grub(self):
self.update_progress(85, True, False, _("Configuring bootloader"))
print(" --> Running grub-mkconfig")
self.do_run_in_chroot("grub-mkconfig -o /boot/grub/grub.cfg")
- grub_output = subprocess.getoutput("chroot /target/ /bin/sh -c \"grub-mkconfig -o /boot/grub/grub.cfg\"")
+ grub_output = self.runner.output("chroot /target/ /bin/sh -c \"grub-mkconfig -o /boot/grub/grub.cfg\"")
grubfh = open("/var/log/live-installer-grub-output.log", "w")
grubfh.writelines(grub_output)
grubfh.close()
@@ -809,7 +1100,7 @@ def do_unmount(self, mountpoint):
# Execute schell command and return output in a list
def exec_cmd(self, cmd):
- p = subprocess.Popen(cmd, shell=True, encoding='utf-8', errors='ignore', stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
+ p = self.runner.popen(cmd)
lstOut = []
for line in p.stdout.readlines():
# Strip the line, also from null spaces (strip() only strips white spaces)
@@ -824,9 +1115,11 @@ class Setup(object):
oem_mode = False
language = None
timezone = None
+ additional_locales = [] # extra locales to also generate
keyboard_model = None
- keyboard_layout = None
- keyboard_variant = None
+ keyboard_layout = None # may be a comma-joined XKB list
+ keyboard_variant = None # matching comma-joined variant list
+ keyboard_options = None # XKB switch option, e.g. grp:alt_shift_toggle
partitions = [] #Array of PartitionSetup objects
username = None
hostname = None
@@ -834,6 +1127,7 @@ class Setup(object):
ecryptfs = False
password1 = None
password2 = None
+ password_is_crypted = False
real_name = None
grub_device = None
disks = []
@@ -844,6 +1138,14 @@ class Setup(object):
passphrase2 = None
lvm = False
luks = False
+ # passphrase_source: prompt-on-first-boot — LUKS is created with a random
+ # throwaway key (auto-unlocks the first boot via a keyfile in the
+ # initramfs); a first-boot service then swaps in the operator's passphrase.
+ luks_rekey_on_first_boot = False
+ layout = "simple" # simple | lvm | lvm-on-luks | custom
+ custom_partitions = [] # for layout: custom — list of partition dicts
+ custom_lvm = [] # for layout: custom — list of LV dicts
+ custom_raid = [] # for layout: custom — list of md-array dicts
badblocks = False
target_disk = None
gptonefi = False
@@ -882,7 +1184,10 @@ def print_setup(self):
print("luks: %s" % self.luks)
print("badblocks: %s" % self.badblocks)
print("lvm: %s" % self.lvm)
- print("passphrase: %s - %s" % (self.passphrase1, self.passphrase2))
+ # Never log the LUKS passphrase (including the random
+ # first-boot-rekey key) — print_setup output is teed to the
+ # install log and the serial console.
+ print("passphrase: %s" % ("" if self.passphrase1 else ""))
if (not self.skip_mount):
print("target_disk: %s " % self.target_disk)
if self.gptonefi:
diff --git a/usr/lib/live-installer/main.py b/usr/lib/live-installer/main.py
index 3cdc25fc..9c1773ef 100644
--- a/usr/lib/live-installer/main.py
+++ b/usr/lib/live-installer/main.py
@@ -3,6 +3,7 @@
from installer import InstallerEngine, Setup
from dialogs import MessageDialog, QuestionDialog, ErrorDialog, WarningDialog, ConfirmDialog
import distro
+import mint_detect
import timezones
import partitioning
import gettext
@@ -39,7 +40,7 @@
DISTRIBUTION = distro.name()
VERSION = distro.version()
-IS_MINT = os.path.exists("/usr/share/doc/ubuntu-system-adjustments/copyright") or "ubuntu" in distro.like()
+IS_MINT = mint_detect.is_mint()
NON_LATIN_KB_LAYOUTS = ['am', 'af', 'ara', 'ben', 'bd', 'bg', 'bn', 'bt', 'by', 'deva', 'et', 'ge', 'gh', 'gn', 'gr', 'guj', 'guru', 'id', 'il', 'iku', 'in', 'iq', 'ir', 'kan', 'kg', 'kh', 'kz', 'la', 'lao', 'lk', 'ma', 'mk', 'mm', 'mn', 'mv', 'mal', 'my', 'np', 'ori', 'pk', 'ru', 'rs', 'scc', 'sy', 'syr', 'tel', 'th', 'tj', 'tam', 'tz', 'ua', 'uz']
@@ -1349,6 +1350,26 @@ def pbar_pulse():
# main entry
if __name__ == "__main__":
+ # Unattended installation: --automated= on the command line, or
+ # live-installer.auto= on the kernel command line. Everything
+ # else falls through to the GUI unchanged.
+ cmdline = subprocess.getoutput("cat /proc/cmdline")
+ if ("live-installer.auto=" in cmdline
+ or any(arg.startswith("--automated") for arg in sys.argv)):
+ import auto_installer
+ # Translate the --automated trigger, then forward every other flag to
+ # the headless driver unchanged, so --list-disks/--check/--config/
+ # --insecure/--dry-run all work via the `live-installer` entrypoint.
+ auto_argv = []
+ for arg in sys.argv[1:]:
+ if arg.startswith("--automated="):
+ auto_argv += ["--config", arg.split("=", 1)[1]]
+ elif arg == "--automated":
+ continue # bare trigger, no source value
+ else:
+ auto_argv.append(arg)
+ sys.exit(auto_installer.main(auto_argv))
+
expert_mode = "--expert-mode" in sys.argv
oem_mode = "--oem-mode" in sys.argv
oem_config = "--oem-config" in sys.argv
diff --git a/usr/lib/live-installer/mint_detect.py b/usr/lib/live-installer/mint_detect.py
new file mode 100644
index 00000000..fa67ea50
--- /dev/null
+++ b/usr/lib/live-installer/mint_detect.py
@@ -0,0 +1,22 @@
+"""Single source of truth for "are we on Mint vs LMDE/Debian".
+
+Shared by the GTK front-end (main.py) and the headless driver
+(auto_installer.py) so this detection lives in exactly one place. The
+headless side must not import the GTK-coupled main.py, hence a tiny module
+of its own rather than a constant in main.py.
+"""
+
+import os
+
+
+def is_mint():
+ """True on Linux Mint (Ubuntu-based), False on LMDE/Debian."""
+ try:
+ import distro
+ like = distro.like()
+ except ImportError:
+ like = ""
+ return (
+ os.path.exists("/usr/share/doc/ubuntu-system-adjustments/copyright")
+ or "ubuntu" in like
+ )
diff --git a/usr/lib/live-installer/netconfig.py b/usr/lib/live-installer/netconfig.py
new file mode 100644
index 00000000..a23d2f09
--- /dev/null
+++ b/usr/lib/live-installer/netconfig.py
@@ -0,0 +1,233 @@
+#!/usr/bin/python3
+# coding: utf-8
+"""Render the answer file's netplan-v2-shaped ``network:`` section into
+NetworkManager keyfiles for the installed system.
+
+We deliberately do NOT shell out to netplan: LMDE/Debian does not ship it,
+and Mint/LMDE installed systems are managed by NetworkManager, whose keyfile
+format (``/etc/NetworkManager/system-connections/.nmconnection``, mode
+0600) is the natural target. This mirrors how cloud-init renders its own
+Network Config v2 to the NetworkManager backend on Debian-family systems.
+
+Handles ethernets, wifis (one keyfile per access point; the PSK lands in the
+keyfile, which is exactly why 0600 matters), and vlans.
+
+The single public entry point, :func:`render`, is pure: it takes a validated
+:class:`schema.Network` (or any object with the same attributes) and returns
+an ordered ``{filename: content}`` dict. The caller writes each file with
+mode 0600. Keeping it side-effect-free makes it trivially unit-testable.
+"""
+
+import ipaddress
+import uuid
+
+# Stable namespace so a given interface id always renders to the same UUID.
+# VLANs reference their parent by this UUID, which is robust even when the
+# parent binds by MAC and has no fixed interface name.
+_NAMESPACE = uuid.UUID("6f9619ff-8b86-d011-b42d-00c04fc964ff")
+
+
+def _conn_uuid(iface_id):
+ return str(uuid.uuid5(_NAMESPACE, iface_id))
+
+
+def _split_family(addresses):
+ v4 = [a for a in addresses if ":" not in a]
+ v6 = [a for a in addresses if ":" in a]
+ return v4, v6
+
+
+def _ip_section(family, cfg):
+ """Build one ``[ipv4]``/``[ipv6]`` keyfile section as a list of lines.
+
+ family is 4 or 6. cfg is the interface's IP config (dhcp4/dhcp6,
+ addresses, gateway4/gateway6, nameservers, routes).
+ """
+ is_v4 = family == 4
+ header = "[ipv4]" if is_v4 else "[ipv6]"
+ v4_addrs, v6_addrs = _split_family(cfg.addresses)
+ addrs = v4_addrs if is_v4 else v6_addrs
+ dhcp = cfg.dhcp4 if is_v4 else cfg.dhcp6
+ gateway = cfg.gateway4 if is_v4 else cfg.gateway6
+
+ if dhcp:
+ method = "auto"
+ elif addrs:
+ method = "manual"
+ else:
+ # No DHCP and no static address for this family. IPv4 is switched
+ # off entirely; IPv6 keeps link-local only (predictable: no global
+ # SLAAC address unless the admin opts in with dhcp6/addresses).
+ method = "disabled" if is_v4 else "link-local"
+
+ lines = [header, "method=%s" % method]
+
+ if method == "manual":
+ for i, addr in enumerate(addrs, 1):
+ lines.append("address%d=%s" % (i, addr))
+ if gateway:
+ lines.append("gateway=%s" % gateway)
+
+ # DNS for this family is dropped onto whichever section is active.
+ if method != "disabled":
+ dns = [a for a in cfg.nameservers.addresses
+ if (ipaddress.ip_address(a).version == family)]
+ if dns:
+ lines.append("dns=%s;" % ";".join(dns))
+ if cfg.nameservers.search:
+ lines.append("dns-search=%s;" % ";".join(cfg.nameservers.search))
+
+ # Extra routes for this family (the default route is normally expressed
+ # via gateway4/gateway6 above, but routes: [{to: default, ...}] also works).
+ route_idx = 0
+ for route in cfg.routes:
+ if route.to == "default":
+ dest = "0.0.0.0/0" if is_v4 else "::/0"
+ via_v = ipaddress.ip_address(route.via).version
+ if via_v != family:
+ continue
+ else:
+ if ipaddress.ip_network(route.to, strict=False).version != family:
+ continue
+ dest = route.to
+ route_idx += 1
+ value = "%s,%s" % (dest, route.via)
+ if route.metric is not None:
+ value += ",%d" % route.metric
+ lines.append("route%d=%s" % (route_idx, value))
+
+ return lines
+
+
+def _8021x_lines(auth):
+ """Render an [802-1x] section (wired 802.1X or wifi EAP). Shared by the
+ ethernet and wifi paths."""
+ lines = ["[802-1x]", "eap=%s" % auth.method]
+ if auth.identity:
+ lines.append("identity=%s" % auth.identity)
+ if auth.anonymous_identity:
+ lines.append("anonymous-identity=%s" % auth.anonymous_identity)
+ if auth.ca_certificate:
+ lines.append("ca-cert=%s" % auth.ca_certificate)
+ if auth.method == "tls":
+ lines.append("client-cert=%s" % auth.client_certificate)
+ lines.append("private-key=%s" % auth.client_key)
+ if auth.client_key_password:
+ lines.append("private-key-password=%s" % auth.client_key_password)
+ else: # peap / ttls
+ if auth.phase2_auth:
+ lines.append("phase2-auth=%s" % auth.phase2_auth)
+ if auth.password:
+ lines.append("password=%s" % auth.password)
+ return lines
+
+
+def _ethernet_lines(iface_id, cfg):
+ lines = ["[connection]",
+ "id=%s" % iface_id,
+ "uuid=%s" % _conn_uuid(iface_id),
+ "type=ethernet"]
+ eth_section = []
+ match = getattr(cfg, "match", None)
+ if match is not None and match.macaddress:
+ # Bind by hardware address; survives kernel interface renaming.
+ eth_section.append("[ethernet]")
+ eth_section.append("mac-address=%s" % match.macaddress.upper())
+ elif match is not None and match.name:
+ lines.append("interface-name=%s" % match.name)
+ else:
+ # No match: netplan treats the key as the interface name.
+ lines.append("interface-name=%s" % iface_id)
+ lines.append("")
+ if eth_section:
+ lines.extend(eth_section)
+ lines.append("")
+ if getattr(cfg, "auth", None) is not None:
+ lines.extend(_8021x_lines(cfg.auth)) # wired 802.1X
+ lines.append("")
+ lines.extend(_ip_section(4, cfg))
+ lines.append("")
+ lines.extend(_ip_section(6, cfg))
+ lines.append("")
+ return "\n".join(lines)
+
+
+def _vlan_lines(iface_id, cfg):
+ lines = ["[connection]",
+ "id=%s" % iface_id,
+ "uuid=%s" % _conn_uuid(iface_id),
+ "type=vlan",
+ "interface-name=%s" % iface_id,
+ "",
+ "[vlan]",
+ "id=%d" % cfg.id,
+ # Reference the parent by its connection UUID, so it resolves
+ # whether the parent binds by name or by MAC.
+ "parent=%s" % _conn_uuid(cfg.link),
+ ""]
+ lines.extend(_ip_section(4, cfg))
+ lines.append("")
+ lines.extend(_ip_section(6, cfg))
+ lines.append("")
+ return "\n".join(lines)
+
+
+def _wifi_lines(iface_id, ssid, ap, cfg):
+ # Each access point becomes its own NM wifi connection. The connection is
+ # keyed on the device (interface-name or mac-address) and the SSID; NM
+ # activates whichever configured SSID is in range.
+ uuid = _conn_uuid("%s\x00%s" % (iface_id, ssid))
+ lines = ["[connection]",
+ "id=%s" % ssid,
+ "uuid=%s" % uuid,
+ "type=wifi"]
+ wifi_section = ["[wifi]", "ssid=%s" % ssid, "mode=infrastructure"]
+ if ap.hidden:
+ wifi_section.append("hidden=true")
+ match = getattr(cfg, "match", None)
+ if match is not None and match.macaddress:
+ wifi_section.append("mac-address=%s" % match.macaddress.upper())
+ elif match is not None and match.name:
+ lines.append("interface-name=%s" % match.name)
+ else:
+ lines.append("interface-name=%s" % iface_id)
+ lines.append("")
+ lines.extend(wifi_section)
+ lines.append("")
+ if getattr(ap, "auth", None) is not None:
+ # WPA-Enterprise / EAP: key-mgmt=wpa-eap plus an [802-1x] section.
+ lines.extend(["[wifi-security]", "key-mgmt=wpa-eap", ""])
+ lines.extend(_8021x_lines(ap.auth))
+ lines.append("")
+ elif ap.password is not None:
+ # WPA-PSK. An open network omits the security section entirely.
+ lines.extend(["[wifi-security]", "key-mgmt=wpa-psk",
+ "psk=%s" % ap.password, ""])
+ lines.extend(_ip_section(4, cfg))
+ lines.append("")
+ lines.extend(_ip_section(6, cfg))
+ lines.append("")
+ return "\n".join(lines)
+
+
+def render(network):
+ """Render a Network config to ``{filename: keyfile-content}``.
+
+ Each file belongs in /etc/NetworkManager/system-connections/ with mode
+ 0600. Returns an empty dict if network is None.
+ """
+ if network is None:
+ return {}
+ files = {}
+ for iface_id, cfg in network.ethernets.items():
+ files["%s.nmconnection" % iface_id] = _ethernet_lines(iface_id, cfg)
+ for iface_id, cfg in network.wifis.items():
+ # One keyfile per access point; suffix the filename only when a device
+ # has more than one, so the common single-AP case stays ".nmconnection".
+ aps = list(cfg.access_points.items())
+ for index, (ssid, ap) in enumerate(aps, start=1):
+ name = iface_id if len(aps) == 1 else "%s-%d" % (iface_id, index)
+ files["%s.nmconnection" % name] = _wifi_lines(iface_id, ssid, ap, cfg)
+ for iface_id, cfg in network.vlans.items():
+ files["%s.nmconnection" % iface_id] = _vlan_lines(iface_id, cfg)
+ return files
diff --git a/usr/lib/live-installer/partitioning.py b/usr/lib/live-installer/partitioning.py
index 4cc429dd..34752be5 100644
--- a/usr/lib/live-installer/partitioning.py
+++ b/usr/lib/live-installer/partitioning.py
@@ -415,7 +415,7 @@ def get_device_naming_scheme_prefix(device_name):
else:
return ""
-def full_disk_format(device, create_boot=False, create_swap=True):
+def full_disk_format(device, setup, create_boot=False, create_swap=True):
# If some LVM volumes use the selected device, remove them.
try:
output = subprocess.getoutput("pvs --reportformat json")
@@ -437,7 +437,7 @@ def full_disk_format(device, create_boot=False, create_swap=True):
os.system("umount -R /target 2>/dev/null || true")
# Create a default partition set up
disk_label = ('gpt' if device.getLength('B') > 2**32*.9 * device.sectorSize # size of disk > ~2TB
- or installer.setup.gptonefi
+ or setup.gptonefi
else 'msdos')
return_code = os.system("parted -s %s mklabel %s" % (device.path, disk_label))
if return_code != 0:
@@ -446,7 +446,7 @@ def full_disk_format(device, create_boot=False, create_swap=True):
mkpart = (
# (condition, mount_as, format_as, mkfs command, size_mb)
# EFI
- (installer.setup.gptonefi, EFI_MOUNT_POINT, 'vfat', 'mkfs.vfat {} -F 32 ', 300),
+ (setup.gptonefi, EFI_MOUNT_POINT, 'vfat', 'mkfs.vfat {} -F 32 ', 300),
# boot
(create_boot, '/boot', 'ext4', 'mkfs.ext4 -F {}', 1024),
# swap - equal to RAM for hibernate to work well (but capped at ~8GB)
@@ -470,6 +470,10 @@ def full_disk_format(device, create_boot=False, create_swap=True):
partition_path = "%s%s%d" % (device.path, partition_prefix, partition_number)
num_tries = 0
while True:
+ # Each parted invocation triggers a partition-table rescan;
+ # udev briefly removes and recreates the device nodes. Wait
+ # for it to finish or mkfs can race a vanishing node.
+ os.system("udevadm settle 2>/dev/null")
if os.path.exists(partition_path):
break
if num_tries < 5:
@@ -481,9 +485,13 @@ def full_disk_format(device, create_boot=False, create_swap=True):
raise Exception(_("The partition %s could not be created. The installation will stop. Restart the computer and try again.") % partition_path)
mkfs = mkfs.format(partition_path)
print(mkfs)
- os.system(mkfs)
+ if os.system(mkfs) != 0:
+ # A failed format must stop the installation here: if this is
+ # tolerated, mounting fails silently and the file copy lands
+ # in the live session's tmpfs until it fills up.
+ raise Exception(_("The partition %s could not be formatted. The installation will stop. Restart the computer and try again.") % partition_path)
start_mb += size_mb + 1
- if installer.setup.gptonefi:
+ if setup.gptonefi:
run_parted('set 1 boot on')
return ((i[1], i[2]) for i in mkpart if i[0])
diff --git a/usr/lib/live-installer/pkgbackend.py b/usr/lib/live-installer/pkgbackend.py
new file mode 100644
index 00000000..3191206b
--- /dev/null
+++ b/usr/lib/live-installer/pkgbackend.py
@@ -0,0 +1,151 @@
+#!/usr/bin/python3
+# coding: utf-8
+"""Package backends: apply repositories and install/remove packages in the
+installed system.
+
+cloud-init keeps `packages:` distro-agnostic (one list, dispatched per distro)
+but keeps repository config per-backend (`apt:`, `yum_repos:`, `zypper:`) — they
+never shared a shape. We follow that: the driver holds one PackageBackend, picks
+it per distro, and calls it with the agnostic package list plus that backend's
+own repo config. Today only apt exists (Mint/LMDE), but the seam keeps the
+install step from being apt-hardcoded so dnf/zypper can slot in later.
+
+Execution goes through the injected CommandRunner (chroot into the target), the
+same shell-out boundary the rest of the installer uses — so this is unit-testable
+with a RecordingRunner and never imports a distro library at module load.
+"""
+
+import os
+import shlex
+
+
+class PackageBackend:
+ """Interface: apply repo config, then install/remove packages, all in the
+ target chroot. Subclasses implement the distro-specific commands.
+
+ runner — CommandRunner (chroot/run/output) into the mounted target.
+ policy — callable(failure_mode, message); applies the answer file's
+ abort/continue policy (may raise InstallationFailed).
+ log — callable(message) for progress output.
+ target — the mounted target root on the host side (for writing files
+ that the chroot then sees), default "/target".
+ """
+
+ def __init__(self, runner, policy, log, target="/target"):
+ self.runner = runner
+ self.policy = policy
+ self.log = log
+ self.target = target
+
+ def apply(self, apt_config, packages, package_remove):
+ raise NotImplementedError
+
+
+class AptBackend(PackageBackend):
+ SOURCES_DIR = "/etc/apt/sources.list.d"
+ KEYRING_DIR = "/etc/apt/trusted.gpg.d"
+
+ def apply(self, apt_config, packages, package_remove):
+ sources = apt_config.sources if apt_config is not None else {}
+ for name, src in sources.items():
+ self._add_source(name, src)
+ if sources or packages:
+ self._update()
+ if packages:
+ self._install(packages)
+ if package_remove:
+ self._remove(package_remove)
+
+ # -- repositories ------------------------------------------------------
+
+ def _add_source(self, name, src):
+ self.log(f" --> Adding apt source: {name}")
+ if src.key is not None:
+ self._write_key_inline(name, src.key)
+ elif src.key_url is not None:
+ self._fetch_key_url(name, src.key_url)
+ elif src.keyid is not None:
+ self._fetch_keyid(name, src.keyid, src.keyserver)
+ basename = src.filename or name
+ list_path = f"{self.SOURCES_DIR}/{basename}.list"
+ self.runner.chroot(
+ f"echo {shlex.quote(src.source)} > {shlex.quote(list_path)}"
+ )
+
+ def _write_key_inline(self, name, armored):
+ # We control the content, so write it straight into the target's
+ # trusted keyring dir rather than echoing through the chroot.
+ host_dir = self.target + self.KEYRING_DIR
+ os.makedirs(host_dir, exist_ok=True)
+ path = os.path.join(host_dir, f"{name}.asc")
+ with open(path, "w") as f:
+ f.write(armored if armored.endswith("\n") else armored + "\n")
+ os.chmod(path, 0o644)
+
+ def _fetch_key_url(self, name, url):
+ rc = self.runner.chroot(
+ f"wget -O {self.KEYRING_DIR}/{shlex.quote(name)}.asc "
+ + shlex.quote(url)
+ )
+ if rc != 0:
+ self.policy("network_unavailable", f"fetching key {url} failed")
+
+ def _fetch_keyid(self, name, keyid, keyserver):
+ # Pull the key from a keyserver into a temp keyring, then export it
+ # (de-armored) into the trusted keyring dir. gnupg ships on the ISO.
+ keyring = f"/tmp/li-{name}.gpg"
+ out = f"{self.KEYRING_DIR}/{shlex.quote(name)}.gpg"
+ cmd = (
+ f"gpg --batch --no-default-keyring --keyring {keyring} "
+ f"--keyserver {shlex.quote(keyserver)} --recv-keys {shlex.quote(keyid)} "
+ f"&& gpg --batch --no-default-keyring --keyring {keyring} "
+ f"--export {shlex.quote(keyid)} > {out} "
+ f"&& rm -f {keyring}"
+ )
+ rc = self.runner.chroot(cmd)
+ if rc != 0:
+ self.policy("network_unavailable",
+ f"fetching key {keyid} from {keyserver} failed")
+
+ # -- packages ----------------------------------------------------------
+
+ def _update(self):
+ rc = self.runner.chroot("apt-get update")
+ if rc != 0:
+ self.policy("network_unavailable", "apt-get update failed")
+ # On EFI installs the engine dpkg-installs the bootloader stack
+ # (shim-signed, grub-efi) from the ISO pool without its full
+ # dependency closure, leaving dpkg in a state apt refuses to build on.
+ # Complete it before installing anything else.
+ rc = self.runner.chroot(
+ "DEBIAN_FRONTEND=noninteractive apt-get install -f -y"
+ )
+ if rc != 0:
+ self.log("WARNING: apt-get install -f failed; "
+ "continuing to package installation")
+
+ def _install(self, packages):
+ self.log(" --> Installing packages: " + " ".join(packages))
+ rc = self.runner.chroot(
+ "DEBIAN_FRONTEND=noninteractive apt-get install -y "
+ + " ".join(shlex.quote(p) for p in packages)
+ )
+ if rc != 0:
+ self.policy("package_install_failure", "package installation failed")
+
+ def _remove(self, packages):
+ self.log(" --> Removing packages: " + " ".join(packages))
+ rc = self.runner.chroot(
+ "DEBIAN_FRONTEND=noninteractive apt-get remove --purge -y "
+ + " ".join(shlex.quote(p) for p in packages)
+ )
+ if rc != 0:
+ self.policy("package_install_failure", "package removal failed")
+
+
+def get_backend(runner, policy, log, *, family="debian", target="/target"):
+ """Pick the package backend for the target distro family. Only apt today;
+ the seam is here so dnf/zypper can be added without touching the driver."""
+ if family == "debian":
+ return AptBackend(runner, policy, log, target=target)
+ raise ValueError(f"no package backend for distro family {family!r}")
diff --git a/usr/lib/live-installer/schema.py b/usr/lib/live-installer/schema.py
new file mode 100644
index 00000000..258248d7
--- /dev/null
+++ b/usr/lib/live-installer/schema.py
@@ -0,0 +1,1432 @@
+#!/usr/bin/python3
+# coding: utf-8
+"""Answer-file schema for unattended installation (version 1).
+
+Parses and strictly validates the YAML answer file that drives a
+headless install. Design rules:
+
+ 1. Raw device paths (/dev/sdX) are rejected — disks are selected by
+ stable match expressions only (by-id, by-path, model, size-min,
+ first-non-removable).
+ 2. Every failure mode has an explicit abort/continue policy, and the
+ defaults fail closed (abort).
+ 3. The format is versioned from day one (`version: 1` is required).
+ 4. Passwords must be pre-hashed in crypt(5) format; plaintext is
+ rejected outright, with no override.
+ 5. The config is data, not a program: unknown keys are errors and
+ nothing is interpolated or templated.
+
+Where this overlaps with cloud-init / Ubuntu autoinstall, it uses the
+same key names and structure (top-level hostname/locale/timezone; users
+with name/gecos/passwd/groups/ssh_authorized_keys; a flat packages
+install list). Installer-only concerns that cloud-init has no
+equivalent for (disk selection, partition layout, LUKS, kernel cmdline,
+failure policy) keep their own shapes. Notable divergences:
+ - `package_remove` is an extension: cloud-init has no declarative
+ package removal.
+ - `apt` mirrors cloud-init's `apt:` section (the `sources:` map, each with
+ `source`/`key`/`keyid`/`keyserver`, plus a `key_url` extension). Repo
+ config is deliberately backend-specific — cloud-init never unified
+ apt/yum/zypper — and is executed by a swappable package backend
+ (pkgbackend.py), not hardcoded into the driver.
+ - `late_commands` runs in the target during install (in the chroot),
+ matching Ubuntu autoinstall's `late-commands` and kickstart `%post`.
+ This is deliberately NOT cloud-init's `runcmd`, which runs on first
+ boot — a different lifecycle, so it gets a different name. `runcmd`
+ is left unused, reserved for true first-boot semantics later.
+ - `network` is a subset of netplan's v2 schema (the same shapes cloud-init
+ uses for Network Config v2), but it is rendered to NetworkManager
+ keyfiles by netconfig.py rather than via the netplan binary, which
+ LMDE/Debian does not ship.
+
+Strict validation deliberately defangs YAML's type-coercion footguns:
+anything that does not parse cleanly into the declared types is an
+error, never a guess.
+"""
+
+import ipaddress
+import re
+import urllib.parse
+
+import yaml
+from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator
+
+SCHEMA_VERSION = 1
+
+# crypt(5) hash prefixes: $6$ sha512crypt, $5$ sha256crypt, $y$/$7$
+# yescrypt, $2a/2b/2y$ bcrypt
+_CRYPT_RE = re.compile(r"^\$(6|5|y|7|2[aby])\$\S+$")
+_USERNAME_RE = re.compile(r"^[a-z_][a-z0-9_-]{0,31}$")
+_GROUP_RE = re.compile(r"^[a-z_][a-z0-9_-]{0,31}$")
+_HOSTNAME_LABEL_RE = re.compile(r"^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$")
+_LOCALE_RE = re.compile(r"^[a-z]{2,3}(_[A-Z]{2})?(\.[A-Za-z0-9-]+)?$")
+_SIZE_RE = re.compile(r"^\d+(\.\d+)?\s*[MGT]B$")
+_MOUNT_RE = re.compile(r"^/[A-Za-z0-9._/-]*$")
+_FILESYSTEMS = ("ext4", "ext3", "ext2", "xfs", "btrfs", "vfat", "swap", "f2fs")
+_PART_FLAGS = ("esp", "bios_grub", "swap")
+_LV_NAME_RE = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_.+-]*$")
+_SUBVOL_RE = re.compile(r"^[A-Za-z0-9@._-][A-Za-z0-9@._/-]*$")
+_RAID_NAME_RE = re.compile(r"^md[0-9]+$")
+_RAID_LEVELS = (0, 1, 5, 10)
+_RAID_MIN_DEVICES = {0: 2, 1: 2, 5: 3, 10: 4}
+_RAID_METADATA = ("0.90", "1.0", "1.1", "1.2")
+# Linux interface names: up to 15 chars, no '/' or whitespace.
+_IFNAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,14}$")
+_MAC_RE = re.compile(r"^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$")
+# apt source-list filename component (no path separators, .list added by us).
+_APT_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
+# A GPG long key id / fingerprint: 8..40 hex digits (optional 0x prefix).
+_KEYID_RE = re.compile(r"^(0x)?[0-9A-Fa-f]{8,40}$")
+
+
+def _check_cidr(value):
+ if "/" not in value:
+ raise ValueError(
+ f"{value!r} must include a prefix length (e.g. 192.168.1.10/24)"
+ )
+ try:
+ ipaddress.ip_interface(value)
+ except ValueError:
+ raise ValueError(
+ f"{value!r} is not a valid IP address with prefix "
+ "(e.g. 192.168.1.10/24 or 2001:db8::5/64)"
+ )
+ return value
+
+
+def _check_ip(value, version=None):
+ try:
+ addr = ipaddress.ip_address(value)
+ except ValueError:
+ raise ValueError(f"{value!r} is not a valid IP address")
+ if version is not None and addr.version != version:
+ raise ValueError(f"{value!r} is not an IPv{version} address")
+ return value
+
+
+class ConfigError(Exception):
+ """Raised when an answer file is malformed or fails validation."""
+
+
+class _StrictModel(BaseModel):
+ model_config = ConfigDict(extra="forbid", populate_by_name=True)
+
+
+_XKB_LAYOUT_RE = re.compile(r"^[a-z][a-z0-9_-]*$")
+_XKB_VARIANT_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*$")
+_XKB_TOGGLE_RE = re.compile(r"^[a-z0-9_]+:[a-z0-9_]+$")
+
+
+def _check_xkb_layout(value):
+ if not _XKB_LAYOUT_RE.match(value):
+ raise ValueError(f"{value!r} is not a valid keyboard layout code")
+ return value
+
+
+def _check_xkb_variant(value):
+ if value and not _XKB_VARIANT_RE.match(value):
+ raise ValueError(f"{value!r} is not a valid keyboard variant")
+ return value
+
+
+class KbLayout(_StrictModel):
+ """An additional keyboard layout (XKB layout + optional variant)."""
+
+ layout: str
+ variant: str = ""
+
+ _v_layout = field_validator("layout")(_check_xkb_layout)
+ _v_variant = field_validator("variant")(_check_xkb_variant)
+
+
+class Keyboard(_StrictModel):
+ model: str = "pc105"
+ layout: str = "us"
+ variant: str = ""
+ # Extra layouts to switch between (en_CA + fr_CA, etc.); `layout`/`variant`
+ # is the primary/first. `toggle` is the XKB switch option, e.g.
+ # grp:alt_shift_toggle — only meaningful with at least one extra layout.
+ additional_layouts: list[KbLayout] = Field(default_factory=list)
+ toggle: str = None
+
+ _v_layout = field_validator("layout")(_check_xkb_layout)
+ _v_variant = field_validator("variant")(_check_xkb_variant)
+
+ @field_validator("toggle")
+ @classmethod
+ def _v_toggle(cls, value):
+ if value is not None and not _XKB_TOGGLE_RE.match(value):
+ raise ValueError(
+ f"{value!r} is not a valid XKB toggle option (e.g. "
+ "grp:alt_shift_toggle)")
+ return value
+
+ @model_validator(mode="after")
+ def _consistency(self):
+ if self.toggle and not self.additional_layouts:
+ raise ValueError(
+ "keyboard.toggle only applies when additional_layouts are set")
+ return self
+
+
+class User(_StrictModel):
+ # cloud-init key names: name, gecos, passwd, groups, ssh_authorized_keys
+ name: str
+ passwd: str
+ gecos: str = ""
+ groups: list[str] = Field(default_factory=list)
+ ssh_authorized_keys: list[str] = Field(default_factory=list)
+ # extensions (no cloud-init equivalent)
+ autologin: bool = False
+ ecryptfs_home: bool = False
+
+ @field_validator("name")
+ @classmethod
+ def _check_name(cls, value):
+ if not _USERNAME_RE.match(value):
+ raise ValueError(
+ f"{value!r} is not a valid username (lowercase, must start "
+ "with a letter or underscore, max 32 chars)"
+ )
+ return value
+
+ @field_validator("passwd")
+ @classmethod
+ def _check_crypted(cls, value):
+ if not _CRYPT_RE.match(value):
+ raise ValueError(
+ "passwd must be a crypt(5) hash (e.g. sha512crypt starting "
+ "with $6$). Plaintext passwords are not accepted; generate a "
+ "hash with: openssl passwd -6"
+ )
+ return value
+
+ @field_validator("groups")
+ @classmethod
+ def _check_groups(cls, value):
+ for group in value:
+ if not _GROUP_RE.match(group):
+ raise ValueError(f"{group!r} is not a valid group name")
+ return value
+
+ @field_validator("ssh_authorized_keys")
+ @classmethod
+ def _check_ssh_keys(cls, value):
+ for key in value:
+ if not key.startswith(("ssh-", "ecdsa-", "sk-")):
+ raise ValueError(
+ f"{key[:40]!r}... does not look like an OpenSSH public "
+ "key (expected ssh-ed25519/ssh-rsa/ecdsa-.../sk-...)"
+ )
+ return value
+
+ @property
+ def sudo(self):
+ """True if this user is granted admin via the sudo group."""
+ return "sudo" in self.groups
+
+
+class DiskMatch(_StrictModel):
+ """Stable-attribute disk selector. Raw /dev paths are rejected."""
+
+ by_id: str = Field(None, alias="by-id")
+ by_path: str = Field(None, alias="by-path")
+ model: str = None
+ size_min: str = Field(None, alias="size-min")
+ first_non_removable: bool = Field(False, alias="first-non-removable")
+
+ @field_validator("by_id", "by_path", "model")
+ @classmethod
+ def _no_raw_device_paths(cls, value):
+ if value is not None and value.startswith("/dev/"):
+ raise ValueError(
+ f"{value!r} looks like a raw device path. Device enumeration "
+ "order is not stable; select the disk by stable attributes "
+ "instead (by-id, by-path, model, size-min, "
+ "first-non-removable)"
+ )
+ return value
+
+ @field_validator("size_min")
+ @classmethod
+ def _check_size(cls, value):
+ if value is not None and not _SIZE_RE.match(value):
+ raise ValueError(
+ f"{value!r} is not a valid size (expected e.g. 500GB, 1TB)"
+ )
+ return value
+
+ @model_validator(mode="after")
+ def _at_least_one_matcher(self):
+ if not any(
+ [self.by_id, self.by_path, self.model, self.size_min,
+ self.first_non_removable]
+ ):
+ raise ValueError(
+ "storage.target.match must contain at least one matcher "
+ "(by-id, by-path, model, size-min, first-non-removable)"
+ )
+ return self
+
+
+class StorageTarget(_StrictModel):
+ match: DiskMatch
+ on_no_match: str = "abort"
+
+ @field_validator("on_no_match")
+ @classmethod
+ def _check_on_no_match(cls, value):
+ if value != "abort":
+ raise ValueError(
+ f"on_no_match: {value!r} is not supported in schema version "
+ f"{SCHEMA_VERSION} (only 'abort')"
+ )
+ return value
+
+
+class Luks(_StrictModel):
+ passphrase_source: str = "prompt-on-first-boot"
+ keyfile: str = None
+
+ @field_validator("passphrase_source")
+ @classmethod
+ def _check_source(cls, value):
+ allowed = ("prompt-on-first-boot", "tpm2", "keyfile")
+ if value not in allowed:
+ raise ValueError(
+ f"passphrase_source must be one of {', '.join(allowed)}"
+ )
+ return value
+
+ @model_validator(mode="after")
+ def _keyfile_consistency(self):
+ if self.passphrase_source == "keyfile" and not self.keyfile:
+ raise ValueError(
+ "passphrase_source: keyfile requires a 'keyfile' path"
+ )
+ if self.passphrase_source != "keyfile" and self.keyfile:
+ raise ValueError(
+ "'keyfile' is only valid with passphrase_source: keyfile"
+ )
+ return self
+
+
+def _check_size(value):
+ if value != "rest" and not _SIZE_RE.match(value):
+ raise ValueError(f"size {value!r} must be 'rest' or like 512MB / 40GB / 1TB")
+ return value
+
+
+def _check_filesystem(value):
+ if value is not None and value not in _FILESYSTEMS:
+ raise ValueError(
+ f"filesystem {value!r} is not one of {', '.join(_FILESYSTEMS)}")
+ return value
+
+
+def _check_mount(value):
+ if value is not None and value != "swap" and not _MOUNT_RE.match(value):
+ raise ValueError(f"mount {value!r} must be an absolute path or 'swap'")
+ return value
+
+
+class Subvolume(_StrictModel):
+ """A btrfs subvolume (e.g. @ at /, @home at /home) on a btrfs partition
+ or logical volume."""
+ name: str
+ mount: str
+
+ @field_validator("name")
+ @classmethod
+ def _v_name(cls, value):
+ if not _SUBVOL_RE.match(value):
+ raise ValueError(f"{value!r} is not a valid btrfs subvolume name")
+ return value
+
+ _v_mount = field_validator("mount")(_check_mount)
+
+ @model_validator(mode="after")
+ def _consistency(self):
+ if not self.mount or self.mount == "swap":
+ raise ValueError("a subvolume needs an absolute mount point")
+ return self
+
+
+class CustomPartition(_StrictModel):
+ """One partition in a custom layout. It is either mounted (mount +
+ filesystem), a btrfs filesystem split into subvolumes, an LVM physical
+ volume (lvm_pv), or a flag-only special partition (e.g. bios_grub)."""
+ size: str
+ mount: str = None
+ filesystem: str = None
+ flags: list[str] = Field(default_factory=list)
+ lvm_pv: str = None
+ raid: str = None # member of the named RAID array (like lvm_pv)
+ subvolumes: list[Subvolume] = Field(default_factory=list)
+
+ _v_size = field_validator("size")(_check_size)
+ _v_fs = field_validator("filesystem")(_check_filesystem)
+ _v_mount = field_validator("mount")(_check_mount)
+
+ @field_validator("flags")
+ @classmethod
+ def _v_flags(cls, value):
+ for flag in value:
+ if flag not in _PART_FLAGS:
+ raise ValueError(
+ f"partition flag {flag!r} is not one of "
+ f"{', '.join(_PART_FLAGS)}")
+ return value
+
+ @model_validator(mode="after")
+ def _consistency(self):
+ if self.raid is not None:
+ if (self.mount or self.filesystem or self.lvm_pv
+ or self.subvolumes or self.flags):
+ raise ValueError(
+ "a RAID member partition (raid:) takes no mount/filesystem/"
+ "lvm_pv/subvolumes/flags")
+ return self
+ if self.subvolumes:
+ if self.filesystem != "btrfs":
+ raise ValueError("subvolumes require filesystem: btrfs")
+ if self.mount or self.lvm_pv or "bios_grub" in self.flags:
+ raise ValueError(
+ "a partition with subvolumes takes no mount/lvm_pv/"
+ "bios_grub (the subvolumes provide the mounts)")
+ return self
+ if self.lvm_pv is not None:
+ if self.mount or self.filesystem:
+ raise ValueError(
+ "an LVM PV partition (lvm_pv) takes no mount/filesystem")
+ elif "bios_grub" in self.flags:
+ if self.mount or self.filesystem:
+ raise ValueError(
+ "a bios_grub partition takes no mount/filesystem")
+ elif not self.mount or not self.filesystem:
+ raise ValueError(
+ "a partition needs both mount and filesystem (or lvm_pv, "
+ "subvolumes, or the bios_grub flag)")
+ if "esp" in self.flags:
+ if self.filesystem != "vfat" or self.mount != "/boot/efi":
+ raise ValueError(
+ "an esp partition must be filesystem: vfat mounted at "
+ "/boot/efi")
+ elif self.mount == "/boot/efi":
+ # The reverse: /boot/efi without the esp flag would create a GPT
+ # entry lacking the EFI System Partition type GUID. The OS mounts
+ # it fine and GRUB writes to it, so the install looks healthy —
+ # but UEFI firmware won't recognise it as an ESP, and the machine
+ # fails to boot (often only surfacing on other hardware or after a
+ # firmware update). Require the flag so this can't slip through.
+ raise ValueError(
+ "the /boot/efi partition must carry the 'esp' flag, so its "
+ "GPT entry gets the EFI System Partition type GUID that UEFI "
+ "firmware boots from")
+ if (self.mount == "swap") != (self.filesystem == "swap"):
+ raise ValueError("mount: swap and filesystem: swap go together")
+ return self
+
+
+class LvmVolume(_StrictModel):
+ """A logical volume in a custom layout, on a VG backed by an lvm_pv
+ partition."""
+ vg: str
+ lv: str
+ size: str
+ mount: str = None
+ filesystem: str = None
+ subvolumes: list[Subvolume] = Field(default_factory=list)
+
+ _v_size = field_validator("size")(_check_size)
+ _v_fs = field_validator("filesystem")(_check_filesystem)
+ _v_mount = field_validator("mount")(_check_mount)
+
+ @field_validator("lv")
+ @classmethod
+ def _v_lv(cls, value):
+ if not _LV_NAME_RE.match(value):
+ raise ValueError(f"{value!r} is not a valid logical-volume name")
+ return value
+
+ @model_validator(mode="after")
+ def _consistency(self):
+ if self.subvolumes:
+ if self.filesystem != "btrfs":
+ raise ValueError("subvolumes require filesystem: btrfs")
+ if self.mount:
+ raise ValueError(
+ "an LVM volume with subvolumes takes no mount (the "
+ "subvolumes provide the mounts)")
+ return self
+ if not self.mount or not self.filesystem:
+ raise ValueError("an LVM volume needs both mount and filesystem")
+ if (self.mount == "swap") != (self.filesystem == "swap"):
+ raise ValueError("mount: swap and filesystem: swap go together")
+ return self
+
+
+class RaidArray(_StrictModel):
+ """A software-RAID (md) array over member partitions (marked `raid: `
+ on each disk). Like a partition, it is then mounted, an LVM PV, or btrfs
+ subvolumes."""
+ name: str # md0, md1, ...
+ level: int # 0, 1, 5, 10
+ mount: str = None
+ filesystem: str = None
+ lvm_pv: str = None
+ subvolumes: list[Subvolume] = Field(default_factory=list)
+ metadata: str = "1.2"
+
+ _v_fs = field_validator("filesystem")(_check_filesystem)
+ _v_mount = field_validator("mount")(_check_mount)
+
+ @field_validator("name")
+ @classmethod
+ def _v_name(cls, value):
+ if not _RAID_NAME_RE.match(value):
+ raise ValueError(f"{value!r} is not a valid md array name (md0, md1, ...)")
+ return value
+
+ @field_validator("level")
+ @classmethod
+ def _v_level(cls, value):
+ if value not in _RAID_LEVELS:
+ raise ValueError(
+ f"RAID level must be one of {', '.join(map(str, _RAID_LEVELS))}")
+ return value
+
+ @field_validator("metadata")
+ @classmethod
+ def _v_metadata(cls, value):
+ if value not in _RAID_METADATA:
+ raise ValueError(
+ f"RAID metadata must be one of {', '.join(_RAID_METADATA)}")
+ return value
+
+ @model_validator(mode="after")
+ def _consistency(self):
+ if self.subvolumes:
+ if self.filesystem != "btrfs":
+ raise ValueError("subvolumes require filesystem: btrfs")
+ if self.mount or self.lvm_pv:
+ raise ValueError(
+ "a RAID array with subvolumes takes no mount/lvm_pv")
+ return self
+ if self.lvm_pv is not None:
+ if self.mount or self.filesystem:
+ raise ValueError(
+ "a RAID array used as an LVM PV takes no mount/filesystem")
+ elif not self.mount or not self.filesystem:
+ raise ValueError(
+ "a RAID array needs both mount and filesystem (or lvm_pv, or "
+ "subvolumes)")
+ if (self.mount == "swap") != (self.filesystem == "swap"):
+ raise ValueError("mount: swap and filesystem: swap go together")
+ return self
+
+
+class Storage(_StrictModel):
+ target: StorageTarget = None
+ layout: str = "simple"
+ luks: Luks = None
+ disks: list[StorageTarget] = Field(default_factory=list)
+ partitions: list[CustomPartition] = Field(default_factory=list)
+ lvm: list[LvmVolume] = Field(default_factory=list)
+ raid: list[RaidArray] = Field(default_factory=list)
+
+ @field_validator("layout")
+ @classmethod
+ def _check_layout(cls, value):
+ allowed = ("simple", "lvm", "lvm-on-luks", "custom")
+ if value not in allowed:
+ raise ValueError(f"layout must be one of {', '.join(allowed)}")
+ return value
+
+ @model_validator(mode="after")
+ def _consistency(self):
+ if self.layout == "lvm-on-luks" and self.luks is None:
+ self.luks = Luks() # fail-safe default: prompt on first boot
+ if self.layout != "lvm-on-luks" and self.luks is not None:
+ raise ValueError("'luks' is only valid with layout: lvm-on-luks")
+
+ if self.layout != "custom":
+ if self.partitions or self.lvm or self.raid or self.disks:
+ raise ValueError(
+ "'partitions'/'lvm'/'raid'/'disks' are only valid with "
+ "layout: custom")
+ if self.target is None:
+ raise ValueError("storage.target is required")
+ return self
+
+ # --- custom layout ---
+ # Disk selection: a single `target`, or a `disks:` list (required for
+ # RAID, since members live on different disks). Exactly one of the two.
+ if self.disks and self.target is not None:
+ raise ValueError(
+ "use either storage.target (single disk) or storage.disks "
+ "(multi-disk), not both")
+ if not self.disks and self.target is None:
+ raise ValueError("storage.target (or storage.disks) is required")
+ if self.raid and not self.disks:
+ raise ValueError(
+ "software RAID requires storage.disks (members live on "
+ "separate disks)")
+
+ if not self.partitions:
+ raise ValueError(
+ "layout: custom requires a non-empty 'partitions' list")
+
+ # RAID referential integrity + device counts. Members are the
+ # raid-marked partitions, replicated across each disk, so the device
+ # count is the number of disks.
+ ndisks = len(self.disks) if self.disks else 1
+ part_raids = {p.raid for p in self.partitions if p.raid}
+ array_names = {a.name for a in self.raid}
+ if part_raids - array_names:
+ raise ValueError(
+ "partition(s) reference RAID array(s) with no definition: "
+ + ", ".join(sorted(part_raids - array_names)))
+ if array_names - part_raids:
+ raise ValueError(
+ "RAID array(s) with no member partitions: "
+ + ", ".join(sorted(array_names - part_raids)))
+ if len(array_names) != len(self.raid):
+ raise ValueError("duplicate RAID array name(s)")
+ for array in self.raid:
+ need = _RAID_MIN_DEVICES[array.level]
+ if ndisks < need:
+ raise ValueError(
+ f"RAID{array.level} array {array.name} needs at least "
+ f"{need} disks, but storage.disks has {ndisks}")
+
+ mounts = ([p.mount for p in self.partitions if p.mount]
+ + [sv.mount for p in self.partitions for sv in p.subvolumes]
+ + [v.mount for v in self.lvm if v.mount]
+ + [sv.mount for v in self.lvm for sv in v.subvolumes]
+ + [a.mount for a in self.raid if a.mount]
+ + [sv.mount for a in self.raid for sv in a.subvolumes])
+ if mounts.count("/") != 1:
+ raise ValueError("custom layout needs exactly one '/' mount point")
+ dupes = sorted({m for m in mounts
+ if m != "swap" and mounts.count(m) > 1})
+ if dupes:
+ raise ValueError(f"duplicate mount point(s): {', '.join(dupes)}")
+ if sum(1 for p in self.partitions if p.size == "rest") > 1:
+ raise ValueError("at most one partition may use size: rest")
+
+ # LVM PVs may come from partitions OR RAID arrays.
+ pv_vgs = ({p.lvm_pv for p in self.partitions if p.lvm_pv}
+ | {a.lvm_pv for a in self.raid if a.lvm_pv})
+ lv_vgs = {v.vg for v in self.lvm}
+ if lv_vgs - pv_vgs:
+ raise ValueError(
+ "lvm volume(s) reference VG(s) with no lvm_pv: "
+ + ", ".join(sorted(lv_vgs - pv_vgs)))
+ if pv_vgs - lv_vgs:
+ raise ValueError(
+ "lvm_pv(s) reference VG(s) with no logical volumes: "
+ + ", ".join(sorted(pv_vgs - lv_vgs)))
+ for vg in lv_vgs:
+ if sum(1 for v in self.lvm if v.vg == vg and v.size == "rest") > 1:
+ raise ValueError(f"at most one LV in VG {vg} may use size: rest")
+ names = [v.lv for v in self.lvm if v.vg == vg]
+ dup_lvs = sorted({n for n in names if names.count(n) > 1})
+ if dup_lvs:
+ raise ValueError(
+ f"duplicate logical-volume name(s) in VG {vg}: "
+ + ", ".join(dup_lvs))
+ return self
+
+
+class AptSource(_StrictModel):
+ """One entry in cloud-init's `apt.sources` map. Same shapes as cloud-init
+ (`source`, `key`, `keyid`, `keyserver`), plus a `key_url` extension and an
+ optional `filename` override. The signing key may be given exactly one of:
+ `key` (inline ASCII-armored), `keyid` (fetched from `keyserver`), or
+ `key_url` (fetched over https). cloud-init keeps repo config per-backend
+ (it never unified apt/yum/zypper), so this stays apt-specific by design."""
+
+ source: str # sources.list line, e.g.
+ # "deb https://repo trixie main"
+ key: str = None # inline ASCII-armored public key
+ keyid: str = None # key id/fingerprint to fetch
+ keyserver: str = "keyserver.ubuntu.com"
+ key_url: str = None # extension: fetch key over https
+ filename: str = None # override the .list basename
+
+ @field_validator("source")
+ @classmethod
+ def _check_source(cls, value):
+ if not (value.startswith("deb ") or value.startswith("deb-src ")):
+ raise ValueError(
+ "apt source must be a sources.list line starting with 'deb ' "
+ "or 'deb-src ' (ppa: shorthand is not supported)"
+ )
+ return value
+
+ @field_validator("key_url")
+ @classmethod
+ def _https_only(cls, value):
+ if value is not None and not value.startswith("https://"):
+ raise ValueError(
+ "apt source key_url must use https:// — a key fetched over "
+ "plain HTTP can be tampered with in transit"
+ )
+ return value
+
+ @field_validator("keyid")
+ @classmethod
+ def _check_keyid(cls, value):
+ if value is not None and not _KEYID_RE.match(value):
+ raise ValueError(
+ f"{value!r} is not a valid GPG key id (8..40 hex digits)"
+ )
+ return value
+
+ @field_validator("filename")
+ @classmethod
+ def _check_filename(cls, value):
+ if value is not None and not _APT_NAME_RE.match(value):
+ raise ValueError(
+ f"{value!r} is not a valid filename (letters, digits, '.', "
+ "'_', '-'; no path separators)"
+ )
+ return value
+
+ @model_validator(mode="after")
+ def _consistency(self):
+ given = [k for k in ("key", "keyid", "key_url")
+ if getattr(self, k) is not None]
+ if len(given) > 1:
+ raise ValueError(
+ "give at most one signing key per source (key, keyid, or "
+ f"key_url) — got {', '.join(given)}"
+ )
+ return self
+
+
+class Apt(_StrictModel):
+ """cloud-init's `apt:` section (the `sources` subset we support)."""
+
+ sources: dict[str, AptSource] = Field(default_factory=dict)
+
+ @field_validator("sources")
+ @classmethod
+ def _check_names(cls, value):
+ for name in value:
+ if not _APT_NAME_RE.match(name):
+ raise ValueError(
+ f"{name!r} is not a valid apt source name (letters, "
+ "digits, '.', '_', '-')"
+ )
+ return value
+
+
+class Kernel(_StrictModel):
+ # Appended to GRUB_CMDLINE_LINUX_DEFAULT on the installed system:
+ # driver blacklists, sysctl-ish params, etc. for headless/fleet hosts.
+ cmdline_extra: str = ""
+ # Provision a full serial console on the installed system, e.g.
+ # "ttyS0" or "ttyS0,115200". Drops quiet/splash (so the boot, and a
+ # LUKS unlock prompt, is visible on serial rather than grabbed by
+ # plymouth), adds console= to the kernel cmdline, and points GRUB's
+ # terminal at the serial line. The channel an admin uses over IPMI
+ # Serial-over-LAN on a headless box.
+ serial_console: str = ""
+
+ @field_validator("serial_console")
+ @classmethod
+ def _check_serial(cls, value):
+ if value and not re.match(r"^ttyS\d+(,\d+)?$", value):
+ raise ValueError(
+ f"{value!r} is not a valid serial console "
+ "(expected e.g. ttyS0 or ttyS0,115200)"
+ )
+ return value
+
+
+class Oem(_StrictModel):
+ enabled: bool = False
+
+
+class Drivers(_StrictModel):
+ """Third-party/restricted driver installation (autoinstall's
+ `drivers: {install: true}`)."""
+
+ install: bool = False
+
+
+_FLATPAK_APP_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]+$")
+_FLATPAK_REMOTE_RE = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
+
+
+class FlatpakRemote(_StrictModel):
+ name: str
+ url: str # an https .flatpakrepo URL
+
+ @field_validator("name")
+ @classmethod
+ def _v_name(cls, value):
+ if not _FLATPAK_REMOTE_RE.match(value):
+ raise ValueError(f"{value!r} is not a valid flatpak remote name")
+ return value
+
+ @field_validator("url")
+ @classmethod
+ def _v_url(cls, value):
+ if not value.startswith("https://"):
+ raise ValueError("flatpak remote url must be https://")
+ return value
+
+
+class Flatpak(_StrictModel):
+ """Flatpak remotes + apps. Remotes are added at install time (config only);
+ the apps are installed by a first-boot one-shot (flatpak install wants a
+ running system, and the app payloads are large)."""
+
+ remotes: list[FlatpakRemote] = Field(default_factory=list)
+ install: list[str] = Field(default_factory=list) # app IDs
+
+ @field_validator("install")
+ @classmethod
+ def _v_install(cls, value):
+ for app in value:
+ if not _FLATPAK_APP_RE.match(app):
+ raise ValueError(f"{app!r} is not a valid flatpak app id")
+ return value
+
+ @model_validator(mode="after")
+ def _consistency(self):
+ names = [r.name for r in self.remotes]
+ if len(names) != len(set(names)):
+ raise ValueError("duplicate flatpak remote name(s)")
+ if self.install and not self.remotes:
+ raise ValueError(
+ "flatpak.install needs at least one remote to install from "
+ "(e.g. flathub)")
+ return self
+
+
+class OnFailure(_StrictModel):
+ """Per-failure-mode policy. Everything defaults to abort (fail closed)."""
+
+ partition_mismatch: str = "abort"
+ network_unavailable: str = "abort"
+ package_install_failure: str = "abort"
+ post_install_script_failure: str = "abort"
+ early_command_failure: str = "abort"
+
+ @field_validator("*")
+ @classmethod
+ def _abort_or_continue(cls, value):
+ if value not in ("abort", "continue"):
+ raise ValueError("failure policy must be 'abort' or 'continue'")
+ return value
+
+
+class CaCerts(_StrictModel):
+ """System CA trust store, mirroring cloud-init's `ca_certs:` module:
+ inline PEM CA certificates added to /etc/ssl/certs (via the OS's
+ update-ca-certificates). This is the SYSTEM trust store consulted by apt,
+ curl, and TLS clients — distinct from any per-connection 802.1X/EAP trust."""
+
+ remove_defaults: bool = False
+ trusted: list[str] = Field(default_factory=list)
+
+ @field_validator("trusted")
+ @classmethod
+ def _v_trusted(cls, value):
+ for cert in value:
+ if "PRIVATE KEY" in cert:
+ raise ValueError(
+ "ca_certs.trusted must contain only CA certificates, never "
+ "a private key (a trust store holds public certs only)")
+ if ("BEGIN CERTIFICATE" not in cert
+ or "END CERTIFICATE" not in cert):
+ raise ValueError(
+ "ca_certs.trusted entries must be PEM certificates "
+ "(-----BEGIN CERTIFICATE----- ... -----END CERTIFICATE-----)")
+ return value
+
+ @model_validator(mode="after")
+ def _consistency(self):
+ if self.remove_defaults and not self.trusted:
+ raise ValueError(
+ "ca_certs.remove_defaults with no 'trusted' certs would leave "
+ "an empty trust store and break TLS (apt-over-HTTPS, etc.)")
+ return self
+
+
+class Logging(_StrictModel):
+ destination: str = "/var/log/live-installer-auto.log"
+ also_serial: str = None
+
+
+# --- network: a subset of the netplan v2 schema --------------------------
+# We adopt netplan's *shapes* (the same key names and structure) but render
+# them ourselves to NetworkManager keyfiles, the format Mint/LMDE installed
+# systems already use — we do NOT depend on the netplan binary (LMDE/Debian
+# does not ship it). This configures the INSTALLED system's networking; the
+# live session's own networking is unaffected.
+
+
+class NameServers(_StrictModel):
+ addresses: list[str] = Field(default_factory=list)
+ search: list[str] = Field(default_factory=list)
+
+ @field_validator("addresses")
+ @classmethod
+ def _v_addresses(cls, value):
+ for addr in value:
+ _check_ip(addr)
+ return value
+
+
+class Route(_StrictModel):
+ to: str # "default" or a CIDR (e.g. 10.0.0.0/24)
+ via: str # next-hop IP
+ metric: int = None
+
+ @field_validator("to")
+ @classmethod
+ def _v_to(cls, value):
+ if value == "default":
+ return value
+ return _check_cidr(value)
+
+ @field_validator("via")
+ @classmethod
+ def _v_via(cls, value):
+ return _check_ip(value)
+
+ @model_validator(mode="after")
+ def _consistency(self):
+ # default routes may be either family; a CIDR target and via must
+ # agree on family (no IPv6 next-hop for an IPv4 destination).
+ if self.to != "default":
+ to_v = ipaddress.ip_network(self.to, strict=False).version
+ via_v = ipaddress.ip_address(self.via).version
+ if to_v != via_v:
+ raise ValueError(
+ f"route to {self.to!r} (IPv{to_v}) cannot use an IPv{via_v} "
+ f"via {self.via!r}"
+ )
+ return self
+
+
+class Match(_StrictModel):
+ """Select the physical device. macaddress binds by hardware address
+ (survives kernel interface renaming); name binds by interface name."""
+
+ macaddress: str = None
+ name: str = None
+
+ @field_validator("macaddress")
+ @classmethod
+ def _v_mac(cls, value):
+ if value is not None and not _MAC_RE.match(value):
+ raise ValueError(f"{value!r} is not a valid MAC address")
+ return value
+
+ @field_validator("name")
+ @classmethod
+ def _v_name(cls, value):
+ if value is not None and not _IFNAME_RE.match(value):
+ raise ValueError(f"{value!r} is not a valid interface name")
+ return value
+
+ @model_validator(mode="after")
+ def _consistency(self):
+ if self.macaddress is None and self.name is None:
+ raise ValueError("match must set at least one of macaddress, name")
+ return self
+
+
+class _IpConfig(_StrictModel):
+ """IP settings shared by ethernets and vlans."""
+
+ dhcp4: bool = False
+ dhcp6: bool = False
+ addresses: list[str] = Field(default_factory=list)
+ gateway4: str = None
+ gateway6: str = None
+ nameservers: NameServers = Field(default_factory=NameServers)
+ routes: list[Route] = Field(default_factory=list)
+
+ @field_validator("addresses")
+ @classmethod
+ def _v_addresses(cls, value):
+ for addr in value:
+ _check_cidr(addr)
+ return value
+
+ @field_validator("gateway4")
+ @classmethod
+ def _v_gateway4(cls, value):
+ if value is None:
+ return value
+ return _check_ip(value, version=4)
+
+ @field_validator("gateway6")
+ @classmethod
+ def _v_gateway6(cls, value):
+ if value is None:
+ return value
+ return _check_ip(value, version=6)
+
+ @model_validator(mode="after")
+ def _ip_consistency(self):
+ has_v4 = any(":" not in a for a in self.addresses)
+ has_v6 = any(":" in a for a in self.addresses)
+ if self.gateway4 and not has_v4:
+ raise ValueError("gateway4 set but no IPv4 address configured")
+ if self.gateway6 and not has_v6:
+ raise ValueError("gateway6 set but no IPv6 address configured")
+ return self
+
+
+_EAP_METHODS = ("tls", "peap", "ttls")
+_EAP_PHASE2 = ("mschapv2", "mschap", "pap", "chap", "gtc", "md5")
+
+
+def _check_cert_path(value):
+ # by-reference only in v1: an absolute path to a cert/key already on the
+ # target (pre-baked into the image or delivered out-of-band). Inline PEM
+ # is deliberately deferred so a private key need not transit the answer file.
+ if value is None:
+ return value
+ if not value.startswith("/") or any(c in value for c in '"\'\n\r'):
+ raise ValueError(
+ f"{value!r} must be an absolute path to a file on the target "
+ "(inline certs/keys are not supported yet)")
+ return value
+
+
+class Auth(_StrictModel):
+ """802.1X/EAP authentication for a wired ethernet or a wifi access point
+ (netplan v2's `auth:` block). Certs/keys are referenced by absolute path on
+ the target. Rendered to the NetworkManager keyfile's [802-1x] section."""
+
+ method: str # tls | peap | ttls
+ identity: str = None
+ anonymous_identity: str = Field(None, alias="anonymous-identity")
+ ca_certificate: str = Field(None, alias="ca-certificate")
+ client_certificate: str = Field(None, alias="client-certificate")
+ client_key: str = Field(None, alias="client-key")
+ client_key_password: str = Field(None, alias="client-key-password")
+ phase2_auth: str = Field(None, alias="phase2-auth")
+ password: str = None
+ # Opt out of server validation (no ca-certificate). Insecure (rogue-AP
+ # credential theft); refused by default.
+ allow_unvalidated: bool = Field(False, alias="allow-unvalidated")
+
+ _v_ca = field_validator("ca_certificate")(_check_cert_path)
+ _v_cc = field_validator("client_certificate")(_check_cert_path)
+ _v_ck = field_validator("client_key")(_check_cert_path)
+
+ @field_validator("method")
+ @classmethod
+ def _v_method(cls, value):
+ if value not in _EAP_METHODS:
+ raise ValueError(
+ f"EAP method must be one of {', '.join(_EAP_METHODS)}")
+ return value
+
+ @field_validator("phase2_auth")
+ @classmethod
+ def _v_phase2(cls, value):
+ if value is not None and value not in _EAP_PHASE2:
+ raise ValueError(
+ f"phase2-auth must be one of {', '.join(_EAP_PHASE2)}")
+ return value
+
+ @model_validator(mode="after")
+ def _consistency(self):
+ if not self.ca_certificate and not self.allow_unvalidated:
+ raise ValueError(
+ "EAP without a ca-certificate does not validate the server "
+ "(rogue-AP credential theft risk); set ca-certificate, or "
+ "allow-unvalidated: true to accept the risk")
+ if self.method == "tls":
+ if not self.client_certificate or not self.client_key:
+ raise ValueError(
+ "method: tls (EAP-TLS) requires client-certificate and "
+ "client-key")
+ else: # peap / ttls
+ if not self.identity or not self.password:
+ raise ValueError(
+ f"method: {self.method} requires identity and password")
+ if not self.phase2_auth:
+ raise ValueError(
+ f"method: {self.method} requires phase2-auth (the inner "
+ "method, e.g. mschapv2)")
+ return self
+
+
+class EthernetConfig(_IpConfig):
+ match: Match = None
+ auth: Auth = None # wired 802.1X
+
+
+class VlanConfig(_IpConfig):
+ id: int
+ link: str
+
+ @field_validator("id")
+ @classmethod
+ def _v_id(cls, value):
+ if not 0 <= value <= 4094:
+ raise ValueError("vlan id must be between 0 and 4094")
+ return value
+
+ @field_validator("link")
+ @classmethod
+ def _v_link(cls, value):
+ if not _IFNAME_RE.match(value):
+ raise ValueError(f"{value!r} is not a valid interface id")
+ return value
+
+
+def _check_ssid(value):
+ # 802.11 SSIDs are 1..32 octets. NM keys the connection on the SSID, so an
+ # empty or over-long one can never match a real network.
+ if not 1 <= len(value.encode("utf-8")) <= 32:
+ raise ValueError(
+ f"{value!r} is not a valid SSID (1..32 bytes)"
+ )
+ return value
+
+
+def _check_psk(value):
+ # WPA-PSK: either an 8..63 character passphrase or a 64-hex-digit raw key.
+ if len(value) == 64 and all(c in "0123456789abcdefABCDEF" for c in value):
+ return value
+ if 8 <= len(value) <= 63:
+ return value
+ raise ValueError(
+ "wifi password must be an 8..63 character WPA passphrase or a "
+ "64-hex-digit PSK"
+ )
+
+
+class AccessPoint(_StrictModel):
+ """One wifi network (a netplan/cloud-init access-points entry). `password`
+ None means an open network; otherwise WPA-PSK. `hidden` marks a
+ non-broadcast SSID so the client probes for it actively."""
+
+ password: str = None # WPA-PSK passphrase (omit for open or EAP)
+ hidden: bool = False
+ auth: Auth = None # WPA-Enterprise / EAP (instead of a PSK)
+
+ @field_validator("password")
+ @classmethod
+ def _v_password(cls, value):
+ if value is None:
+ return value
+ return _check_psk(value)
+
+ @model_validator(mode="after")
+ def _consistency(self):
+ if self.auth and self.password:
+ raise ValueError(
+ "a wifi access point uses either a WPA-PSK password or EAP "
+ "(auth), not both")
+ return self
+
+
+class WifiConfig(_IpConfig):
+ match: Match = None
+ access_points: dict[str, AccessPoint] = Field(
+ default_factory=dict, alias="access-points")
+
+ @field_validator("access_points")
+ @classmethod
+ def _v_access_points(cls, value):
+ for ssid in value:
+ _check_ssid(ssid)
+ return value
+
+ @model_validator(mode="after")
+ def _wifi_consistency(self):
+ # Enforced here (not in the field validator) so an omitted
+ # access-points key — which never triggers a field validator — is
+ # still rejected.
+ if not self.access_points:
+ raise ValueError(
+ "a wifi interface needs at least one access-points entry")
+ return self
+
+
+class Network(_StrictModel):
+ """A subset of netplan's v2 schema: ethernets, wifis, and vlans with static
+ or DHCP addressing. Bonds/bridges are intentionally not modelled yet."""
+
+ version: int = 2
+ ethernets: dict[str, EthernetConfig] = Field(default_factory=dict)
+ wifis: dict[str, WifiConfig] = Field(default_factory=dict)
+ vlans: dict[str, VlanConfig] = Field(default_factory=dict)
+
+ @field_validator("version")
+ @classmethod
+ def _v_version(cls, value):
+ if value != 2:
+ raise ValueError("network.version must be 2 (netplan v2 schema)")
+ return value
+
+ @field_validator("ethernets", "wifis", "vlans")
+ @classmethod
+ def _v_ids(cls, value):
+ for name in value:
+ if not _IFNAME_RE.match(name):
+ raise ValueError(f"{name!r} is not a valid interface id")
+ return value
+
+ @model_validator(mode="after")
+ def _consistency(self):
+ groups = {"ethernet": self.ethernets, "wifi": self.wifis,
+ "vlan": self.vlans}
+ seen = {}
+ for kind, table in groups.items():
+ for name in table:
+ if name in seen:
+ raise ValueError(
+ f"interface id {name!r} used for both a {seen[name]} "
+ f"and a {kind}")
+ seen[name] = kind
+ known = set(seen)
+ for name, vlan in self.vlans.items():
+ if vlan.link not in known:
+ raise ValueError(
+ f"vlan {name!r} link {vlan.link!r} is not a defined "
+ "ethernet, wifi, or vlan"
+ )
+ if vlan.link == name:
+ raise ValueError(f"vlan {name!r} cannot link to itself")
+ return self
+
+
+def storage_has_btrfs_root(storage):
+ """True if the storage layout produces a btrfs root on an `@` subvolume —
+ the prerequisite for the timeshift-btrfs snapshot backend. Shared by the
+ cross-validation here and the backend's is_compatible()."""
+ if storage.layout != "custom":
+ return False
+ for entry in list(storage.partitions) + list(storage.lvm):
+ if getattr(entry, "filesystem", None) == "btrfs":
+ for sub in entry.subvolumes:
+ if sub.name == "@" and sub.mount == "/":
+ return True
+ return False
+
+
+class SnapshotSchedule(_StrictModel):
+ """Tool-neutral schedule; translated per backend. Each count is how many
+ snapshots of that level to keep (0 disables it)."""
+
+ boot: bool = False
+ daily: int = 0
+ weekly: int = 0
+ monthly: int = 0
+
+ @field_validator("daily", "weekly", "monthly")
+ @classmethod
+ def _v_count(cls, value):
+ if value < 0:
+ raise ValueError("snapshot counts must be >= 0")
+ return value
+
+
+class Snapshots(_StrictModel):
+ """Configure a snapshot tool at install time. v1: timeshift-btrfs only,
+ behind a backend seam (rsync/snapper can be added later)."""
+
+ enabled: bool = True
+ backend: str = "timeshift-btrfs"
+ schedule: SnapshotSchedule = Field(default_factory=SnapshotSchedule)
+ initial_snapshot: bool = False
+
+ @field_validator("backend")
+ @classmethod
+ def _v_backend(cls, value):
+ if value != "timeshift-btrfs":
+ raise ValueError(
+ "v1 supports only backend: timeshift-btrfs (rsync/snapper and "
+ "backend: auto are not implemented yet)")
+ return value
+
+
+def _check_hostname(value):
+ if value is None:
+ return value
+ if len(value) > 253 or not all(
+ _HOSTNAME_LABEL_RE.match(label) for label in value.split(".")
+ ):
+ raise ValueError(f"{value!r} is not a valid hostname")
+ return value
+
+
+def _check_locale(value):
+ if not _LOCALE_RE.match(value):
+ raise ValueError(
+ f"{value!r} is not a valid locale (expected e.g. en_US.UTF-8)"
+ )
+ return value
+
+
+def _check_timezone(value):
+ try:
+ from zoneinfo import ZoneInfo
+
+ ZoneInfo(value)
+ except ImportError: # minimal live environment without tzdata access
+ if not re.match(r"^[A-Za-z_+-]+(/[A-Za-z0-9_+-]+)*$", value):
+ raise ValueError(f"{value!r} is not a valid timezone")
+ except Exception:
+ raise ValueError(
+ f"{value!r} is not a valid IANA timezone (expected e.g. "
+ "America/Toronto)"
+ )
+ return value
+
+
+class AutoInstallConfig(_StrictModel):
+ """Top-level answer file."""
+
+ version: int
+ # cloud-init style: identity at the top level
+ locale: str
+ timezone: str
+ storage: Storage
+ users: list[User] = Field(min_length=1)
+ hostname: str = None
+ keyboard: Keyboard = Field(default_factory=Keyboard)
+ additional_locales: list[str] = Field(default_factory=list) # also generated
+ proxy: str = None # system http(s) proxy
+ ca_certs: CaCerts = None # cloud-init: ca_certs:
+ network: Network = None # netplan v2 subset
+ packages: list[str] = Field(default_factory=list) # cloud-init: installs
+ package_remove: list[str] = Field(default_factory=list) # extension
+ apt: Apt = None # cloud-init: apt:
+ early_commands: list[str] = Field(default_factory=list) # %pre, live env
+ late_commands: list[str] = Field(default_factory=list) # autoinstall-style
+ kernel: Kernel = Field(default_factory=Kernel)
+ oem: Oem = Field(default_factory=Oem)
+ drivers: Drivers = Field(default_factory=Drivers) # autoinstall: drivers:
+ flatpak: Flatpak = None # flatpak remotes + apps
+ snapshots: Snapshots = None # Timeshift config
+ on_failure: OnFailure = Field(default_factory=OnFailure)
+ logging: Logging = Field(default_factory=Logging)
+
+ @field_validator("version")
+ @classmethod
+ def _check_version(cls, value):
+ if value != SCHEMA_VERSION:
+ raise ValueError(
+ f"unsupported schema version {value!r}; this installer "
+ f"supports version {SCHEMA_VERSION}"
+ )
+ return value
+
+ @field_validator("locale")
+ @classmethod
+ def _v_locale(cls, value):
+ return _check_locale(value)
+
+ @field_validator("additional_locales")
+ @classmethod
+ def _v_additional_locales(cls, value):
+ for loc in value:
+ _check_locale(loc)
+ return value
+
+ @field_validator("proxy")
+ @classmethod
+ def _v_proxy(cls, value):
+ if value is None:
+ return value
+ parts = urllib.parse.urlsplit(value)
+ if parts.scheme not in ("http", "https") or not parts.hostname:
+ raise ValueError(
+ f"{value!r} is not a valid proxy URL (e.g. "
+ "http://proxy.example.com:3128)")
+ # apt.conf string and shell env values; forbid the chars that would
+ # let it break out of either quoting context.
+ if any(c in value for c in '"\'\n\r \t'):
+ raise ValueError("proxy URL must not contain quotes or whitespace")
+ return value
+
+ @field_validator("timezone")
+ @classmethod
+ def _v_timezone(cls, value):
+ return _check_timezone(value)
+
+ @field_validator("hostname")
+ @classmethod
+ def _v_hostname(cls, value):
+ return _check_hostname(value)
+
+ @model_validator(mode="after")
+ def _check_users(self):
+ names = [user.name for user in self.users]
+ if len(names) != len(set(names)):
+ raise ValueError("duplicate usernames in 'users'")
+ if sum(1 for user in self.users if user.autologin) > 1:
+ raise ValueError("only one user may have autologin: true")
+ return self
+
+ @model_validator(mode="after")
+ def _check_snapshots(self):
+ # Cross-section guard (spans snapshots + storage): the timeshift-btrfs
+ # backend needs the btrfs @/@home layout, which is decided in storage.
+ # Catching the mismatch here means a config that can't make snapshots
+ # fails at validation, not silently after install.
+ snaps = self.snapshots
+ if snaps is None or not snaps.enabled:
+ return self
+ if snaps.backend == "timeshift-btrfs" and not storage_has_btrfs_root(
+ self.storage):
+ raise ValueError(
+ "snapshots backend timeshift-btrfs requires a btrfs root on an "
+ "'@' subvolume, but storage did not produce one — use "
+ "layout: custom with a btrfs root split into @ (/) and "
+ "@home (/home)")
+ return self
+
+
+def _format_errors(exc):
+ lines = []
+ for error in exc.errors():
+ location = ".".join(str(part) for part in error["loc"])
+ lines.append(f" {location or ''}: {error['msg']}")
+ return "\n".join(lines)
+
+
+def parse_config(text):
+ """Parse and validate answer-file YAML text into AutoInstallConfig.
+
+ Raises ConfigError with a human-readable message on any problem.
+ The installer must treat that as fatal — never guess at intent.
+ """
+ try:
+ data = yaml.safe_load(text)
+ except yaml.YAMLError as exc:
+ raise ConfigError(f"Answer file is not valid YAML: {exc}")
+ if not isinstance(data, dict):
+ raise ConfigError("Answer file must be a YAML mapping")
+ try:
+ return AutoInstallConfig.model_validate(data)
+ except ValidationError as exc:
+ raise ConfigError(
+ "Answer file failed validation:\n" + _format_errors(exc)
+ )
+
+
+def load_config(path):
+ """Read and validate an answer file from disk."""
+ try:
+ with open(path, encoding="utf-8") as f:
+ text = f.read()
+ except OSError as exc:
+ raise ConfigError(f"Cannot read answer file {path}: {exc}")
+ return parse_config(text)
diff --git a/usr/lib/live-installer/snapshotbackend.py b/usr/lib/live-installer/snapshotbackend.py
new file mode 100644
index 00000000..d0edd4da
--- /dev/null
+++ b/usr/lib/live-installer/snapshotbackend.py
@@ -0,0 +1,157 @@
+#!/usr/bin/python3
+# coding: utf-8
+"""Snapshot backends: configure a snapshot tool in the installed system.
+
+Mirrors pkgbackend.py / catrust.py: the driver holds one backend (picked per
+config), called through the CommandRunner chroot boundary, unit-testable with a
+recording runner, no snapshot library imported at load time. v1 has one
+concrete backend (Timeshift on btrfs); the seam keeps rsync/Snapper addable as
+a new class + a get_snapshot_backend() branch, not a refactor.
+
+Split across two phases (deliberately): install-time writes timeshift.json
+(storage-coupled — it must agree with the btrfs @/@home layout decided in
+storage); a first-boot one-shot registers the schedule timers and takes the
+optional initial snapshot (Timeshift wants a booted system for that, and the
+first snapshot is only meaningful after first boot). Both read the SAME
+timeshift.json — only activation is split, never config authorship.
+
+NOTE: the exact timeshift.json path and keys have shifted across Timeshift
+releases (/etc/timeshift.json -> /etc/timeshift/timeshift.json). This targets
+the current layout; the integration test validates it against the Timeshift
+actually shipped on the test image.
+"""
+
+import json
+import os
+
+# First-boot one-shot: registers Timeshift's schedule from the config we wrote
+# and (optionally) takes the initial snapshot, then disables itself.
+_FIRSTBOOT_SERVICE = """\
+[Unit]
+Description=First-boot Timeshift setup (live-installer)
+After=network-online.target
+Wants=network-online.target
+ConditionPathExists=/usr/local/sbin/li-timeshift-firstboot
+
+[Service]
+Type=oneshot
+ExecStart=/usr/local/sbin/li-timeshift-firstboot
+RemainAfterExit=no
+
+[Install]
+WantedBy=multi-user.target
+"""
+
+
+def _firstboot_script(initial_snapshot):
+ take = ('timeshift --create --comments "Initial snapshot (live-installer)" '
+ '--tags O || true\n') if initial_snapshot else ""
+ return ("#!/bin/sh\n"
+ "# Installed by live-installer (snapshots: timeshift-btrfs).\n"
+ "# Registers Timeshift's schedule from /etc/timeshift/timeshift.json\n"
+ "# and (optionally) takes the first snapshot, then self-disables.\n"
+ "set -u\n"
+ "timeshift --check || true\n"
+ + take +
+ "systemctl disable li-timeshift-firstboot.service\n"
+ "rm -f /etc/systemd/system/li-timeshift-firstboot.service "
+ "/usr/local/sbin/li-timeshift-firstboot\n")
+
+
+class SnapshotBackend:
+ def __init__(self, runner, policy, log, target="/target"):
+ self.runner = runner
+ self.policy = policy
+ self.log = log
+ self.target = target
+
+ def is_compatible(self, storage):
+ raise NotImplementedError
+
+ def apply(self, snapshots, storage):
+ raise NotImplementedError
+
+
+def _has_btrfs_root(storage):
+ """Duck-typed btrfs-@-root check (so this module needn't import schema)."""
+ if getattr(storage, "layout", None) != "custom":
+ return False
+ for entry in list(getattr(storage, "partitions", []) or []) + \
+ list(getattr(storage, "lvm", []) or []):
+ if getattr(entry, "filesystem", None) == "btrfs":
+ for sub in getattr(entry, "subvolumes", []) or []:
+ if getattr(sub, "name", None) == "@" \
+ and getattr(sub, "mount", None) == "/":
+ return True
+ return False
+
+
+class TimeshiftBtrfsBackend(SnapshotBackend):
+ CONFIG_DIR = "/etc/timeshift"
+
+ def is_compatible(self, storage):
+ return _has_btrfs_root(storage)
+
+ def render_config(self, snapshots):
+ """Build the timeshift.json dict (Timeshift stores bools/ints as
+ strings). Public so the unit test can assert on it directly."""
+ sched = snapshots.schedule
+ def b(value):
+ return "true" if value else "false"
+ return {
+ "btrfs_mode": "true",
+ "include_btrfs_home_for_backup": "true",
+ "stop_cron_emails": "true",
+ "schedule_hourly": "false",
+ "schedule_boot": b(sched.boot),
+ "schedule_daily": b(sched.daily > 0),
+ "schedule_weekly": b(sched.weekly > 0),
+ "schedule_monthly": b(sched.monthly > 0),
+ "count_hourly": "0",
+ "count_boot": str(5 if sched.boot else 0),
+ "count_daily": str(sched.daily),
+ "count_weekly": str(sched.weekly),
+ "count_monthly": str(sched.monthly),
+ }
+
+ def apply(self, snapshots, storage):
+ # Timeshift may not be on the base image (it is on Mint, not
+ # necessarily LMDE/minimal); make sure it is present.
+ rc = self.runner.chroot(
+ "DEBIAN_FRONTEND=noninteractive apt-get install -y timeshift")
+ if rc != 0:
+ self.policy("package_install_failure",
+ "installing timeshift failed")
+ return
+ self.log(" --> Writing Timeshift configuration")
+ conf_dir = self.target + self.CONFIG_DIR
+ os.makedirs(conf_dir, exist_ok=True) # package may not pre-create it
+ with open(os.path.join(conf_dir, "timeshift.json"), "w") as f:
+ json.dump(self.render_config(snapshots), f, indent=2)
+ f.write("\n")
+ # First-boot one-shot for schedule registration + initial snapshot.
+ sbindir = self.target + "/usr/local/sbin"
+ os.makedirs(sbindir, exist_ok=True)
+ script = os.path.join(sbindir, "li-timeshift-firstboot")
+ with open(script, "w") as f:
+ f.write(_firstboot_script(snapshots.initial_snapshot))
+ os.chmod(script, 0o755)
+ os.makedirs(self.target + "/etc/systemd/system", exist_ok=True)
+ with open(self.target + "/etc/systemd/system/"
+ "li-timeshift-firstboot.service", "w") as f:
+ f.write(_FIRSTBOOT_SERVICE)
+ self.runner.chroot("systemctl enable li-timeshift-firstboot.service")
+
+
+def get_snapshot_backend(config, runner, policy, log, *, target="/target"):
+ """Pick a snapshot backend, or raise with a specific reason. Only
+ timeshift-btrfs today; the schema cross-validation has already guaranteed a
+ btrfs root by the time we get here."""
+ backend = config.snapshots.backend
+ if backend == "timeshift-btrfs":
+ b = TimeshiftBtrfsBackend(runner, policy, log, target=target)
+ if not b.is_compatible(config.storage):
+ raise ValueError(
+ "backend timeshift-btrfs requires a btrfs @/@home root")
+ return b
+ raise ValueError(f"no snapshot backend for {backend!r}")
diff --git a/usr/lib/systemd/system/live-installer-auto.service b/usr/lib/systemd/system/live-installer-auto.service
new file mode 100644
index 00000000..90e560c5
--- /dev/null
+++ b/usr/lib/systemd/system/live-installer-auto.service
@@ -0,0 +1,24 @@
+[Unit]
+Description=Unattended installation (live-installer)
+Documentation=man:live-installer(1)
+# Only runs when an answer-file source is on the kernel command line.
+# ConditionKernelCommandLine= cannot prefix-match live-installer.auto=,
+# so the check is an ExecCondition instead.
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=oneshot
+ExecCondition=/bin/sh -c 'grep -q live-installer.auto= /proc/cmdline'
+ExecStart=/usr/bin/live-installer
+# Crash-before-marker safety net: if the installer process dies without
+# printing its own failure marker (e.g. an import error), emit one so
+# unattended callers watching the console fail fast instead of timing out.
+# A condition skip counts as success, so normal boots stay silent.
+ExecStopPost=/bin/sh -c 'if [ "$$SERVICE_RESULT" != "success" ]; then echo "Automated installation FAILED (service result: $$SERVICE_RESULT)" > /dev/console; fi'
+StandardOutput=journal+console
+StandardError=journal+console
+TimeoutStartSec=infinity
+
+[Install]
+WantedBy=multi-user.target
diff --git a/usr/lib/systemd/system/multi-user.target.wants/live-installer-auto.service b/usr/lib/systemd/system/multi-user.target.wants/live-installer-auto.service
new file mode 120000
index 00000000..17688b43
--- /dev/null
+++ b/usr/lib/systemd/system/multi-user.target.wants/live-installer-auto.service
@@ -0,0 +1 @@
+../live-installer-auto.service
\ No newline at end of file