Skip to content

Vagrant: cache VM downloads and support several checkouts - #1379

Merged
gusthoff merged 24 commits into
AdaCore:mainfrom
gusthoff:dev/topic/infrastructure/vagrant/download-caching/2026-09-11
Sep 11, 2026
Merged

Vagrant: cache VM downloads and support several checkouts#1379
gusthoff merged 24 commits into
AdaCore:mainfrom
gusthoff:dev/topic/infrastructure/vagrant/download-caching/2026-09-11

Conversation

@gusthoff

@gusthoff gusthoff commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Why

Provisioning downloaded everything into the VM, so vagrant destroy
threw it away: over 2 GB of GNAT toolchains and some 230 MB of Debian
packages per rebuild, the toolchains twice over because each VM fetched
its own copy. Two related gaps came with it — only one checkout could
run its VMs at a time, and the pinned package lists could not be
regenerated.

What changed

  • Host-side caches for the GNAT toolchain tarballs and the Debian packages,
    mounted into both VMs.
    • Tarballs are checked against the upstream SHA-256 on every use, and
      downloaded to a per-checkout temporary name that moves into place
      atomically.
    • Locations set by LEARN_VM_CACHE_GNAT and LEARN_VM_CACHE_APT,
      defaulting to a gitignored .toolchains/.
  • Package pinning can be switched off with VM_APT_PIN=0, which a new
    Ubuntu base box requires, and vm_apt_capture.sh regenerates the lists
    from a running VM.
  • LEARN_WEB_PORT, LEARN_WEB_SSH_PORT and LEARN_EPUB_SSH_PORT let
    several checkouts run their VMs at once; the defaults reproduce the
    previous behavior and a clash relocates the port instead of failing.
  • vm_cache_report.sh and vm_cache_clean.sh maintain both caches, one
    script per cache behind them. An entry is unneeded only when the files
    the provisioner reads no longer name it, and removal is a dry run
    unless --delete is given.
  • frontend/vm/README.md documents all of it as a how-to.

Testing & validation

  • Both VMs were provisioned end to end, and later runs reused the caches:
    2.3 GB of toolchains, 227 MB of packages.
  • Toolchain fetching was exercised against the real upstream release: a
    second fetch hits the cache, a damaged file is re-fetched, a checksum
    mismatch fails without touching the cache, and two concurrent fetches
    leave one valid file.
  • Package detection was checked against a provisioned VM's cache: 390 of
    390 files matched the pinned lists, and apt's own lock and
    partial/ are never touched.
  • The entry points were checked with one cache deliberately broken: the
    other is still handled, and the run exits non-zero.

Notes

  • A cache for the pnpm store was implemented and then removed: importing
    from a store on a shared folder fails with EBADF, and the store
    holds unpacked content, so moving it would cost more than the
    download.

Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com

gusthoff and others added 24 commits September 11, 2026 15:58
The pin lists record the package versions of a VM whose output was
verified, so a provision reproduces that machine. They only apply to the
box they were captured from: pointing the Vagrantfile at a new Ubuntu
series makes them a list of versions that series never carried, and the
provisioner aborts before a new list can be captured.

Gate the step on VM_APT_PIN, which defaults to 1. `VM_APT_PIN=0 vagrant
up` skips it, which is what a base-box bootstrap needs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Group the two pin lists with the script that regenerates them, rather
than leaving them loose in frontend/. Update the provision source paths
for both VMs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A full reprovision re-downloaded 3.3 GB of GNAT-FSF tarballs, and the
three gnat ones twice, since each VM fetched independently. Fetch them
into a host-side folder mounted at /vagrant_cache/gnat instead, so
destroying a VM no longer throws them away. Redirect it with
LEARN_VM_CACHE_GNAT; the default is the gitignored .toolchains/gnat.

vm_toolchain_fetch.sh verifies each tarball against the .sha256 sidecar
upstream publishes, on every use rather than only after downloading, and
downloads to a per-VM .part file so the two VMs cannot collide. It runs
on the host too: `--all` warms the cache before `vagrant up`.

