📌 Note:

Right now you are viewing the "rendered" version of the markdown document.

If you want to download the "raw" version, go here.

APT-Cdrom image builder

📌 Note:

This article uses Literate Programming: it contains runnable source-code! To extract the code from the MarkDown File, use the tool presented in the A new spin on literate programming article.

This project is based on the script at https://github.com/mdukat/manual-apt-cdrom, but completely rewritten in Ruby, annotated with explanations along the way.

{"filename": "apt-cdrom-image-builder.rb"}
#!/usr/bin/env ruby
<<spdx_headers>>
require 'open3'
require 'tmpdir'
require 'fileutils'

<<set_country_code>>

# Accumulator for ubuntu package-names
wanted_packages = Array.new

tools = ['vim', 'htop', 'genisoimage', 'kas', 'checkinstall', 'brasero',
         'glances', 'bmaptool', 'gparted', 'net-tools']
wanted_packages += tools

<<want_more_packages>>

# Deduplicate the accumulator
wanted_packages.uniq!

<<virtual_aptget_use>>

Making a virtual environment for apt-get

Dir.mktmpdir is a useful Ruby method, that creates a temporary directory, passes it to a block of code, and when the block of code finishes (with success or with an exception), it erases the contents of that directory. The directory is typically created inside the /tmp path.

This means that we don't need to worry about deleting objects on the filesystem, since most of the time it happens automatically (but there are situations when it cannot be done, such as when the application receives an SIGKILL signal and exits immediately). The only file created outside of this tmp_dir is the final ISO image, called repo.iso.

{"name": "virtual_aptget_use"}
Dir.mktmpdir do |tmp_dir|
  puts "Working with dir #{tmp_dir}."
<<apt_get_venv>>

<<apt_init_discover_dependencies>>
<<apt_download_and_build_iso>>
end

The apt.conf file

We use a separate apt.conf file to override the system-level apt configuration.

This way we separate the configurations used by the system itself, from the cdrom-image-builder.

apt.conf key Typical value Updated value
Dir::State /var/lib/apt #{tmp_dir}/apt
Dir::State::status /var/lib/dpkg/status #{tmp_dir}/dpkg/status
Dir::Etc::SourceList /etc/apt/sources.list #{tmp_dir}/sources.list
Dir::Etc::SourceParts /etc/apt/sources.list.d/ none
Dir::Cache /var/cache/apt #{tmp_dir}/cache
Dir::Etc::Preferences /etc/apt/preferences #{tmp_dir}/preferences
{"name": "apt_conf"}
  File.write(
    "#{tmp_dir}/apt.conf",
    <<~HEREDOC
      Dir::State             "#{tmp_dir}/apt";
      Dir::State::status     "#{tmp_dir}/dpkg/status";
      Dir::Etc::SourceList   "#{tmp_dir}/sources.list";
      Dir::Etc::SourceParts  "none";
      Dir::Cache             "#{tmp_dir}/cache";
<<apt_setup_preferences>>
      pkgCacheGen::Essential "none";

      # apt-secure(8)
      Acquire::AllowInsecureRepositories "true";

      # apt-get
      APT::Get::AllowUnauthenticated "true";
    HEREDOC
  )

To use this file at #{tmp_dir}/apt.conf, whenever we need to call a tool like apt-get, we must specify it as:

apt-get -c ${tmp_dir}/apt.conf install firefox

The sources.list file

Feel free to replace the mirror:

{"name": "set_country_code"}
# Used to select the nearest mirror
COUNTRY_CODE = 'pl'

We initialize the #{tmpdir}/sources.list configuration file with the official repositories for the current distribution.

{"name": "apt_sources_list"}
  File.write(
    "#{tmpdir}/sources.list",
    <<~HEREDOC
      deb http://#{COUNTRY_CODE}.archive.ubuntu.com/ubuntu/ resolute-backports main restricted universe multiverse
      deb http://#{COUNTRY_CODE}.archive.ubuntu.com/ubuntu/ resolute           main restricted universe multiverse
      deb http://#{COUNTRY_CODE}.archive.ubuntu.com/ubuntu/ resolute-updates   main restricted universe multiverse
      deb http://security.ubuntu.com/ubuntu    resolute-security  main restricted universe multiverse

<<firefox_repo_list>>
<<vscode_repo_list>>
    HEREDOC
  )

