Coverage for structured_tutorials / runners / vagrant.py: 100%
108 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-04 13:17 +0200
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-04 13:17 +0200
1# Copyright (c) 2026 Mathias Ertl
2# Licensed under the MIT License. See LICENSE file for details.
4"""Class for running the VagrantRunner."""
6import io
7import logging
8import os.path
9import shlex
10import shutil
11import subprocess
12import tempfile
13from collections.abc import Sequence
14from pathlib import Path
15from subprocess import CompletedProcess
16from typing import Any
18from pydantic import BaseModel, Field
20from structured_tutorials.errors import (
21 ConfigurationException,
22 RequiredExecutableNotFoundError,
23 RunTutorialException,
24)
25from structured_tutorials.models.base import CommandType
26from structured_tutorials.models.types import RelativePath
27from structured_tutorials.runners.base import RunnerBase
29log = logging.getLogger(__name__)
30command_logger = logging.getLogger("command")
32PATH_ENVIRONMENT_VARIABLES = (
33 "VAGRANT_CWD",
34 "SSL_CERT_FILE",
35 "VAGRANT_ALIAS_FILE",
36 "VAGRANT_DOTFILE_PATH",
37 "VAGRANT_HOME",
38)
41class PrepareBoxOptions(BaseModel):
42 """Options for preparing a box."""
44 name: str
45 path: RelativePath
48class VagrantOptions(BaseModel):
49 """Options for running vagrant."""
51 environment: dict[str, Any] = Field(default_factory=dict)
52 cwds: dict[str, str] = Field(default_factory=dict)
53 prepare_box: PrepareBoxOptions | None = None
56class VagrantRunner(RunnerBase):
57 """Runner to run Vagrant tutorials."""
59 def __init__(self, *args: Any, **kwargs: Any) -> None:
60 super().__init__(*args, **kwargs)
62 # Check if vagrant is installed
63 if not shutil.which("vagrant", path=self.environment.get("PATH")):
64 raise RequiredExecutableNotFoundError("vagrant: Executable not found.")
66 self.config = VagrantOptions.model_validate(self.tutorial.configuration.run.runner.options)
67 self.config.environment.setdefault("VAGRANT_CWD", str(self.tutorial.root))
68 self.config.environment.setdefault("PATH", os.environ["PATH"])
69 self.cwds: dict[str, str] = self.config.cwds.copy()
71 # convert relative paths to absolute paths for known path variables:
72 for key, value in self.config.environment.items():
73 if key in PATH_ENVIRONMENT_VARIABLES:
74 value_path = Path(value)
75 if not value_path.is_absolute():
76 value_path = self.tutorial.root / value_path
77 self.config.environment[key] = str(value_path.resolve())
79 def vagrant(
80 self, args: Sequence[str], env: dict[str, str] | None = None, check: bool = True
81 ) -> subprocess.CompletedProcess[bytes]:
82 """Run a vagrant command.
84 NOTE: This method is intended to be run by boilerplate parts (e.g. preparation, cleanup) of running
85 the tutorial, and *not* for commands from the tutorial.
86 """
87 command = ["vagrant", *args]
88 joined_command = shlex.join(command)
89 command_logger.info(joined_command)
90 env = {**os.environ, **self.config.environment, **(env or {})}
91 proc = subprocess.run(command, env=env, capture_output=not self.show_command_output)
92 if check and proc.returncode != 0:
93 raise RunTutorialException(f"{joined_command} exited with status code {proc.returncode}.")
94 return proc
96 def chdir(self, path: str, options: dict[str, Any]) -> None:
97 machine = options.get("machine", "default")
98 self.cwds[machine] = path
100 def run_shell_command(
101 self,
102 command: CommandType,
103 show_output: bool,
104 capture_output: bool = False,
105 stdin: int | io.BufferedReader | None = None,
106 input: bytes | None = None,
107 environment: dict[str, Any] | None = None,
108 clear_environment: bool = False,
109 options: dict[str, Any] | None = None,
110 ) -> CompletedProcess[bytes]:
111 if options is None: # pragma: no cover # doesn't happen in real time
112 options = {}
114 machine = options.get("machine", "default")
115 command = self.render_command(command)
116 if isinstance(command, tuple):
117 command = shlex.join(command)
119 cmd = command
120 cwd = self.cwds.get(machine)
121 if cwd:
122 cmd = f"{shlex.join(['cd', cwd])} && {command}"
124 vagrant_command = ["vagrant", "ssh", machine, "-c", cmd]
126 proc = super().run_shell_command(
127 tuple(vagrant_command),
128 show_output=show_output,
129 capture_output=capture_output,
130 stdin=stdin,
131 input=input,
132 environment=self.config.environment,
133 clear_environment=True,
134 )
135 return proc
137 def prepare_vagrantfile(self, path: Path) -> None:
138 """Render a Vagrantfile in the given directory."""
139 if not path.exists():
140 raise ConfigurationException(f"{path}: Directory not found.")
142 vagrantfile_path = path / "Vagrantfile"
143 template_path = vagrantfile_path.with_suffix(".jinja")
144 if os.path.exists(template_path):
145 with open(template_path) as template_stream:
146 template = template_stream.read()
148 contents = self.render(template)
149 with open(vagrantfile_path, "w") as vagrant_file_stream:
150 vagrant_file_stream.write(contents)
151 elif not os.path.exists(vagrantfile_path):
152 raise ConfigurationException(f"Vagrantfile not found: {vagrantfile_path}")
154 def prepare_box(self, config: PrepareBoxOptions) -> None:
155 """Prepare the box before running the tutorial, if requested."""
156 vagrant_cwd = self.tutorial.root / config.path
157 self.context["box"] = config.name # update context with name of box
159 # Render the Vagrantfile to prepare the box
160 self.prepare_vagrantfile(vagrant_cwd)
162 result = subprocess.run(
163 ["vagrant", "box", "list", "--machine-readable"], capture_output=True, text=True, check=True
164 )
165 if any(
166 row.split(",")[2] == "box-name" and row.split(",")[3] == config.name
167 for row in result.stdout.splitlines()
168 ):
169 log.info("Box '%s' already exists, skip creation.", config.name)
170 return
172 log.info("Preparing Vagrant box in %s", vagrant_cwd)
173 env = {"VAGRANT_CWD": str(vagrant_cwd)}
174 try:
175 self.vagrant(["up"], env=env)
176 self.vagrant(["package", "--output", f"{config.name}.box"], env=env)
177 finally:
178 self.vagrant(["destroy", "-f"], env=env)
180 self.vagrant(["box", "add", config.name, f"{config.name}.box"], env=env)
182 def update_environment_variable(self, key: str, value: str, options: dict[str, str]) -> None:
183 self.run_shell_command(
184 f'echo export {key}="{value}" >> ~/.profile', show_output=False, options=options
185 )
187 def prepare_tutorial(self) -> None:
188 """Prepare tutorial for running."""
189 if prepare_box_config := self.config.prepare_box:
190 self.prepare_box(prepare_box_config)
192 # Render the main Vagrantfile for the tutorial
193 self.prepare_vagrantfile(Path(self.config.environment["VAGRANT_CWD"]))
194 self.vagrant(["up"])
196 def cleanup_tutorial(self) -> None:
197 """Cleanup tutorial after running."""
198 self.vagrant(["destroy", "-f"])
200 def copy_file(self, source: Path, destination: Path, options: dict[str, Any]) -> None:
201 machine = options.get("machine", "default")
202 self.vagrant(["upload", str(source), str(destination), machine])
204 def write_file_from_string(self, contents: str, destination: Path, options: dict[str, Any]) -> None:
205 # Only way to write a file from string is to write it to a temporary file on the host system
206 with tempfile.NamedTemporaryFile(mode="w", delete=False) as temp_file:
207 temp_file.write(contents)
208 temp_file.flush()
209 self.copy_file(Path(temp_file.name), destination, options)