# APT-Cdrom image builder

{% infobox %}
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](https://personalcompute.net/resources/literate-programming) article.
{% endinfobox %}

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.

```lruby
{"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`](https://docs.ruby-lang.org/en/3.3/Dir.html#method-c-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`.

```lruby
{"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`  |

```lruby
{"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:

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

### The `sources.list` file

Feel free to replace the mirror:

```lruby
{"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.

```lruby
{"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`](https://docs.ruby-lang.org/en/3.3/FileUtils.html#method-i-mkdir_p) and
[`FileUtils.touch`](https://docs.ruby-lang.org/en/3.3/FileUtils.html#method-c-touch) methods are used to initialize empty directories and files.

```lruby
{"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`](https://docs.ruby-lang.org/en/3.3/Kernel.html#method-i-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`](https://docs.ruby-lang.org/en/3.3/Open3.html#method-i-capture2)
to get in a variable the standard-output of the command.

```lruby
{"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:
* <https://stackoverflow.com/a/45489718>
* <https://stackoverflow.com/a/41428445>

## 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.

```lruby
{"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`](https://manpages.ubuntu.com/manpages/resolute/man1/dpkg-scanpackages.1.html)
tool for this.


```lruby
{"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.

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

References:
* <https://askubuntu.com/a/458754>
* <https://gist.github.com/awesomebytes/ce0643c1ddead589ab06e2a1e4c5861b>
* <https://unix.stackexchange.com/a/274751>

## 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/>.

```lruby
{"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. 

```lruby
{"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. 

```lruby
{"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.

```lruby
{"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:
* <https://bugs-devel.debian.org/cgi-bin/bugreport.cgi?bug=606930>
* <https://www.linuxquestions.org/questions/debian-26/problems-with-apt-cdrom-add-command-866761/>


### Normal use

```bash
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":
* <https://www.reddit.com/r/debian/comments/1gz0qpm/should_cddvd_have_a_release_file/>


## 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](https://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.

```lruby
{"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:

```bash
pipx run reuse lint
```