This also drops `rm *.tar.gz`, a bare glob in the provisioner's working
directory rather than the file just downloaded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pnpm install re-downloaded a 530 MB store on every reprovision, in both
VMs. Point pnpm's store at a host-side folder mounted at
/vagrant_cache/node instead. Redirect it with LEARN_VM_CACHE_NODE; the
default is the gitignored .toolchains/node.

Putting the store on a shared folder costs nothing: node_modules already
lives on one, so pnpm has been copying rather than hardlinking all along
(hardlinks are refused on vboxsf, and a file under node_modules/.pnpm
reports a link count of 1).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Keep downloaded .deb files in a host-side folder shared by both VMs
rather than discarding them with the VM. Redirect it with
LEARN_VM_CACHE_APT; the default is the gitignored .toolchains/apt.

Dir::Cache::Archives is redirected instead of bind-mounting over
/var/cache/apt/archives, so apt's lock and partial/ handling stays
explicit. APT::Sandbox::User is set to root because the _apt user cannot
read a vboxsf share owned by vagrant.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dropping a version from toolchain.ini leaves its tarball in the download
cache and nothing prunes it, so orphans accumulate unnoticed. Both
scripts derive the set of wanted versions from the same toolchain.ini the
provisioner reads, so "orphan" cannot drift from "needed".

vm_cache_clean.sh defaults to a dry run: the cache directory is
configurable and may be anywhere on the host, so deleting has to be
asked for explicitly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The scripts went in without the executable bit: this repo has
core.fileMode disabled, so chmod on the working tree never reached the
index. Set the mode explicitly.

The provisioner also calls vm_toolchain_fetch.sh via bash rather than
directly, since the script reaches the VM over a vboxsf share whose mount
options need not expose the executable bit either.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The cache locations are read in two places -- the Vagrantfile and the
helper scripts in `frontend/vm/` -- and only the defaults were expanded
to absolute paths. A relative value in `LEARN_VM_CACHE_GNAT` and friends
was therefore taken verbatim on both sides and resolved against each
process's own working directory, so the Vagrantfile and the scripts could
disagree about where the cache is.

Expand the environment value as well, against the repository root in both
cases. Absolute values are unaffected.

Verified by resolving an absolute override, a relative override and the
default from three different working directories: all three now report
the same location from each.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Bringing up the web VM while another one already holds host port 8080
aborts with "Vagrant cannot forward the specified ports on this VM",
since an explicitly declared `forwarded_port` is fatal on collision by
default. That happens whenever a second checkout of this repository
brings up its own VMs.

Enable `auto_correct` on the rule so Vagrant picks a free host port and
warns instead of refusing to boot. `vagrant port web` reports the port
actually in use.

The guest port is unchanged, so a single-VM setup still gets 8080 and the
address documented in the README stays correct.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`auto_correct` keeps a second web VM from failing to boot, but the port
it lands on is then arbitrary and has to be looked up after the fact.

Read the host port from `LEARN_WEB_PORT`, defaulting to 8080. A checkout
that sets it gets a predictable address; `auto_correct` remains as the
fallback when even that port is taken.

The default reproduces the previous behaviour exactly, so an unmodified
single-VM setup is unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The SSH host ports were whatever Vagrant chose: 2222 for the machine that
booted first and 2200, the base of the auto-correct range, for the
second. That ordering is not stable once several checkouts run their own
VMs, so the ports shift and any command using a fixed one breaks.

Declare both explicitly under the reserved `ssh` forwarding id, which
overrides Vagrant's own rule rather than adding a second one, and read
them from `LEARN_WEB_SSH_PORT` and `LEARN_EPUB_SSH_PORT`. The defaults
reproduce the values a single checkout gets today.

`auto_correct` stays enabled, so these remain a preference rather than a
guarantee and `vagrant ssh-config` is still authoritative.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`LEARN_VM_NAME` distinguishes the temporary files `vm_toolchain_fetch.sh`
writes while downloading, so that the web and epub VMs sharing one cache
cannot collide. It held the bare machine name, which is unique only
within a single checkout: two `web` VMs from different checkouts would
both write the same `.part` file and overwrite each other mid-download.

Prefix it with the checkout's directory name, giving values such as
`ada-learning-material-web`.

