Skip to content

Fixes #39765 - TFTP archive extraction feature - #958

Open
lzap wants to merge 1 commit into
theforeman:developfrom
lzap:tftp-api-39765
Open

lzap wants to merge 1 commit into
theforeman:developfrom
lzap:tftp-api-39765

Conversation

@lzap

@lzap lzap commented Sep 15, 2026 •

Copy link
Copy Markdown
Member

Adds new TFTP capabilities with extracting tarballs and ISO files. Use the test script to perform a live download test on CentOS or Debian to see it in action. See https://community.theforeman.org/t/managed-bootloader-universe/47609 for more context. The script follows the universe contract: https://docs.theforeman.org/nightly/Provisioning_Hosts/index-katello.html#grub2-uefi-boot-loader-and-the-bootloader-universe-structure

The API is idempotent - when called with same URL, it will only check if the file on server is newer, if not, download (and extraction) is skipped. If some files were deleted for any reason, extraction will be performed. The source archives are always kept in the TFTP directory.

The legacy API call was asynchronous, this is done by the downloader utility class. The new extraction code retain this behavior and spawns a new thread, then download the archive synchronously and performs the extraction logging everything to the log.

Additionally, an extra check is performed before download of any file via the utility downloader - HTTP HEAD is performed first, if the file does not exist it fails immediately. This will improve experience by emitting errors right away for incorrect URLs or misconfigured setups.

Writing of any files is now much more strict, paths are normalized, symlinked directories are not allowed, and symlinks are never extracted. But this should not affect existing contract.

It is a little bit more code that I expected, most of it are extra checks against path escape and various other attacks.

Example result when you run extra/download_test.sh:

bootloader-universe/pxegrub2/ubuntu/26.04/amd64/grubx64.efi
bootloader-universe/pxegrub2/ubuntu/26.04/amd64/shimx64.efi
bootloader-universe/pxegrub2/ubuntu/26.04/amd64/linux
bootloader-universe/pxegrub2/ubuntu/26.04/amd64/initrd.gz
bootloader-universe/pxegrub2/ubuntu/26.04/amd64/netboot.tar.gz
bootloader-universe/pxegrub2/ubuntu/26.04/amd64/boot.iso
bootloader-universe/pxegrub2/debian/bookworm/amd64/linux
bootloader-universe/pxegrub2/debian/bookworm/amd64/initrd.gz
bootloader-universe/pxegrub2/debian/bookworm/amd64/grubx64.efi
bootloader-universe/pxegrub2/debian/bookworm/amd64/netboot.tar.gz
bootloader-universe/pxegrub2/centos/10/x86_64/shimx64.efi
bootloader-universe/pxegrub2/centos/10/x86_64/grubx64.efi
bootloader-universe/pxegrub2/centos/10/x86_64/boot.iso

The following symlinks are also created:

bootloader-universe/pxegrub2/ubuntu/26.04/amd64/boot-sb.efi
bootloader-universe/pxegrub2/ubuntu/26.04/amd64/boot.efi
bootloader-universe/pxegrub2/debian/bookworm/amd64/boot.efi
bootloader-universe/pxegrub2/centos/10/x86_64/boot-sb.efi
bootloader-universe/pxegrub2/centos/10/x86_64/boot.efi

@ekohl ekohl left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a real review. I should admit I neglected to review #837 for a long time. It has very related functionality. Mind taking a look?

Comment thread modules/tftp/extractor_iso.rb Outdated
Comment thread modules/tftp/server.rb
Comment thread modules/tftp/server.rb
EXTRACTORS = {
'tgz' => Proxy::TFTP::ExtractorTgz,
'iso' => Proxy::TFTP::ExtractorIso,
}.freeze

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These extractors will cover Fedora, Red Hat, CentOS and clone, Debian and Ubuntu and clones. It is a good start, it is easy to add more if needed.

@adamruzicka adamruzicka left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just some tidbits after a quick read-through

Comment thread modules/tftp/extractor_iso.rb Outdated
Proxy::TFTP.ensure_tftp_file_path(destination)

