Source code for meta_package_manager.managers.vagrant

# Copyright Kevin Deldycke <kevin@deldycke.com> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.

from __future__ import annotations

import json
import re
from typing import ClassVar

from extra_platforms import LINUX_LIKE, MACOS, WINDOWS

from ..capabilities import search_capabilities, version_not_implemented
from ..manager import PackageManager
from ..version import parse_version

TYPE_CHECKING = False
if TYPE_CHECKING:
    from collections.abc import Iterator

    from ..package import Package


[docs] class Vagrant(PackageManager): """Vagrant's box manager, covering the base images it fetches from its registry. Vagrant orchestrates virtual machines, which is not package management. Two of its subcommand trees are: `vagrant box`, covering versioned base images pulled from a registry, and `vagrant plugin`, covering Vagrant's own extensions. Only one can be the inventory, and boxes are it. They carry the whole operation set, where plugins offer neither an `outdated` nor a search of any kind, and a plugin is a RubyGem installed into a private gem home rather than something with a registry of its own. A package is a box, identified by the bare name the listing prints, which may be a registry name like `ubuntu/jammy64`, a purely local name, or a full URL. The provider and the architecture are deliberately dropped from the identifier. ```{note} That last point is what makes this a class rather than a definition. Vagrant lists one row per *(name, provider, version)* triple, so a box installed in three versions appears three times, and the same is true of the outdated report. mpm keys a package on its id alone, so both listings are reduced here to one entry per name, keeping the newest version installed. ``` ```{caution} Every box command reads the registry under `~/.vagrant.d` and needs no Vagrantfile, with two exceptions that are avoided rather than handled: `vagrant box outdated` inspects only the boxes the *current directory's* Vagrantfile declares unless `--global` is passed, and `vagrant box update` is scoped the same way unless `--box` names one. Both forced flags are therefore load-bearing: without them the answer would depend on where mpm happened to be invoked, and would fail outright outside a Vagrant project. One piece of ambient state cannot be escaped: Vagrant evaluates the Vagrantfile's trigger configuration on every subcommand, so a *malformed* Vagrantfile in the working directory breaks even `box list`. Only the version probe is immune. ``` ```{note} No `upgrade --all`: Vagrant has no command that updates every installed box, `box update` addressing either one named box or the current project's. mpm backfills it from `outdated` plus the per-box upgrade instead. No `sync` either, there being no command that refreshes box metadata without also downloading, and the machine-readable output mode is unusable for boxes: it emits four lines per box with an empty target column, so nothing correlates them back into a record. ``` Documentation: [Vagrant boxes](https://developer.hashicorp.com/vagrant/docs/boxes). """ maintenance_note = ( "Upstream has slowed: the last stable release is `2.4.9` of August 2025, " "though the repository is still committed to. Note also that Vagrant is " "distributed under the Business Source License from `2.4.3` onwards, " "which some distributions treat as non-free." ) name = "Vagrant" homepage_url = "https://www.vagrantup.com" logo = "vagrant" platforms = LINUX_LIKE, MACOS, WINDOWS requirement = ">=2.4.0" """The release whose listing groups boxes and renders the architecture as a trailing parenthesized segment, which is the shape {attr}`_INSTALLED_REGEXP` parses. """ extra_env: ClassVar = { # Silences the release-check round-trip Vagrant makes on every command, # along with the upgrade banner it prints when one is available. "VAGRANT_CHECKPOINT_DISABLE": "1", } version_regexes = (r"^Vagrant[ \t]+(?P<version>\S+)$",) r"""Search the version right after the `Vagrant ` string. ```{code-block} shell-session $ vagrant --version Vagrant 2.4.9 ``` Note the dashes: `vagrant version` without them is a different command that queries the network for the latest release. """ _INSTALLED_REGEXP = re.compile( r"^(?P<package_id>\S+)[ \t]+\((?P<provider>[^,()]+),[ \t]+" r"(?P<installed_version>[^,()]+?)(?:,[ \t]+\([^()]*\))?\)$", ) """One box row: the name, then a parenthesized provider and version, and on recent Vagrants a further parenthesized architecture the pattern discards. """ _OUTDATED_REGEXP = re.compile( r"^\*[ \t]+'(?P<package_id>[^']+)'[ \t]+for[ \t]+'[^']+'[ \t]+is outdated!" r"[ \t]+Current:[ \t]+(?P<installed_version>.+?)\.[ \t]+" r"Latest:[ \t]+(?P<latest_version>\S+)$", ) """A box with an update pending. The report interleaves three other shapes, for a box that is current, one that was never added from a catalog and one whose metadata failed to load: none carries an update, and none matches. """ def _newest_per_box( self, rows: Iterator[tuple[str, str, str | None]], ) -> Iterator[Package]: """Reduce `(name, installed, latest)` rows to one package per box. Vagrant reports a row per version and provider, so a box installed several times over appears several times. Only the newest installed version is kept, which is the one a bare `vagrant box update --box` acts on. """ best: dict[str, tuple[str, str | None]] = {} for package_id, installed, latest in rows: current = best.get(package_id) if current is None or parse_version(installed) > parse_version(current[0]): best[package_id] = (installed, latest) for package_id, (installed, latest) in best.items(): yield self.package( id=package_id, installed_version=installed, latest_version=latest, ) @property def installed(self) -> Iterator[Package]: """Fetch installed packages. ```{code-block} shell-session $ vagrant box list linuxmint-21.3-cinnamon-64bit (hyperv, 0) mintv1 (hyperv, 0) wolvverine/LinuxMintCinnamon (hyperv, 1.1, (amd64)) ``` """ output = self.run_cli("box", "list") yield from self._newest_per_box( (match.group("package_id"), match.group("installed_version"), None) for match in map(self._INSTALLED_REGEXP.match, output.splitlines()) if match ) @property def outdated(self) -> Iterator[Package]: """Fetch outdated packages. `--global` is what makes this answer for the machine: without it Vagrant reports only the boxes the working directory's Vagrantfile declares, and fails where there is none. ```{caution} Vagrant exits zero whether or not updates were found, by an explicit upstream decision, so the listing itself is the only signal. ``` ```{code-block} shell-session $ vagrant box outdated --global * 'ubuntu/jammy64' for 'virtualbox' is outdated! Current: 20231012.0.0. Latest: 20240126.0.0 * 'ubuntu/jammy64' for 'virtualbox' is outdated! Current: 20230914.0.0. Latest: 20240126.0.0 * 'ubuntu/jammy64' for 'virtualbox' is outdated! Current: 20230616.0.0. Latest: 20240126.0.0 ``` """ output = self.run_cli("box", "outdated", "--global") yield from self._newest_per_box( ( match.group("package_id"), match.group("installed_version"), match.group("latest_version"), ) for match in map(self._OUTDATED_REGEXP.match, output.splitlines()) if match )
[docs] @search_capabilities(extended_support=False, exact_support=False) def search(self, query: str, extended: bool, exact: bool) -> Iterator[Package]: """Fetch matching packages. Boxes have no search of their own: the query goes to the registry through Vagrant's `cloud` command tree, which answers anonymously unless credentials are explicitly requested. ```{caution} Search does not support extended or exact matching. ``` ```{code-block} console $ vagrant cloud search ubuntu --json [ { "name": "ubuntu/jammy64", "version": "20240126.0.0", "downloads": "1,234,567", "providers": "virtualbox", "architectures": "amd64" } ] ``` """ output = self.run_cli("cloud", "search", query, "--json") for entry in json.loads(output) if output.strip() else (): package_id = entry.get("name") if not package_id: continue yield self.package(id=package_id, latest_version=entry.get("version"))
[docs] @version_not_implemented def install(self, package_id: str, version: str | None = None) -> str: """Install one package. ```{code-block} shell-session $ vagrant box add ubuntu/jammy64 ``` """ return self.run_cli("box", "add", package_id)
[docs] @version_not_implemented def upgrade_one_cli( self, package_id: str, version: str | None = None, ) -> tuple[str, ...]: """Generates the CLI to upgrade the package provided as parameter. `--box` names the box explicitly, which is what lets this run outside a Vagrant project: the bare form updates whatever the current directory's Vagrantfile declares instead. ```{code-block} shell-session $ vagrant box update --box ubuntu/jammy64 ``` """ return self.build_cli("box", "update", "--box", package_id)
[docs] def remove(self, package_id: str) -> str: """Removes a package. `--all` removes every version, provider and architecture of the box in one call. It is what keeps this addressable by a bare name: without it Vagrant refuses a box held in several versions and demands an explicit `--box-version`. `--force` skips the confirmation Vagrant would otherwise ask for a box still attached to a machine. ```{code-block} shell-session $ vagrant box remove --force --all ubuntu/jammy64 ``` """ return self.run_cli("box", "remove", "--force", "--all", package_id)
[docs] def cleanup_orphan(self) -> None: """Removes outdated versions of installed boxes. Keeps the newest version of each box and drops the rest. `--force` skips the confirmation Vagrant asks when a stale version is still attached to a machine, which would otherwise abort for want of a terminal. ```{code-block} shell-session $ vagrant box prune --force ``` """ self.run_cli("box", "prune", "--force")