Verified by running two fetches of the same tarball concurrently under
different names into one cache: both completed, each wrote its own
temporary, and the resulting tarball passes its checksum.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`pnpm install` fails when its store sits on a shared folder:

  [ERR_PNPM_EBADF] [importPackage .../acorn] EBADF: bad file
  descriptor, copyfile '/vagrant_cache/node/v11/files/...'

Downloading works -- the packages resolve and land in the store -- but
importing them into `node_modules` aborts partway through. It is not a
matter of import strategy: `package-import-method=copy` fails the same
way, and `copyfile` and `copy_file_range` both succeed in isolation
between the same two directories. The failure only appears under pnpm's
concurrent import of several hundred packages.

Even working, the cache would not pay for itself. The store holds
unpacked, content-addressed files, so it is considerably larger than the
compressed tarballs actually fetched; copying it in and out of the shared
folder would cost more time than the download it replaces.

The toolchain and apt caches are unaffected and stay.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pin lists in `frontend/vm/` record the exact package versions of a VM
whose build output has been checked, so that a later `vagrant up`
reproduces that machine rather than whatever the archive serves on the
day. Nothing recorded how to regenerate them: the command existed only
implicitly, in the shape of the committed files.

`vm_apt_capture.sh` writes a snapshot of one or both VMs over
`vagrant ssh`, using `dpkg-query -W -f='${binary:Package}=${Version}\n'`
-- the format that keeps the `:arch` suffix on multi-arch packages and
omits it elsewhere, which is what `apt-get install` expects back. It goes
through `vagrant ssh` rather than a fixed port so it keeps working when
the forwarded ports move.

A failed capture leaves the existing list untouched: the download is
written to a temporary file whose exit status is checked explicitly,
since the redirection creates that file before the command runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The VMs grew several options that were documented only as comments in
the `Vagrantfile`: environment variables for the host ports and the
download caches, the switch that skips apt package pinning, and the
scripts in `frontend/vm/` that fill the caches and regenerate the
pinned lists. None of it was discoverable from the top-level README.

`frontend/vm/README.md` is the longer form of that README's "Getting
started" section. It is organized by task rather than by file:
bringing the VMs up and connecting to them, running the VMs of several
checkouts side by side, using the download caches, what the pinned
package lists are and how to update them, and the sequence for moving
to a new Ubuntu base box.

Each documented variable and default was checked against the
`Vagrantfile`, and each command against the script it invokes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`vm_cache_report.sh` and `vm_cache_clean.sh` handled two unrelated
caches, and the generic names hid how unequal that handling was: almost
everything they did applied to the GNAT toolchain cache, while the apt
cache contributed one line of output and could not be cleaned at all.

Move the work into one script per cache, `vm_cache_gnat.sh` and
`vm_cache_apt.sh`, each taking the same verbs. The two existing scripts
become entry points that dispatch to them and keep their options
unchanged, so the documented commands behave exactly as before; either
cache script can also be invoked directly. `vm_cache_apt.sh` implements
only `summary`, which is all the apt cache supports today.

Verified against output captured before the change:
`vm_cache_report.sh`, `vm_cache_report.sh --orphans` and
`vm_cache_clean.sh` are byte-identical over a cache holding required
entries, a dropped version and a stale `.part` file. `--delete` removes
the same three entries, leaves the six required ones and is idempotent.
`--help` and an unknown argument were checked on all four scripts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The apt cache grew without bound: every package upgrade adds a .deb file
and nothing ever removed the superseded one. `vm_cache_apt.sh` could
only report the size of the directory.

Give it the same verbs as the toolchain cache script: `report`,
`orphans` and `clean`. A package file is no longer required when its
package and version appear in neither `vm_apt_web.txt` nor
`vm_apt_epub.txt`, which are the lists the provisioner installs from, so
the criterion cannot drift from what the VMs actually need. Matching
accounts for the `%3a` encoding of an epoch separator in a filename and
for the architecture qualifier that a multi-arch entry carries in the
lists but that the filename may render as `all` or `amd64`.