commands = [
[[find_command('bsdtar'), '-xOf', archive.to_s, member], false],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why bsdtar over gnu tar which is more likely to be available?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I only found two utilities in RHEL9 which can extract ISOs without mounting: bsdtar and xorriso.

Comment thread modules/tftp/extractor_iso.rb Outdated
Comment thread modules/tftp/extractor_iso.rb Outdated
Comment thread modules/tftp/server.rb
@lzap

lzap commented Sep 15, 2026

Copy link
Copy Markdown
Member Author

I did not expect such a quick reviews, I pushed an ugly draft so I can take this with me offline (not working today). Well, you guys are fast, this is a very rough code (it works but needs more love).

#837 for a long time. It has very related functionality. Mind taking a look?

Yeah, I was not aware. This implementation in comparsion:

  • bootloader-universe aware
  • flexible API with
  • modular extraction
  • supports Fedora/RH/Debian/Ubuntu out of box

The other implementation:

  • just plainly extracts (not create files for bootloader universe)
  • simpler API
  • only focuses on Ubuntu

There are some ideas which I like and I could inspire, e.g. using the command task for running the extraction subcommands.

Comment thread modules/tftp/server.rb
Comment thread modules/tftp/server.rb
@lzap

lzap commented Sep 16, 2026

Copy link
Copy Markdown
Member Author

Ok rebased, thanks for initial reviews. This is now ready for full reviews. Once I get approvals, I will work on the Foreman part and test this end to end.

@lzap
lzap marked this pull request as ready for review September 16, 2026 06:54

@adamruzicka adamruzicka left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Haven't tested, but it fits together nicely in my head. It still feels a bit go-esque though in some places

Comment thread modules/tftp/extractor_iso.rb Outdated
Comment thread modules/tftp/extractor_tgz.rb Outdated

@ekohl ekohl left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a complete review.

Comment thread modules/tftp/extractor_iso.rb Outdated
Comment thread lib/proxy/http_download.rb
@lzap

lzap commented Sep 16, 2026

Copy link
Copy Markdown
Member Author

Thanks amended your comments, I actually squashed the two commits into one because I had to fix how "destination" alias works. Previously, it was only prefix so it worked like a prefix when file path was provided and for directory (ending with slash) it created the very same filename. But after I tested this also with Ubuntu, we need to be also able to download ISO alone. This is because last 3 releases of Ubuntu provides both netboot tarball and installation ISO, however, for Ubuntu installer the ISO is always required. Thus, it will make sense to download BOTH netboot tarball and the ISO:

curl --fail --silent --show-error \
  --header 'Content-Type: application/json' \
  --data '{
  "extract": {
    "source": "https://releases.ubuntu.com/26.04/ubuntu-26.04-netboot-amd64.tar.gz",
    "destination": "bootloader-universe/pxegrub2/ubuntu/26.04/amd64/netboot.tar.gz",
    "type": "tgz",
    "files": {
      "bootloader-universe/pxegrub2/ubuntu/26.04/amd64/linux": "amd64/linux",
      "bootloader-universe/pxegrub2/ubuntu/26.04/amd64/initrd.gz": "amd64/initrd",
      "bootloader-universe/pxegrub2/ubuntu/26.04/amd64/grubx64.efi": "amd64/grubx64.efi",
      "bootloader-universe/pxegrub2/ubuntu/26.04/amd64/shimx64.efi": "amd64/bootx64.efi"
    },
    "symlinks": {
      "bootloader-universe/pxegrub2/ubuntu/26.04/amd64/boot.efi": "bootloader-universe/pxegrub2/ubuntu/26.04/amd64/grubx64.efi",
      "bootloader-universe/pxegrub2/ubuntu/26.04/amd64/boot-sb.efi": "bootloader-universe/pxegrub2/ubuntu/26.04/amd64/shimx64.efi"
    }
  }
}' \
  "${PROXY_URL}/tftp/fetch_boot_file"

# Ubuntu ISO is also needed
curl --fail --silent --show-error \
  --header 'Content-Type: application/json' \
  --data '{
  "source": "https://releases.ubuntu.com/26.04/ubuntu-26.04-live-server-amd64.iso",
  "destination": "bootloader-universe/pxegrub2/ubuntu/26.04/amd64/boot.iso"
}' \
  "${PROXY_URL}/tftp/fetch_boot_file"

The old flag called prefix still works and it has unchanged behavior. Only source is an alias, the old path also still works so the API compatibility is remained. I tested this works fine.