Misc

FileUtils.mkdir_p and FileUtils.touch methods are used to initialize empty directories and files.

{"name": "apt_get_venv"}
<<apt_conf>>
<<apt_sources_list>>

  FileUtils.mkdir_p [
    "#{tmp_dir}/cache",
    "#{tmp_dir}/dpkg",
    "#{tmp_dir}/apt/partial",
    "#{tmp_dir}/apt/archives/partial",
    "#{tmp_dir}/rootrepo/repo"
  ]

  FileUtils.touch "#{tmp_dir}/dpkg/status"

<<firefox_preference>>

Using apt-get to discover dependencies

apt-get update is used to "learn" what packages are available in the upstream repositories, and what are their dependencies. Because we're working in an empty "virtual environment" for apt, must first download these details.

Then, apt-cache depends is used to determine what packages are needed in order to install wanted_packages. This explores the dependency graph and shows the full list of required packages.

When using system, we specify the exception: true parameter: if the command fails, a Ruby exception is raised, and the entire script crashes, while also cleaning up the contents of the tmp_dir. Without this option, we would need to validate the return-code of the call.

Also, we use Open3.capture2 to get in a variable the standard-output of the command.

{"name": "apt_init_discover_dependencies"}
  system(
    "apt-get -c #{tmp_dir}/apt.conf update",
    { exception: true }
  )

  apt_cache_out, apt_cache_return = Open3.capture2(
    "apt-cache -c #{tmp_dir}/apt.conf " \
    'depends --recurse ' \
    '--no-recommends --no-suggests --no-conflicts --no-breaks ' \
    '--no-replaces --no-enhances --no-pre-depends ' \
    wanted_packages.join(' ')
  )

  mirrored_packages = apt_cache_out.lines(chomp: true)
                                   .delete_if { |it| it.start_with? ' ' }
                                   .delete_if { |it| it.start_with? '<' }

At this point we have in mirrored_packages the list of packages that must be downloaded: it includes the wanted_packages and all of their transitive dependencies.

References:

Downloading deb files and building the ISO file

