Vagrant: cache VM downloads and support several checkouts - #1379
Merged
gusthoff merged 24 commits intoSep 11, 2026
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Provisioning downloaded everything into the VM, so
vagrant destroythrew 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
mounted into both VMs.
downloaded to a per-checkout temporary name that moves into place
atomically.
LEARN_VM_CACHE_GNATandLEARN_VM_CACHE_APT,defaulting to a gitignored
.toolchains/.VM_APT_PIN=0, which a newUbuntu base box requires, and
vm_apt_capture.shregenerates the listsfrom a running VM.
LEARN_WEB_PORT,LEARN_WEB_SSH_PORTandLEARN_EPUB_SSH_PORTletseveral checkouts run their VMs at once; the defaults reproduce the
previous behavior and a clash relocates the port instead of failing.
vm_cache_report.shandvm_cache_clean.shmaintain both caches, onescript 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
--deleteis given.frontend/vm/README.mddocuments all of it as a how-to.Testing & validation
2.3 GB of toolchains, 227 MB of packages.
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.
390 files matched the pinned lists, and apt's own
lockandpartial/are never touched.other is still handled, and the run exits non-zero.
Notes
from a store on a shared folder fails with
EBADF, and the storeholds unpacked content, so moving it would cost more than the
download.
Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com