@ekohl

ekohl commented Sep 16, 2026

Copy link
Copy Markdown
Member
    "destination": "bootloader-universe/pxegrub2/ubuntu/26.04/amd64/netboot.tar.gz",

I don't think Foreman should send the exact path. In other places we send the os, release, and arch and I'd like to remain consistent. Neither should it send which files and symlinks because that's already done server side:

# Configures bootloader files for a host in its host-config directory
#
# @param mac [String] The MAC address of the host
# @param os [String] The lowercase name of the operating system of the host
# @param release [String] The major and minor version of the operating system of the host
# @param arch [String] The architecture of the operating system of the host
# @param bootfile_suffix [String] The architecture specific boot filename suffix
def setup_bootloader(mac:, os:, release:, arch:, bootfile_suffix:)
pxeconfig_dir_mac = pxeconfig_dir(mac)
logger.debug "TFTP: Deploying host specific bootloader files to '#{pxeconfig_dir_mac}'."
FileUtils.mkdir_p(pxeconfig_dir_mac)
FileUtils.rm_f(Dir.glob("#{pxeconfig_dir_mac}/*.efi"))
bootloader_path = bootloader_path(os, release, arch)
if bootloader_path
logger.debug "TFTP: Creating symlinks from bootloader universe."
symlinks = bootloader_universe_symlinks(bootloader_path, pxeconfig_dir_mac)
else
logger.debug "TFTP: Creating symlinks from default bootloader files."
symlinks = default_symlinks(bootfile_suffix, pxeconfig_dir_mac)
end
create_symlinks(symlinks)
end

@lzap

lzap commented Sep 18, 2026

Copy link
Copy Markdown
Member Author

I don't think Foreman should send the exact path. In other places we send the os, release, and arch and I'd like to remain consistent. Neither should it send which files and symlinks because that's already done server side:

That is exactly what the changed API point does, prefix is a relative path within the TFTP root. I would like to stay consistent here.

It also aligns how this works in Foreman core - the code there fully works with full (relative) TFTP paths when constructing the filename option and orchestrating the downloads. And this has a reason - we centralize all OS-related know how in Foreman code, specifically in Operatingsystem class (and descendants). Designing the API the generic way, which is generally preferred and it is hard to doubt that, would mean we also need to copy some of the logic (of constructing the paths) in here. And because this change will be gradual, I only plan to implement RedHat, Ubuntu, Debian this would mean this is scattered between Foreman core and Foreman Proxy.

For this reason I think it is better to keep the "download this file in here" contract also for extraction.

@stejskalleos stejskalleos left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First review round, not tested yet, but I plan to as a next step.

Comment thread lib/proxy/http_download.rb Outdated
return if response.is_a?(Net::HTTPSuccess)

unless response.is_a?(Net::HTTPRedirection)
raise "HTTP HEAD preflight failed for #{uri}: #{response.code} #{response.message}"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we explicitly fail when the error is Net::HTTPMethodNotAllowed or 501 Not Implemented so Foreman admins know that they have to set the tftp_http_download_preflight to false.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no response for the fetch_boot_files it is async, however, you got a point that in the future code call sites without rescue could lead to 500 or something. I could do this:

  class PreflightError < StandardError
    attr_reader :uri, :upstream_status

    def initialize(uri, message, upstream_status: nil)
      @uri = uri
      @upstream_status = upstream_status
      super("HTTP HEAD preflight failed for #{uri}: #{message}")
    end

    def status_code
      502
    end
  end

This would be ideal, but that is a lot of code for such a small helper. What you recommend would effectively mean simply returning response.value that is weird. I wish Ruby had Go error wrapping that is such a powerful thing.

You know what let me generate the PreflightError solution, that is the correct way.

Comment thread modules/tftp/tftp_api.rb Outdated
destination_provided = request_params.key?(:destination) || request_params.key?('destination')
destination = request_params[:destination] || request_params['destination']
prefix = request_params[:prefix] || request_params['prefix']
source = request_params[:source] || request_params['source'] || request_params[:path] || request_params['path']

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we rename it to source_iso to make the source type clearer?

@lzap lzap Oct 1, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These two parameters are the original API ones, named "path" and "prefix". In the initial version of the patch, as you figured out, I had also aliases "source" and "destination". What I am trying to say is this "source" field is the original, not used for ISO/TGZ downloads at all.