Only regular `.deb` files directly in the directory are considered; apt
keeps `lock`, `partial/` and `apt/` there as well. A missing pin list is
an error rather than a reason to treat the whole cache as removable, and
a report in which most entries look unneeded prints a warning, since
that is what a cache filled by a `VM_APT_PIN=0` bootstrap looks like
before the lists have been captured.

Verified against the cache of a provisioned VM: all 390 package files
match the pinned lists, none spuriously reported. On a constructed cache
covering the epoch, `all` and `amd64` cases, superseded versions are
reported and required ones are not; `clean --delete` removes exactly
those and is idempotent; `lock`, `partial/` and `apt/` are never listed;
a missing list exits non-zero without deleting anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The entry points covered both caches only nominally: the report showed
one summary line for the apt cache and no detail, and cleaning skipped
it entirely by handing the whole invocation to the toolchain script.

Call both cache scripts from both entry points. `vm_cache_report.sh` now
prints the apt detail after the toolchain detail and includes apt paths
in `--orphans`; `vm_cache_clean.sh` runs the requested verb against each
cache in turn instead of replacing itself with one of them, so its dry
run and `--delete` apply to both.

The documented options are unchanged.

Verified on a cache holding an unneeded tarball and an unneeded package
file: both entry points report and remove from both caches, and the dry
run remains the default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
With both caches reporting detail, the two sections ran together: the
apt figures followed the toolchain orphan list with no indication that a
new cache had started.

Print a heading before each detail section. The cache scripts are
unchanged, so a section keeps its current form when one of them is run
on its own, where the context is already unambiguous.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The how-to described the cleanup scripts as removing stale toolchain
tarballs, which is no longer the whole story now that the apt cache is
covered too.

Say what each cache accumulates, and add the caveat specific to the apt
cache: a package file counts as required only if one of the pinned lists
names it, so a cache filled by a `VM_APT_PIN=0` bootstrap, or by a VM
upgraded in place, is almost entirely reported as removable until the
lists have been regenerated. Deleting in that state is harmless --
provisioning only installs what the lists name -- but it discards files
the next `vagrant up` immediately fetches again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both entry points run under `set -e`, so a non-zero exit from the first
cache script ended the run before the second was reached. A missing
`toolchain.ini` therefore suppressed the apt report entirely, and a
missing pinned list suppressed the toolchain cleanup -- silently, since
the output simply stopped rather than reporting a problem.

Run every cache script regardless, and return the worst status. One
unusable cache no longer hides the state of the others.

Verified by removing `toolchain.ini` and, separately, a pinned list: in
each case the other cache is still reported and cleaned, and both
invocations exit non-zero.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two scripts own the toolchain cache: one downloads into it, the other
reports and prunes it. Their names shared nothing, so neither suggested
the other existed, let alone that both resolve `LEARN_VM_CACHE_GNAT`
and parse `toolchain.ini` to decide what belongs there.

Rename `vm_toolchain_fetch.sh` to `vm_cache_gnat_fetch.sh`, so the
common prefix names the cache both act on. The provisioner and the
script's own documentation follow the new name; nothing else changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Downloading into the toolchain cache and pruning it are operations on
one cache, but callers had to know which of two scripts performed which.

Give `vm_cache_gnat.sh` a `fetch` verb that delegates to
`vm_cache_gnat_fetch.sh`, making it the only interface to that cache.
Downloading stays in its own file, which is large enough to be worth
separating, but callers no longer see it: the provisioner runs
`vm_cache_gnat.sh fetch <tool> <version>`.

This also makes the difference between the caches legible. The apt cache
has no `fetch`, because its contents depend on what apt resolves during
provisioning and cannot be fetched ahead of time.

Verified through the verb: a cold fetch downloads, a second reports
`Using cached`, standard output carries only the cached path, and both
errors and exit statuses pass through unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three statements in the how-to no longer matched the scripts: the
pre-warm commands named the download script directly rather than the
`fetch` verb that now fronts it, the example of invoking a single cache
showed the toolchain script twice, and the two cache scripts were
described as taking the same verbs, which stopped being true once only
one of them gained `fetch`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@gusthoff
gusthoff merged commit d512f6a into AdaCore:main Sep 11, 2026
9 checks passed
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.

1 participant