We call apt-get download using the "virtual environment" (by forcing the use of the special config-file with -c #{tmp_dir}/apt.conf) and with the list of packages that we want downloaded to the current directory.

Here we use chdir to override the current working directory seen by the called command.

{"name": "apt_download_and_build_iso"}
  iso_root_dir = "#{tmp_dir}/rootrepo"

  system(
    "apt-get -c #{tmp_dir}/apt.conf download #{mirrored_packages.join(' ')}",
    {
      chdir: "#{iso_root_dir}/repo",
      exception: true
    }
  )

<<set_up_mirror>>
<<make_iso>>

At this point we have downloaded everything in the #{iso_root_dir}/repo directory, and we need to produce some a metadata-file containing the list of packages present in this mirror. We use the dpkg-scanpackages tool for this.

{"name": "set_up_mirror"}
  system(
    "dpkg-scanpackages #{iso_root_dir}/repo /dev/null | gzip -9c > #{iso_root_dir}/Packages.gz",
    { exception: true }
  )

Now that the deb files and the file containing the metadata (Packages.gz) are in the #{iso_root_dir}, we can archive the entire thing into an ISO file (repo.iso) placed in the local directory.

{"name": "make_iso"}
  system(
    "mkisofs -lJR -o repo.iso #{iso_root_dir}",
    { exception: true }
  )

References:

Adding more tools

Now that the basic application has been defined, we can add more tools in the image, by appending groups of elements to the wanted_packages array. Every user has different needs, so feel free to customize the list of packages.

Since the list is deduplicated at the end (by doing wanted_packages.uniq!), you don't need to worry about adding the same package twice.

If you want to make sure what's the correct name of a package, visit https://packages.ubuntu.com/.

{"name": "want_more_packages"}
ruby = ['ruby', 'ruby-dev', 'ruby-bundler', 'libyaml-dev', 'libffi-dev',
        'rbenv']
wanted_packages += ruby

yocto_prerequisites = ['build-essential', 'chrpath', 'cpio', 'debianutils',
                       'diffstat', 'file', 'gawk', 'gcc', 'git', 'iputils-ping',
                       'libacl1', 'locales', 'python3', 'python3-git',
                       'python3-jinja2', 'python3-pexpect', 'python3-pip',
                       'python3-submit', 'socat', 'texinfo', 'unzip', 'wget',
                       'xz-utils', 'zstd']
wanted_packages += yocto_prerequisites

fuse_archive_prerequisites = ['git', 'libboost-container-dev', 'libfuse3-dev',
                              'libarchive-dev', 'g++', 'pkg-config', 'make',
                              'gtest-dev', 'pandoc']
wanted_packages += fuse_archive_prerequisites

<<want_firefox>>
<<want_vscode>>

Adding VS Code

VS Code is not distributed in the official Ubuntu repositories, so in order to download it, we have two steps to follow:

  • Adding the Microsoft repository to the #{tmpdir}/sources.list file:
{"name": "vscode_repo_list"}
      deb [trusted=yes] https://packages.microsoft.com/repos/code stable  main
  • Adding the code package-name to the wanted_packages list.
{"name": "want_vscode"}
vscode = ['code']
wanted_packages += vscode

Adding Firefox

This is more complicated, because in the official Ubuntu distribution, firefox is packaged as a snap, so apt-get doesn't download a regular deb package.

Fortunately, we can get the firefox deb package from the Mozilla repository, so we repeat the same steps:

  • Adding the Mozilla repository to the #{tmpdir}/sources.list file:
{"name": "firefox_repo_list"}
      deb [trusted=yes] https://packages.mozilla.org/apt          mozilla main
  • Adding the firefox package-name to the wanted_packages list.
{"name": "want_firefox"}
browser = ['firefox']
wanted_packages += browser

If we stopped here, apt-get would have a choice between two firefox packages: one from the Mozilla repo and one from the Ubuntu repo (and it will pick the Ubuntu repo, since it's the official upstream source). We need to also change the priorities and preferences used by apt-get:

  • First, we set indicate in the #{tmp_dir}/apt.conf config-file that we have a dedicated file recording our preferences at #{tmp_dir}/preferences.
{"name": "apt_setup_preferences"}
      Dir::Etc::Preferences  "#{tmp_dir}/preferences";
  • Then, we write in this file that we like the packages.mozilla.org repository more than the default one.
{"name": "firefox_preference"}
  File.write(
    "#{tmp_dir}/preferences",
    <<~HEREDOC
      Package: *
      Pin: origin packages.mozilla.org
      Pin-Priority: 1000
    HEREDOC
  )

Installing packages with apt-cdrom

First use of apt-cdrom

Edit the /etc/apt/apt.conf file to add a line with:

Acquire::cdrom::mount "/media/cdrom/";

There's a bug first reported in 2010 where the apt-cdrom tool gets confused because it comes pre-packaged with two paths to use when loading packages from optical media: /media/cdrom and /media/apt.

This workaround clears out the confusion, elimiating the references to /media/apt.

References:

Normal use

apt-cdrom add

# edit the /etc/apt/sources.list file
# to add [trusted=yes] label

# Forget the packages found in "online" repositories.
sudo rm -rf /var/lib/apt/lists/*

# Discover the packages found on the optical medium.
sudo apt-get update

# Install a package
sudo apt-get install firefox

Needs to be set to "trusted":

Known issues and limitations

  • The machine building the ISO file should have the same CPU architecture and OS version as the users of the CD/DVD.
  • Although the script was made for Ubuntu 26.04 LTS Resolute, it will probably work for other Ubuntu & Debian-like distributions.

Annex - The license

This project attepts to be mostly-compliant with the REUSE Software specifications.

One requirement of the specification is to have the text of the chosen license (MIT in our case) in a file in the LICENSES directory. As such, we add it in:

{"filename": "LICENSES/MIT.txt"}
MIT License

Copyright (c) <year> <copyright holders>

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.

Another prerequisite of the specification is that every copyrightable file must include two comments using a special formatting, containing the license and the author/copyright holder.

{"name": "spdx_headers"}
# SPDX-FileCopyrightText: 2026 PersonalCompute.Net <publisher@PersonalCompute.Net>
# SPDX-License-Identifier: MIT

We can check the REUSE compliance using the lint tool:

pipx run reuse lint