Frankly, now that I see the final result, the initial assumption that it was a good idea to build this new call on the current fetch_boot_file call is not great - I think we can affort to make a completely new endpoint here because once universe is implemented for all OSes, then we can simply drop this endpoint completely.

Comment thread extra/download_test.sh
--header 'Content-Type: application/json' \
--data '{
"extract": {
"source": "https://mirror.stream.centos.org/10-stream/BaseOS/x86_64/os/images/boot.iso",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From the PR description:

source is just an alias for path (which remains unchanged)

But here I see the source as part of the extract, not as a new third field.

@lzap lzap Oct 1, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are actually different fields, there is "source" and "destination" (aliases) and "extract.source" and "extract.destination". I know this is confusing. As I said elsewhere now I think I should drop the modifications of the original API call and make a new one and keep the original untouched.

Comment thread extra/download_test.sh
--data '{
"extract": {
"source": "https://mirror.stream.centos.org/10-stream/BaseOS/x86_64/os/images/boot.iso",
"destination": "bootloader-universe/pxegrub2/centos/10/x86_64/boot.iso",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as for source above.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ditto.

Comment thread modules/tftp/server.rb Outdated
logger.info "TFTP: Queuing boot file extraction from #{options[:source]} to #{options[:archive]}"
# Keep the API asynchronous. The worker owns download completion and all
# extraction errors are logged there because the request has already ended.
Thread.new { perform_extract_boot_files(options) }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is making the API endpoint asynchronous. We need to document this; otherwise, users might have the wrong impression when we return 200, only to later show an extraction error in the logs.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm curious about the integration with the Foreman Create Host form. How will the Orchestration handle this extraction phase?

@lzap lzap Oct 1, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This endpoint already was asynchronous (optionally), I am not changing that. But it was not obvious, let me explain.

The HttpDownloader util is async and up until now, the endpoint only called the download. Now, we need to download first, wait via the join call, and then make extraction. The extraction must run in a separate thread in order to achieve that.

Comment thread modules/tftp/server.rb Outdated
ensure_tftp_root
archive = tftp_path(archive_path)
files = options[:files] || {}
symlinks = options[:symlinks] || options[:symlink] || {}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Do we have to support both?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh this was a typo, nice catch.

Comment thread modules/tftp/tftp_api.rb

post "/fetch_boot_file" do
log_halt(400, "TFTP: Failed to fetch boot file: ") { Proxy::TFTP.fetch_boot_file(params[:prefix], params[:path]) }
request_params = parse_json_body.merge(params)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note to myself: With this, we support data from the body and from URL params,
while URL params will override the data sent via the body.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the old API only supported URL params, the new one supports both.

Comment on lines +24 to +25
commands << [:bsdtar, bsdtar] if bsdtar
commands << [:xorriso, xorriso] if xorriso

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if both commands are available? Does it extract the iso file twice?

@lzap lzap Oct 1, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nope, few lines later the first one is picked up: commands.any?

Btw both tools are in RHEL, added to my notes to add this to packaging.

Comment thread lib/proxy/http_download.rb Outdated
super(args)
end

private

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As a Go developer, you will surely appreciate this AI comment:

lib/proxy/http_download.rb:52-97 — using private/public visibility toggles around two small helpers is a bit go-esque; a small private :preflight_http, :preflight_request after the constructor would be more idiomatic Ruby.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did not give LLM any instructions to imitate Go, not sure why it decided to do that. Sure, let's do that.

@lzap
lzap marked this pull request as draft October 1, 2026 15:11
@lzap

lzap commented Oct 1, 2026

Copy link
Copy Markdown
Member Author

Ah sorry I was working on many more changes, I converted to draft until I am done with re-review. I will read all your comments, tho, thanks so far.

@lzap

lzap commented Oct 1, 2026

Copy link
Copy Markdown
Member Author

Thanks for the review, I force-pushed. Addressed hopefully all your comments and I did revert the original API call and added a brand new one. You were apparently confused by the aliases and how it works, I think two separate endpoints are much cleaner. I am also updating the description to reflect the current implementation.

@lzap
lzap marked this pull request as ready for review October 1, 2026 19:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants