├── .flake8 ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── documentation.md │ └── feature_request.md └── pull_request_template.md ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── autoinstall ├── README.md └── basic.yaml ├── conftest.py ├── poetry.lock ├── pyproject.toml ├── ubuntu-server-netboot └── usn ├── __init__.py ├── tests ├── __init__.py ├── data │ ├── grub_expected.cfg │ └── grub_ubuntu-20.04.2-live-server-arm64.cfg └── test_grub_cfg.py ├── ubuntu_server_netboot.py └── usn.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | # Line break occurred before a binary operator (W503) 3 | # The recent research unearthed recommendations by Donald Knuth to break before 4 | # binary operators. flake8 will soon follow up the new recommendation. Until 5 | # the flake8 update, let's ignore the check. 6 | # 7 | # https://www.flake8rules.com/rules/W503.html 8 | extend-ignore = W503, E203 9 | 10 | max-line-length = 119 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🛠 Bug report 3 | about: Create a report to help us improve 4 | title: "[Bug Report] Good bug title tells us about precise symptom, not about the root cause." 5 | labels: "bug" 6 | assignees: "" 7 | --- 8 | 9 | ## Description 10 | 11 | 12 | ## {{ cookiecutter.project_name }} version 13 | 14 | 15 | ## Steps to Reproduce 16 | 23 | 24 | ## Expected Behavior 25 | 31 | 32 | ## Actual Behavior 33 | 34 | 35 | ## More Information 36 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/documentation.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 📖 Documentation 3 | about: Suggest an improvement for the documentation of this project 4 | title: "[Documentation] Content to be added or fixed" 5 | labels: "documentation" 6 | assignees: "" 7 | --- 8 | 9 | ## Type 10 | * [ ] Content inaccurate 11 | * [ ] Content missing 12 | * [ ] Typo 13 | 14 | ## URL 15 | 16 | 17 | ## Description 18 | 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🚀 Feature request 3 | about: Suggest an idea for this project 4 | title: "[Feature Request] " 5 | labels: "" 6 | assignees: "" 7 | --- 8 | 9 | ## Description 10 | 11 | 12 | ## Possible Solution 13 | 14 | 15 | ## Additional context 16 | 17 | 18 | ## Related Issue 19 | 20 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Types of changes 4 | 5 | 6 | - **Bugfix** 7 | - **New feature** 8 | - **Refactoring** 9 | - **Breaking change** (any change that would cause existing functionality to not work as expected) 10 | - **Documentation Update** 11 | - **Other (please describe)** 12 | 13 | ## Description 14 | 15 | 16 | ## Checklist: 17 | - [ ] Add test cases to all the changes you introduce 18 | - [ ] Run `poetry run pytest` locally to ensure all linter checks pass 19 | - [ ] Update the documentation if necessary 20 | 21 | ## Steps to Test This Pull Request 22 | 28 | 29 | ## Expected behavior 30 | 31 | 32 | ## Related Issue 33 | 34 | 35 | ## Additional context 36 | 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | dist 3 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/psf/black 3 | rev: 20.8b1 4 | hooks: 5 | - id: black 6 | language_version: python3 7 | - repo: https://gitlab.com/pycqa/flake8 8 | rev: 3.8.4 9 | hooks: 10 | - id: flake8 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2020-2021 Canonical, Ltd. 2 | 3 | This program is free software; you can redistribute it and/or 4 | modify it under the terms of the GNU General Public License 5 | as published by the Free Software Foundation; either version 2 6 | of the License, or (at your option) any later version. 7 | 8 | This program is distributed in the hope that it will be useful, 9 | but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | GNU General Public License for more details. 12 | 13 | You should have received a copy of the GNU General Public License 14 | along with this program; if not, write to the Free Software Foundation, Inc., 15 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 16 | 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ubuntu-server-netboot 2 | This utility generates a netboot directory tree from an Ubuntu Server Live ISO image, an image based on the `subiquity` installer. The tree contents are similar to the contents of the `netboot.tar.gz` file that debian-installer builds provide. Example: 3 | 4 | ``` 5 | $ ./ubuntu-server-netboot --url http://releases.ubuntu.com/focal/ubuntu-20.04.2-live-server-amd64.iso 6 | INFO: Downloading http://releases.ubuntu.com/focal/ubuntu-20.04.2-live-server-amd64.iso 7 | INFO: Attempting to download http://archive.ubuntu.com/ubuntu/dists/focal-updates/main/uefi/grub2-amd64/current/grubx64.efi.signed 8 | INFO: Netboot generation complete: /tmp/tmpo54145m2/ubuntu-installer 9 | ``` 10 | 11 | The `--url` parameter is used for 2 reasons: 12 | 13 | 1. `ubuntu-server-netboot` will download the image at runtime to extract the necessary files from it. 14 | 1. Subiquity-based installs need to download an image at install-time. `ubuntu-server-netboot` will generate configuration files that point the installer to this URL. 15 | 16 | If you have a local copy of the ISO, you can point to it with the `--iso` parameter to avoid having `ubuntu-server-netboot` download an extra copy. Just be sure that `--iso` and `--url` point to the same version of the ISO. 17 | 18 | Optionally, you can place `--autoinstall-url` to tell the netbooting process to enable subiquity automation. See [our autoinstall example](./autoinstall/README.md) and [the autoinstall and Automated Server Installs 19 | Introduction of Ubuntu Server guide](Automated Server Installs Introduction) for more details. 20 | 21 | You can also add additional kernel command line arguments (e.g. `"console=ttyS0"`) to the generated configuration files using the `--extra-args` parameter. 22 | 23 | ## Usage of the Generated Files 24 | Copy the files generated under the interim folder `/tmp/tmpxxx/ubuntu-installer/` 25 | to your tftp root folder for netboot, for example `/srv/tftp` or `/var/lib/tftpboot`. 26 | You may check your tftpd configuration of the root directory, for instance, tftpd-hpa is `/etc/default/tftpd-hpa`. Let's copy: 27 | 28 | ``` 29 | $ sudo cp -r /tmp/tmpxxx/ubuntu-installer/* /srv/tftp 30 | ``` 31 | 32 | Then your netboot server is ready to go if the corresponding DHCP is set up. 33 | 34 | ## Troubleshooting 35 | For more details on setting up a PXE environment for x86 systems using a legacy BIOS, see [this discourse post](https://discourse.ubuntu.com/t/netbooting-the-server-installer-on-amd64/16620). 36 | 37 | For more details on setting up a PXE environment for UEFI-based systems, see [this discourse post](https://discourse.ubuntu.com/t/netbooting-the-live-server-installer-via-uefi-pxe-on-arm-aarch64-arm64-and-x86-64-amd64/19240). 38 | 39 | ## Dependencies 40 | Today `ubuntu-server-netboot` needs to run on Ubuntu or another Debian derivative with the following packages installed: 41 | 42 | - genisoimage 43 | - mtools 44 | - python3-distro-info 45 | - pxelinux (x86-only) 46 | - syslinux-common (x86-only) 47 | 48 | This script is tested with Ubuntu 18.04 ("bionic beaver") and above. 49 | 50 | ## Contribution and Development 51 | 52 | Please report bugs to [this github issue tracker](https://github.com/dannf/ubuntu-server-netboot/issues). The github templates including "Issue" and "Pull requests" are originally forked from [this "cookiecutter" templates for python](https://github.com/Lee-W/cookiecutter-python-template). 53 | 54 | Place `pytest` to cover the basic test sets. 55 | -------------------------------------------------------------------------------- /autoinstall/README.md: -------------------------------------------------------------------------------- 1 | # Autoinstall 2 | By using autoinstall we could automate the subiquity installer like what `preseed` is used for `debian-installer`, a.k.a. `d-i`. 3 | 4 | ## Usage 5 | 1. Upload your `user-data` to a www server. You may use `basic.yaml` as an example. Rename `basic.yaml` to be `user-data` and put it into a www server to serve it. By using `basic.yaml` the default username and password will be both `ubuntu`. You can change the default username and password by following the instruction in the comment block of `basic.yaml`. 6 | 2. `touch /meta-data` to create an empty but necessary file required by `cloud-init`. 7 | 3. Include the necessary kernel parameters `autoinstall "ds=nocloud-net;s=http:///"`. For UEFI, it is usually the line of `linux` in your `grub.cfg`. If you are working on `pxelinux.cfg` for legacy mode, please do something similar in your `APPEND` line. For example, 8 | - as is `quiet splash root=/dev/ram0 ramdisk_size=1500000 ip=dhcp url=http:///focal-live-server-arm64.iso ---` 9 | - to be `quiet splash root=/dev/ram0 ramdisk_size=1500000 ip=dhcp url=http:///focal-live-server-arm64.iso autoinstall "ds=nocloud-net;s=http:///" ---` 10 | 11 | ## Trouble-shooting and Tips 12 | - The format of the first line "shebang", a.k.a. `#cloud-config` matters. Do not change it or input extra spaces 13 | unless you know what you are doing. 14 | - The double quotes matter when injecting `user-data` in the kernel parameters. 15 | - The file name `user-data` is a must. Its the file name that `cloud-init` will look for. 16 | - The file `meta-data` is also a must, even it is an empty file in our example. 17 | - The trailing slash of the url serving `user-data` matters in the current autoinstall version. 18 | -------------------------------------------------------------------------------- /autoinstall/basic.yaml: -------------------------------------------------------------------------------- 1 | #cloud-config 2 | autoinstall: 3 | version: 1 4 | identity: 5 | hostname: ubuntu-server 6 | # The password hash maps to "ubuntu". 7 | # See the example of Ubuntu Server guide https://ubuntu.com/server/docs/install/autoinstall-quickstart 8 | # 9 | # You may generate your own password by: 10 | # mkpasswd --method=SHA-512 --rounds=4096 11 | # Refer to the document of cloudinit for generating password 12 | # https://cloudinit.readthedocs.io/en/latest/topics/examples.html 13 | password: "$6$exDY1mhS4KUYCE/2$zmn9ToZwTKLhCw.b4/b.ZRTIZM30JZ4QrOQ2aOXJ8yk96xpcCof0kxKwuX1kqLG/ygbJ1f8wxED22bTL4F46P0" 14 | username: ubuntu 15 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | 4 | # we would like to use the distro-info provided by system 5 | sys.path.append("/usr/lib/python3/dist-packages") 6 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | name = "atomicwrites" 3 | version = "1.4.0" 4 | description = "Atomic file writes." 5 | category = "main" 6 | optional = false 7 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 8 | 9 | [[package]] 10 | name = "attrs" 11 | version = "21.2.0" 12 | description = "Classes Without Boilerplate" 13 | category = "main" 14 | optional = false 15 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 16 | 17 | [package.extras] 18 | dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit"] 19 | docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] 20 | tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface"] 21 | tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins"] 22 | 23 | [[package]] 24 | name = "colorama" 25 | version = "0.4.4" 26 | description = "Cross-platform colored terminal text." 27 | category = "main" 28 | optional = false 29 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 30 | 31 | [[package]] 32 | name = "importlib-metadata" 33 | version = "4.0.1" 34 | description = "Read metadata from Python packages" 35 | category = "main" 36 | optional = false 37 | python-versions = ">=3.6" 38 | 39 | [package.dependencies] 40 | typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} 41 | zipp = ">=0.5" 42 | 43 | [package.extras] 44 | docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] 45 | testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"] 46 | 47 | [[package]] 48 | name = "iniconfig" 49 | version = "1.1.1" 50 | description = "iniconfig: brain-dead simple config-ini parsing" 51 | category = "main" 52 | optional = false 53 | python-versions = "*" 54 | 55 | [[package]] 56 | name = "packaging" 57 | version = "20.9" 58 | description = "Core utilities for Python packages" 59 | category = "main" 60 | optional = false 61 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 62 | 63 | [package.dependencies] 64 | pyparsing = ">=2.0.2" 65 | 66 | [[package]] 67 | name = "pluggy" 68 | version = "0.13.1" 69 | description = "plugin and hook calling mechanisms for python" 70 | category = "main" 71 | optional = false 72 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 73 | 74 | [package.dependencies] 75 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 76 | 77 | [package.extras] 78 | dev = ["pre-commit", "tox"] 79 | 80 | [[package]] 81 | name = "py" 82 | version = "1.10.0" 83 | description = "library with cross-python path, ini-parsing, io, code, log facilities" 84 | category = "main" 85 | optional = false 86 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 87 | 88 | [[package]] 89 | name = "pyparsing" 90 | version = "2.4.7" 91 | description = "Python parsing module" 92 | category = "main" 93 | optional = false 94 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 95 | 96 | [[package]] 97 | name = "pytest" 98 | version = "6.2.4" 99 | description = "pytest: simple powerful testing with Python" 100 | category = "main" 101 | optional = false 102 | python-versions = ">=3.6" 103 | 104 | [package.dependencies] 105 | atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} 106 | attrs = ">=19.2.0" 107 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 108 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 109 | iniconfig = "*" 110 | packaging = "*" 111 | pluggy = ">=0.12,<1.0.0a1" 112 | py = ">=1.8.2" 113 | toml = "*" 114 | 115 | [package.extras] 116 | testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] 117 | 118 | [[package]] 119 | name = "toml" 120 | version = "0.10.2" 121 | description = "Python Library for Tom's Obvious, Minimal Language" 122 | category = "main" 123 | optional = false 124 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 125 | 126 | [[package]] 127 | name = "typing-extensions" 128 | version = "3.10.0.0" 129 | description = "Backported and Experimental Type Hints for Python 3.5+" 130 | category = "main" 131 | optional = false 132 | python-versions = "*" 133 | 134 | [[package]] 135 | name = "zipp" 136 | version = "3.4.1" 137 | description = "Backport of pathlib-compatible object wrapper for zip files" 138 | category = "main" 139 | optional = false 140 | python-versions = ">=3.6" 141 | 142 | [package.extras] 143 | docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] 144 | testing = ["pytest (>=4.6)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-cov", "pytest-enabler", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] 145 | 146 | [metadata] 147 | lock-version = "1.1" 148 | python-versions = "^3.6.5" 149 | content-hash = "f868dff97bf2eae0351ca05a23a7fe163b823e0333836ad770cc96ba789dc906" 150 | 151 | [metadata.files] 152 | atomicwrites = [ 153 | {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, 154 | {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, 155 | ] 156 | attrs = [ 157 | {file = "attrs-21.2.0-py2.py3-none-any.whl", hash = "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1"}, 158 | {file = "attrs-21.2.0.tar.gz", hash = "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"}, 159 | ] 160 | colorama = [ 161 | {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, 162 | {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, 163 | ] 164 | importlib-metadata = [ 165 | {file = "importlib_metadata-4.0.1-py3-none-any.whl", hash = "sha256:d7eb1dea6d6a6086f8be21784cc9e3bcfa55872b52309bc5fad53a8ea444465d"}, 166 | {file = "importlib_metadata-4.0.1.tar.gz", hash = "sha256:8c501196e49fb9df5df43833bdb1e4328f64847763ec8a50703148b73784d581"}, 167 | ] 168 | iniconfig = [ 169 | {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, 170 | {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, 171 | ] 172 | packaging = [ 173 | {file = "packaging-20.9-py2.py3-none-any.whl", hash = "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"}, 174 | {file = "packaging-20.9.tar.gz", hash = "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5"}, 175 | ] 176 | pluggy = [ 177 | {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, 178 | {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, 179 | ] 180 | py = [ 181 | {file = "py-1.10.0-py2.py3-none-any.whl", hash = "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"}, 182 | {file = "py-1.10.0.tar.gz", hash = "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3"}, 183 | ] 184 | pyparsing = [ 185 | {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, 186 | {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, 187 | ] 188 | pytest = [ 189 | {file = "pytest-6.2.4-py3-none-any.whl", hash = "sha256:91ef2131a9bd6be8f76f1f08eac5c5317221d6ad1e143ae03894b862e8976890"}, 190 | {file = "pytest-6.2.4.tar.gz", hash = "sha256:50bcad0a0b9c5a72c8e4e7c9855a3ad496ca6a881a3641b4260605450772c54b"}, 191 | ] 192 | toml = [ 193 | {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, 194 | {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, 195 | ] 196 | typing-extensions = [ 197 | {file = "typing_extensions-3.10.0.0-py2-none-any.whl", hash = "sha256:0ac0f89795dd19de6b97debb0c6af1c70987fd80a2d62d1958f7e56fcc31b497"}, 198 | {file = "typing_extensions-3.10.0.0-py3-none-any.whl", hash = "sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84"}, 199 | {file = "typing_extensions-3.10.0.0.tar.gz", hash = "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342"}, 200 | ] 201 | zipp = [ 202 | {file = "zipp-3.4.1-py3-none-any.whl", hash = "sha256:51cb66cc54621609dd593d1787f286ee42a5c0adbb4b29abea5a63edc3e03098"}, 203 | {file = "zipp-3.4.1.tar.gz", hash = "sha256:3607921face881ba3e026887d8150cca609d517579abe052ac81fc5aeffdbd76"}, 204 | ] 205 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "ubuntu-server-netboot" 3 | packages = [ 4 | {include = "usn"} 5 | ] 6 | version = "0.1.1" 7 | description = "This utility generates a netboot directory tree from an Ubuntu Server Live ISO image, an image based on the subiquity installer." 8 | authors = ["dann frazier ", "Taihsiang Ho (tai271828) "] 9 | license = "GPL-2.0-only" 10 | readme = "README.md" 11 | classifiers = [ 12 | "Topic :: System :: Installation/Setup", 13 | "Topic :: Utilities", 14 | "Development Status :: 6 - Mature", 15 | "Programming Language :: Python :: 3 :: Only", 16 | "Operating System :: POSIX", 17 | "Environment :: Console", 18 | "Intended Audience :: System Administrators", 19 | "Natural Language :: English", 20 | ] 21 | 22 | [tool.poetry.dependencies] 23 | python = "^3.6.5" 24 | 25 | [tool.poetry.dev-dependencies] 26 | pytest = "^6.2.4" 27 | 28 | [tool.poetry.scripts] 29 | ubuntu-server-netboot = "usn.usn:ubuntu_server_netboot" 30 | 31 | [build-system] 32 | requires = ["poetry-core>=1.0.0"] 33 | build-backend = "poetry.core.masonry.api" 34 | -------------------------------------------------------------------------------- /ubuntu-server-netboot: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | from usn.usn import ubuntu_server_netboot 4 | 5 | 6 | if __name__ == "__main__": 7 | ubuntu_server_netboot() 8 | -------------------------------------------------------------------------------- /usn/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dannf/ubuntu-server-netboot/6fdd48e52a92b4761c03f9887540126da155ee15/usn/__init__.py -------------------------------------------------------------------------------- /usn/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dannf/ubuntu-server-netboot/6fdd48e52a92b4761c03f9887540126da155ee15/usn/tests/__init__.py -------------------------------------------------------------------------------- /usn/tests/data/grub_expected.cfg: -------------------------------------------------------------------------------- 1 | set menu_color_normal=white/black 2 | set menu_color_highlight=black/light-gray 3 | if background_color 44,0,30,0; then 4 | clear 5 | fi 6 | 7 | insmod gzio 8 | 9 | set timeout=1 10 | menuentry "Install Ubuntu Server" { 11 | set gfxpayload=keep 12 | linux /casper/vmlinuz quiet splash root=/dev/ram0 ramdisk_size=1500000 ip=dhcp url=http://cdimage.ubuntu.com/ubuntu/releases/20.04.2/release/ubuntu-20.04.2-live-server-arm64.iso autoinstall "ds=nocloud-net;s=http://12.34.56.78/" --- 13 | initrd /casper/initrd 14 | } 15 | menuentry 'Boot from next volume' { 16 | exit 1 17 | } 18 | menuentry 'UEFI Firmware Settings' { 19 | fwsetup 20 | } 21 | submenu 'Boot and Install with the HWE kernel' { 22 | menuentry "Install Ubuntu Server" { 23 | set gfxpayload=keep 24 | linux /casper/hwe-vmlinuz quiet splash root=/dev/ram0 ramdisk_size=1500000 ip=dhcp url=http://cdimage.ubuntu.com/ubuntu/releases/20.04.2/release/ubuntu-20.04.2-live-server-arm64.iso autoinstall "ds=nocloud-net;s=http://12.34.56.78/" --- 25 | initrd /casper/hwe-initrd 26 | } 27 | } -------------------------------------------------------------------------------- /usn/tests/data/grub_ubuntu-20.04.2-live-server-arm64.cfg: -------------------------------------------------------------------------------- 1 | set menu_color_normal=white/black 2 | set menu_color_highlight=black/light-gray 3 | if background_color 44,0,30,0; then 4 | clear 5 | fi 6 | 7 | insmod gzio 8 | 9 | set timeout=30 10 | menuentry "Install Ubuntu Server" { 11 | set gfxpayload=keep 12 | linux /casper/vmlinuz quiet splash --- 13 | initrd /casper/initrd 14 | } 15 | menuentry 'Boot from next volume' { 16 | exit 1 17 | } 18 | menuentry 'UEFI Firmware Settings' { 19 | fwsetup 20 | } 21 | submenu 'Boot and Install with the HWE kernel' { 22 | menuentry "Install Ubuntu Server" { 23 | set gfxpayload=keep 24 | linux /casper/hwe-vmlinuz quiet splash --- 25 | initrd /casper/hwe-initrd 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /usn/tests/test_grub_cfg.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from pkg_resources import resource_filename 4 | from usn import ubuntu_server_netboot 5 | 6 | 7 | class TestGrubCfg: 8 | @pytest.fixture(scope="class") 9 | def grub_cfg_orig_filename(self): 10 | resource = "tests/data/grub_ubuntu-20.04.2-live-server-arm64.cfg" 11 | grub_cfg_orig_filename = resource_filename("usn", resource) 12 | 13 | return grub_cfg_orig_filename 14 | 15 | @pytest.fixture(scope="class") 16 | def grub_cfg_expected_filename(self): 17 | resource = "tests/data/grub_expected.cfg" 18 | grub_cfg_expected_filename = resource_filename("usn", resource) 19 | 20 | return grub_cfg_expected_filename 21 | 22 | def test_grub_cfg_ubuntu_20_4_2_live_server_arm64( 23 | self, grub_cfg_orig_filename, grub_cfg_expected_filename 24 | ): 25 | """ 26 | Check the generated grub.cfg content 27 | 28 | Compare the grub.cfg generated with the original grub.cfg extracted from the iso ubuntu 20.04.2 live server 29 | arm64. 30 | """ 31 | with open(grub_cfg_orig_filename, "r", encoding="utf-8") as grub_cfg_orig_f: 32 | bootloader_cfg = ubuntu_server_netboot.GrubConfig(grub_cfg_orig_f.read()) 33 | 34 | url = "http://cdimage.ubuntu.com/ubuntu/releases/20.04.2/release/ubuntu-20.04.2-live-server-arm64.iso" 35 | autoinstall_url = "http://12.34.56.78/" 36 | extra_args = "" 37 | ubuntu_server_netboot.setup_kernel_params( 38 | bootloader_cfg, url, autoinstall_url, extra_args 39 | ) 40 | cfg_modified = bootloader_cfg.cfg 41 | 42 | with open( 43 | grub_cfg_expected_filename, "r", encoding="utf-8" 44 | ) as grub_cfg_expected_f: 45 | bootloader_cfg_expected = ubuntu_server_netboot.GrubConfig( 46 | grub_cfg_expected_f.read() 47 | ) 48 | cfg_expected = bootloader_cfg_expected.cfg 49 | 50 | assert cfg_modified == cfg_expected 51 | -------------------------------------------------------------------------------- /usn/ubuntu_server_netboot.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2020-2021 Canonical, Ltd. 2 | # 3 | # This program is free software; you can redistribute it and/or 4 | # modify it under the terms of the GNU General Public License 5 | # as published by the Free Software Foundation; either version 2 6 | # of the License, or (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program; if not, write to the Free Software Foundation, Inc., 15 | # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 16 | # 17 | import distro_info 18 | import os 19 | import re 20 | import shutil 21 | import subprocess 22 | import urllib.error 23 | import urllib.request 24 | 25 | 26 | Netboot_Args = ["root=/dev/ram0", "ramdisk_size=1500000", "ip=dhcp"] 27 | Ubuntu_Arch_to_Uefi_Arch_Abbrev = { 28 | "amd64": "x64", 29 | "arm64": "aa64", 30 | } 31 | AutoInstall_Timeout = "1" 32 | 33 | 34 | class UbuntuDistroInfoWithVersionSupport(distro_info.UbuntuDistroInfo): 35 | """ 36 | The version() method wasn't supported by distro_info until 37 | version 0.23, which is newer than some supported releases 38 | ship (Ubuntu 18.04 currently has 0.18). Extend the class 39 | to provide our own copy for backwards compatibility. 40 | """ 41 | 42 | def version(self, name, default=None): 43 | """Map codename or series to version""" 44 | for release in self._releases: 45 | if name in (release.codename, release.series): 46 | return release.version 47 | return default 48 | 49 | 50 | class ServerLiveIso: 51 | def __init__(self, iso_path): 52 | self.path = iso_path 53 | iso_info_output = subprocess.check_output( 54 | ["isoinfo", "-d", "-i", iso_path], 55 | ) 56 | vol_id = None 57 | for line in iso_info_output.decode("utf-8").split("\n"): 58 | if not line.startswith("Volume id: "): 59 | continue 60 | vol_id = line[len("Volume id: ") :] 61 | break 62 | ubuntu_vol_id_re = re.compile( 63 | r"^Ubuntu-Server " 64 | + "(?P[0-9]{2}.[0-9]{2})(.[0-9]+)?" 65 | + "(?P LTS)? (?P.*)$" 66 | ) 67 | if vol_id: 68 | m = ubuntu_vol_id_re.match(vol_id) 69 | else: 70 | raise Exception("No Volume ID is found.") 71 | if not m: 72 | raise Exception("%s does not look like an Ubuntu Server ISO" % self.path) 73 | self.architecture = m.group("arch") 74 | self.version = m.group("release") 75 | if m.group("lts"): 76 | self.version = self.version + m.group("lts") 77 | udi = UbuntuDistroInfoWithVersionSupport() 78 | for codename in udi.supported(): 79 | if self.version != udi.version(codename): 80 | continue 81 | self.codename = codename 82 | break 83 | 84 | def has_file(self, path): 85 | for entry in ( 86 | subprocess.check_output(["isoinfo", "-J", "-f", "-i", self.path]) 87 | .decode("utf-8") 88 | .split("\n") 89 | ): 90 | if entry.lstrip("/") == path.lstrip("/"): 91 | return True 92 | return False 93 | 94 | def extract_file(self, path, dest): 95 | # isoinfo reports success extracting a file even if it doesn't 96 | # exist, so let's check that it exists before proceeding 97 | if not self.has_file(path): 98 | raise FileNotFoundError("%s not found on Ubuntu Server ISO" % path) 99 | 100 | with open(dest, "w") as outf: 101 | child = subprocess.Popen( 102 | ["isoinfo", "-J", "-i", self.path, "-x", path], 103 | stdout=outf, 104 | ) 105 | child.communicate() 106 | if child.returncode != 0: 107 | raise Exception("Error extracting %s from Ubuntu Server ISO" % path) 108 | 109 | def read_file(self, path): 110 | return subprocess.check_output( 111 | ["isoinfo", "-J", "-i", self.path, "-x", path], 112 | ) 113 | 114 | 115 | class BootloaderConfig: 116 | """ 117 | A base class that can be overridden for specific bootloaders 118 | """ 119 | 120 | def __init__(self): 121 | self.cfg = None 122 | 123 | def add_kernel_params(self, params, install_only=False): 124 | new_cfg = "" 125 | for line in self.cfg.split("\n"): 126 | index = line.find("---") 127 | if index != -1: 128 | param_str = " ".join(params) 129 | if install_only: 130 | replace = "%s ---" % param_str 131 | else: 132 | replace = "--- %s" % param_str 133 | line = line.replace("---", replace) 134 | new_cfg += "%s\n" % line 135 | # Remove trailing newlines from file 136 | new_cfg = new_cfg.rstrip() 137 | self.cfg = new_cfg 138 | 139 | def update_timeout(self, timeout): 140 | pattern = r"^set timeout=\d+$" 141 | replace = "set timeout=%s" % timeout 142 | self.cfg = re.sub(pattern, replace, self.cfg, flags=re.MULTILINE) 143 | 144 | def __str__(self): 145 | return self.cfg 146 | 147 | 148 | class GrubConfig(BootloaderConfig): 149 | """ 150 | This BootloaderConfig subclass for GRUB takes a seedcfg - the 151 | grub.cfg scraped from the ISO - and modifies it from there. 152 | """ 153 | 154 | def __init__(self, seedcfg): 155 | super().__init__() 156 | self.cfg = seedcfg 157 | 158 | 159 | class PxelinuxConfig(BootloaderConfig): 160 | """ 161 | This BootloaderConfig subclass for pxelinux needs to generate 162 | a starting config. Unlike for GRUB, there's no file to use as 163 | a seed on the ISO. 164 | """ 165 | 166 | def __init__(self): 167 | super().__init__() 168 | self.cfg = """DEFAULT install 169 | LABEL install 170 | KERNEL casper/vmlinuz 171 | INITRD casper/initrd 172 | APPEND ---""" 173 | 174 | 175 | def select_mirror(arch): 176 | # FIXME: When I try to use https, I get: 177 | # urllib.error.URLError: 178 | # 179 | if arch in ["amd64", "i386"]: 180 | return "http://archive.ubuntu.com/ubuntu" 181 | else: 182 | return "http://ports.ubuntu.com/ubuntu-ports" 183 | 184 | 185 | def download_bootnet(release, architecture, destdir, logger): 186 | uefi_arch_abbrev = Ubuntu_Arch_to_Uefi_Arch_Abbrev[architecture] 187 | for pocket in ["%s-updates" % release, release]: 188 | url = "%s/dists/%s/main/uefi/grub2-%s/current/grubnet%s.efi.signed" % ( 189 | select_mirror(architecture), 190 | pocket, 191 | architecture, 192 | uefi_arch_abbrev, 193 | ) 194 | outfile = os.path.join(destdir, "grubnet%s.efi" % uefi_arch_abbrev) 195 | try: 196 | logger.info("Attempting to download %s" % url) 197 | with urllib.request.urlopen(url) as response: 198 | with open(outfile, "wb") as outf: 199 | shutil.copyfileobj(response, outf) 200 | return 201 | except urllib.error.HTTPError: 202 | # Assuming a 404 203 | continue 204 | raise Exception("Could not download %s" % url) 205 | 206 | 207 | def download_pxelinux(release, destdir, logger): 208 | for pocket in ["%s-updates" % release, release]: 209 | mirror = select_mirror("amd64") 210 | url = ( 211 | "%s/dists/%s/main/installer-amd64/" % (mirror, pocket) 212 | + "current/images/netboot/ubuntu-installer/amd64/pxelinux.0" 213 | ) 214 | outfile = os.path.join(destdir, "pxelinux.0") 215 | try: 216 | logger.info("Attempting to download %s" % url) 217 | with urllib.request.urlopen(url) as response: 218 | with open(outfile, "wb") as outf: 219 | shutil.copyfileobj(response, outf) 220 | return 221 | except urllib.error.HTTPError: 222 | # Assuming a 404 223 | continue 224 | raise Exception("Could not download %s" % url) 225 | 226 | 227 | def setup_kernel_params(bootloader_cfg, url, autoinstall_url, extra_args): 228 | bootloader_cfg.add_kernel_params(Netboot_Args + ["url=%s" % url], install_only=True) 229 | if autoinstall_url: 230 | bootloader_cfg.add_kernel_params( 231 | [ 232 | 'autoinstall "ds=nocloud-net;s=%s"' % autoinstall_url, 233 | ], 234 | install_only=True, 235 | ) 236 | bootloader_cfg.update_timeout(AutoInstall_Timeout) 237 | 238 | if extra_args: 239 | bootloader_cfg.add_kernel_params(extra_args.split(" ")) 240 | 241 | 242 | def cleanup(directory, logger): 243 | logger.info("Cleaning up %s" % directory) 244 | shutil.rmtree(directory) 245 | -------------------------------------------------------------------------------- /usn/usn.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2020-2021 Canonical, Ltd. 2 | # 3 | # This program is free software; you can redistribute it and/or 4 | # modify it under the terms of the GNU General Public License 5 | # as published by the Free Software Foundation; either version 2 6 | # of the License, or (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program; if not, write to the Free Software Foundation, Inc., 15 | # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 16 | # 17 | import argparse 18 | import atexit 19 | import logging 20 | import os 21 | import shutil 22 | import sys 23 | import tempfile 24 | import urllib.request 25 | from usn.ubuntu_server_netboot import ( 26 | GrubConfig, 27 | ServerLiveIso, 28 | PxelinuxConfig, 29 | cleanup, 30 | download_bootnet, 31 | setup_kernel_params, 32 | ) 33 | 34 | 35 | def ubuntu_server_netboot(): 36 | logger = logging.getLogger() 37 | logger.setLevel(logging.INFO) 38 | ch = logging.StreamHandler() 39 | formatter = logging.Formatter("%(levelname)s: %(message)s") 40 | ch.setFormatter(formatter) 41 | logger.addHandler(ch) 42 | 43 | parser = argparse.ArgumentParser( 44 | description="Generate a netboot tree from an Ubuntu Server live ISO" 45 | ) 46 | parser.add_argument( 47 | "-e", 48 | "--extra-args", 49 | help="Any additional kernel command line arguments", 50 | ) 51 | parser.add_argument( 52 | "--iso", 53 | help="Local copy of Server Live ISO" 54 | + " (--url should point to a copy of the same file)", 55 | ) 56 | parser.add_argument( 57 | "-o", 58 | "--out-dir", 59 | help="Output directory", 60 | ) 61 | parser.add_argument( 62 | "--url", 63 | help="URL to Server Live ISO to be downloaded at install-time", 64 | required=True, 65 | ) 66 | parser.add_argument( 67 | "--autoinstall-url", 68 | help="URL to Autoinstall config file to be used during Subiquity installation", 69 | ) 70 | 71 | args = parser.parse_args() 72 | if args.iso: 73 | iso = ServerLiveIso(args.iso) 74 | else: 75 | logger.info("Downloading %s" % args.url) 76 | with urllib.request.urlopen(args.url) as response: 77 | with tempfile.NamedTemporaryFile(delete=False) as iso: 78 | atexit.register(os.remove, iso.name) 79 | shutil.copyfileobj(response, iso) 80 | iso = ServerLiveIso(iso.name) 81 | 82 | architecture = iso.architecture 83 | release = iso.codename 84 | 85 | staging_root = args.out_dir or tempfile.mkdtemp() 86 | staging_dir = os.path.join(staging_root, "ubuntu-installer") 87 | os.mkdir(staging_dir) 88 | if args.out_dir: 89 | atexit.register(cleanup, staging_dir, logger) 90 | else: 91 | atexit.register(cleanup, staging_root, logger) 92 | 93 | download_bootnet(release, architecture, staging_dir, logger) 94 | 95 | os.mkdir(os.path.join(staging_dir, "casper")) 96 | for f in ["vmlinuz", "initrd"]: 97 | iso.extract_file( 98 | os.path.join(os.sep, "casper", f), 99 | os.path.join(staging_dir, "casper", f), 100 | ) 101 | try: 102 | for hwe_f in ["hwe-vmlinuz", "hwe-initrd"]: 103 | iso.extract_file( 104 | os.path.join(os.sep, "casper", hwe_f), 105 | os.path.join(staging_dir, "casper", hwe_f), 106 | ) 107 | except FileNotFoundError: 108 | logger.info("No HWE boot files found, skipping") 109 | pass 110 | 111 | grub_cfg_orig = iso.read_file(os.path.join(os.sep, "boot", "grub", "grub.cfg")) 112 | grub_cfg = GrubConfig(grub_cfg_orig.decode("utf-8")) 113 | setup_kernel_params(grub_cfg, args.url, args.autoinstall_url, args.extra_args) 114 | 115 | os.mkdir(os.path.join(staging_dir, "grub")) 116 | with open(os.path.join(staging_dir, "grub", "grub.cfg"), "w") as grub_f: 117 | grub_f.write(str(grub_cfg)) 118 | 119 | if architecture == "amd64": 120 | local_files = [ 121 | (os.path.join(os.sep, "usr", "lib", "PXELINUX", "pxelinux.0"), "pxelinux"), 122 | ( 123 | os.path.join( 124 | os.sep, "usr", "lib", "syslinux", "modules", "bios", "ldlinux.c32" 125 | ), 126 | "syslinux-common", 127 | ), 128 | ] 129 | for (local_file, pkg) in local_files: 130 | try: 131 | shutil.copy(local_file, staging_dir) 132 | except FileNotFoundError as err: 133 | sys.stderr.write("%s\n" % err) 134 | sys.stderr.write("Try installing %s.\n" % pkg) 135 | sys.exit(1) 136 | 137 | pxelinux_dir = os.path.join(staging_dir, "pxelinux.cfg") 138 | os.mkdir(pxelinux_dir) 139 | pxelinux_cfg = PxelinuxConfig() 140 | setup_kernel_params( 141 | pxelinux_cfg, args.url, args.autoinstall_url, args.extra_args 142 | ) 143 | with open(os.path.join(pxelinux_dir, "default"), "w") as pxelinux_f: 144 | pxelinux_f.write(str(pxelinux_cfg)) 145 | 146 | atexit.unregister(cleanup) 147 | logger.info("Netboot generation complete: %s" % staging_dir) 148 | --------------------------------------------------------------------------------