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