pyrecracker 0.5.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,52 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyrecracker
3
+ Version: 0.5.1
4
+ Summary: Python module for creating and controlling micro VMs with Firecracker
5
+ License-Expression: MIT
6
+ Author: Jake Jongewaard
7
+ Author-email: jjongewaard1@gmail.com
8
+ Requires-Python: >=3.12
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Programming Language :: Python :: 3.14
13
+ Requires-Dist: requests-unixsocket (>=0.4.1,<0.5.0)
14
+ Description-Content-Type: text/markdown
15
+
16
+ # Pyrecracker
17
+ Pyrcracker is a python module that can be used to create, run, and manage the lifecycle of micro virtual machines using Firecracker. Pyrecracker gives a simple to use API for VM management that can be used from within python based applications.
18
+
19
+ ## Supported Operating Systems
20
+ The following are the operating systems that pyrecracker has been tested on:
21
+ - Ubuntu 24.04 LTS (with nested virtualization enabled)
22
+
23
+ ## Installing
24
+ If using pip, pyrecracker can be installed with:
25
+
26
+ ```
27
+ pip install pyrecracker
28
+ ```
29
+
30
+ If using poetry, pyrecracker can be installed with:
31
+
32
+ ```
33
+ poetry add pyrecracker
34
+ ```
35
+
36
+ ## Building
37
+ This project uses poetry for building and packaging. Pyrecracker can be built with the following command:
38
+
39
+ ```
40
+ poetry build
41
+ ```
42
+
43
+ This will generate the project's tar.gz and .whl files in the `./dist` directory.
44
+
45
+ ## Testing
46
+ Unit tests can be run with the following:
47
+
48
+ ```
49
+ poetry run pytest
50
+ ```
51
+
52
+ Examples on using pyrecracker can be found in the `./examples` directory.
@@ -0,0 +1,37 @@
1
+ # Pyrecracker
2
+ Pyrcracker is a python module that can be used to create, run, and manage the lifecycle of micro virtual machines using Firecracker. Pyrecracker gives a simple to use API for VM management that can be used from within python based applications.
3
+
4
+ ## Supported Operating Systems
5
+ The following are the operating systems that pyrecracker has been tested on:
6
+ - Ubuntu 24.04 LTS (with nested virtualization enabled)
7
+
8
+ ## Installing
9
+ If using pip, pyrecracker can be installed with:
10
+
11
+ ```
12
+ pip install pyrecracker
13
+ ```
14
+
15
+ If using poetry, pyrecracker can be installed with:
16
+
17
+ ```
18
+ poetry add pyrecracker
19
+ ```
20
+
21
+ ## Building
22
+ This project uses poetry for building and packaging. Pyrecracker can be built with the following command:
23
+
24
+ ```
25
+ poetry build
26
+ ```
27
+
28
+ This will generate the project's tar.gz and .whl files in the `./dist` directory.
29
+
30
+ ## Testing
31
+ Unit tests can be run with the following:
32
+
33
+ ```
34
+ poetry run pytest
35
+ ```
36
+
37
+ Examples on using pyrecracker can be found in the `./examples` directory.
@@ -0,0 +1,26 @@
1
+ [project]
2
+ name = "pyrecracker"
3
+ version = "0.5.1"
4
+ description = "Python module for creating and controlling micro VMs with Firecracker"
5
+ authors = [
6
+ {name = "Jake Jongewaard",email = "jjongewaard1@gmail.com"}
7
+ ]
8
+ readme = "README.md"
9
+ requires-python = ">=3.12"
10
+ dependencies = [
11
+ "requests-unixsocket (>=0.4.1,<0.5.0)"
12
+ ]
13
+ license = "MIT"
14
+
15
+ [tool.poetry]
16
+ packages = [{include = "pyrecracker", from = "src"}]
17
+
18
+ [build-system]
19
+ requires = ["poetry-core>=2.0.0,<3.0.0"]
20
+ build-backend = "poetry.core.masonry.api"
21
+
22
+ [dependency-groups]
23
+ dev = [
24
+ "pytest (>=9.0.2,<10.0.0)",
25
+ "pytest-mock (>=3.15.1,<4.0.0)"
26
+ ]
File without changes
@@ -0,0 +1,175 @@
1
+ import logging
2
+ from dataclasses import asdict
3
+ from typing import Any
4
+
5
+ import requests_unixsocket
6
+ from requests.exceptions import HTTPError
7
+
8
+ from pyrecracker.client_types import (
9
+ VM,
10
+ MachineConfiguration,
11
+ BootSource,
12
+ Drive,
13
+ InstanceActionInfo,
14
+ NetworkInterface,
15
+ SnapshotCreateParams,
16
+ SnapshotLoadParams
17
+ )
18
+
19
+
20
+ log = logging.getLogger(__name__)
21
+
22
+
23
+ class FirecrackerClient:
24
+ """
25
+ A client for interacting with the Firecracker HTTP API via a Unix socket.
26
+ """
27
+
28
+ def __init__(self, socket_path: str) -> None:
29
+ """
30
+ Initialize the FirecrackerClient with the path to the Unix socket.
31
+
32
+ Args:
33
+ socket_path (str): The path to the Firecracker API Unix socket.
34
+ """
35
+ self.__session = requests_unixsocket.Session()
36
+ self.__socket_path = socket_path
37
+ self.__socket_url = f"http+unix://{socket_path.replace('/', '%2F')}"
38
+
39
+ @property
40
+ def socket_path(self) -> str:
41
+ """
42
+ Get the socket path for the Firecracker API.
43
+ """
44
+ return self.__socket_path
45
+
46
+ def __put(self, endpoint: str, data: dict) -> None:
47
+ """
48
+ Helper method to send a PUT request to the Firecracker API.
49
+
50
+ Args:
51
+ endpoint (str): The API endpoint to which the request should be sent.
52
+ data (dict): The JSON data to be included in the PUT request.
53
+ Raises:
54
+ HTTPError: If the PUT request receives with an HTTP error response.
55
+ """
56
+ try:
57
+ response = self.__session.put(f"{self.__socket_url}/{endpoint}", json=data)
58
+ response.raise_for_status()
59
+ except HTTPError as err:
60
+ log.error(f"Error occurred while sending PUT request to {endpoint}: {err}")
61
+ raise
62
+
63
+ def __patch(self, endpoint: str, data: dict) -> None:
64
+ """
65
+ Helper method to send a PATCH request to the Firecracker API.
66
+
67
+ Args:
68
+ endpoint (str): The API endpoint to which the request should be sent.
69
+ data (dict): The JSON data to be included in the PATCH request.
70
+ Raises:
71
+ HTTPError: If the PATCH request receives with an HTTP error response.
72
+ """
73
+ try:
74
+ response = self.__session.patch(f"{self.__socket_url}/{endpoint}", json=data)
75
+ response.raise_for_status()
76
+ except HTTPError as err:
77
+ log.error(f"Error occurred while sending PATCH request to {endpoint}: {err}")
78
+ raise
79
+
80
+ def __body_to_dict(self, body: Any) -> dict:
81
+ """
82
+ Helper method to convert a dataclass instance to a dictionary.
83
+
84
+ Args:
85
+ body (Any): The dataclass instance to be converted.
86
+
87
+ Returns:
88
+ dict: The dictionary representation of the dataclass instance.
89
+ """
90
+ return asdict(
91
+ body,
92
+ dict_factory=lambda x: {k: v for (k, v) in x if v is not None}
93
+ )
94
+
95
+ def put_machine_config(self, machine_config: MachineConfiguration) -> None:
96
+ """
97
+ Configure the machine with the specified number of vCPUs and memory size.
98
+
99
+ Args:
100
+ vcpu_count (int): The number of virtual CPUs to allocate to the machine.
101
+ mem_size_mib (int): The amount of memory in MiB to allocate to the machine.
102
+ ht_enabled (bool): Whether to enable hyper-threading for the machine.
103
+ """
104
+ data = self.__body_to_dict(machine_config)
105
+ self.__put("machine-config", data)
106
+
107
+ def put_boot_source(self, boot_source: BootSource) -> None:
108
+ """
109
+ Configure the linux kernel boot source for the machine.
110
+
111
+ Args:
112
+ kernel_image_path (str): The path to the kernel image to be used as the boot source.
113
+ boot_args (str): The kernel command line arguments to be passed to the kernel on boot.
114
+ """
115
+ data = self.__body_to_dict(boot_source)
116
+ self.__put("boot-source", data)
117
+
118
+ def put_drives(self, drive: Drive) -> None:
119
+ """
120
+ Configure a root filesystem drive for the machine.
121
+
122
+ Args:
123
+ drive_id (str): The identifier inside firecracker for the drive to be configured.
124
+ path_on_host (str): The path on the host where the drive's backing file is located.
125
+ is_root_device (str): True if disk should be the root file drive else False
126
+ is_read_only (bool): Whether the drive should be configured as read-only.
127
+ """
128
+ data = self.__body_to_dict(drive)
129
+ self.__put(f"drives/{drive.drive_id}", data)
130
+
131
+ def put_network_interfaces(self, network_interface: NetworkInterface) -> None:
132
+ """
133
+ Configure a network interface for the machine.
134
+
135
+ Args:
136
+ network_interface (NetworkInterface): The network interface configuration.
137
+ """
138
+ data = self.__body_to_dict(network_interface)
139
+ self.__put(f"network-interfaces/{network_interface.iface_id}", data)
140
+
141
+ def put_actions(self, instance_action_info: InstanceActionInfo) -> None:
142
+ """
143
+ Send an action request to the Firecracker API.
144
+
145
+ Args:
146
+ action_type (str): The type of action to be performed. Valid values are "InstanceStart" and "InstanceStop".
147
+ """
148
+ self.__put("actions", self.__body_to_dict(instance_action_info))
149
+
150
+ def patch_vm(self, vm: VM) -> None:
151
+ """
152
+ Configure the VM with the specified configuration.
153
+
154
+ Args:
155
+ vm (VM): The VM configuration to be applied.
156
+ """
157
+ self.__patch("vm", self.__body_to_dict(vm))
158
+
159
+ def put_snapshot_create(self, snapshot_create_params: SnapshotCreateParams) -> None:
160
+ """
161
+ Create a snapshot of the VM.
162
+
163
+ Args:
164
+ snapshot_create_params (SnapshotCreateParams): The parameters for creating the snapshot.
165
+ """
166
+ self.__put("snapshot/create", self.__body_to_dict(snapshot_create_params))
167
+
168
+ def put_snapshot_load(self, snapshot_load_params: SnapshotLoadParams) -> None:
169
+ """
170
+ Load a snapshot of the VM.
171
+
172
+ Args:
173
+ snapshot_load_params (SnapshotLoadParams): The parameters for loading the snapshot.
174
+ """
175
+ self.__put("snapshot/load", self.__body_to_dict(snapshot_load_params))
@@ -0,0 +1,218 @@
1
+ from typing import Optional
2
+ from dataclasses import dataclass
3
+ from enum import StrEnum
4
+
5
+
6
+ @dataclass
7
+ class BootSource:
8
+ """
9
+ Represents the request body for the Firecracker BootSource API.
10
+ Maps 1-to-1 with the BootSource definition in the Firecracker Swagger spec.
11
+
12
+ Attributes:
13
+ kernel_image_path (str): Path to the kernel image on the host.
14
+ boot_args (Optional[str]): Kernel boot arguments.
15
+ initrd_path (Optional[str]): Path to the initrd image on the host.
16
+ """
17
+ kernel_image_path: str
18
+ boot_args: Optional[str] = None
19
+ initrd_path: Optional[str] = None
20
+
21
+
22
+ class HugePages(StrEnum):
23
+ NONE = "None"
24
+ TWO_MIB = "2M"
25
+
26
+
27
+ @dataclass
28
+ class MachineConfiguration:
29
+ """
30
+ Represents the request body for the Firecracker Machine Configuration API.
31
+ Maps 1-to-1 with the MachineConfiguration definition in the Firecracker Swagger spec.
32
+
33
+ Attributes:
34
+ mem_size_mib (int): Memory size in MiB.
35
+ vcpu_count (int): Number of vCPUs (1-32).
36
+ smt (Optional[bool]): Simultaneous multithreading enabled.
37
+ track_dirty_pages (Optional[bool]): Track dirty pages for live migration.
38
+ huge_pages (Optional[HugePages]): Use huge pages.
39
+ """
40
+ mem_size_mib: int
41
+ vcpu_count: int
42
+ #cpu_template: Optional[CPUTemplate] = None will have to be a ref to another dataclass
43
+ smt: Optional[bool] = None
44
+ track_dirty_pages: Optional[bool] = None
45
+ huge_pages: Optional[HugePages] = None
46
+
47
+ def __post__init__(self):
48
+ if self.vcpu_count < 1 or self.vcpu_count > 32:
49
+ raise ValueError("vcpu_count must be between 1 and 32")
50
+ if self.huge_pages is not None:
51
+ if self.huge_pages not in [HugePages.NONE, HugePages.TWO_MIB]:
52
+ raise ValueError(
53
+ "MachineConfiguration.huge_pages must be either"
54
+ f" '{HugePages.NONE}' or '{HugePages.TWO_MIB}'"
55
+ )
56
+
57
+
58
+ class CacheType(StrEnum):
59
+ UNSAFE = "Unsafe"
60
+ WRITEBACK = "Writeback"
61
+
62
+
63
+ class IOEngine(StrEnum):
64
+ SYNC = "Sync"
65
+ ASYNC = "Async"
66
+
67
+
68
+ @dataclass
69
+ class Drive:
70
+ """
71
+ Represents the request body for the Firecracker Drive API.
72
+ Maps 1-to-1 with the Drive definition in the Firecracker Swagger spec.
73
+
74
+ Attributes:
75
+ drive_id (str): Unique identifier for the drive.
76
+ is_root_device (bool): Whether this drive is the root device.
77
+ partuuid (Optional[str]): Partition UUID.
78
+ cache_type (Optional[CacheType]): Cache type.
79
+ is_read_only (Optional[bool]): If the drive is read-only.
80
+ path_on_host (Optional[str]): Path to the drive on the host.
81
+ io_engine (Optional[IOEngine]): IO engine.
82
+ socket (Optional[str]): Path to the drive's socket.
83
+ """
84
+ drive_id: str
85
+ is_root_device: bool
86
+ partuuid: Optional[str] = None
87
+ cache_type: Optional[CacheType] = None
88
+ is_read_only: Optional[bool] = None
89
+ path_on_host: Optional[str] = None
90
+ #rate_limiter: Optional[RateLimiter] = None This needs to be a ref to another dataclass
91
+ io_engine: Optional[IOEngine] = None
92
+ socket: Optional[str] = None
93
+
94
+ def __post__init__(self):
95
+ if self.cache_type is not None:
96
+ if self.cache_type not in [CacheType.UNSAFE, CacheType.WRITEBACK]:
97
+ raise ValueError(
98
+ f"DriveBody.cache_type must be either '{CacheType.UNSAFE}' or '{CacheType.WRITEBACK}'"
99
+ )
100
+ if self.io_engine is not None:
101
+ if self.io_engine not in [IOEngine.SYNC, IOEngine.ASYNC]:
102
+ raise ValueError(
103
+ f"DriveBody.io_engine must be either '{IOEngine.SYNC}' or '{IOEngine.ASYNC}'"
104
+ )
105
+
106
+
107
+ class ActionType(StrEnum):
108
+ FLUSH_METRICS = "FlushMetrics"
109
+ INSTANCE_START = "InstanceStart"
110
+ SEND_CTRL_ALT_DEL = "SendCtrlAltDel"
111
+
112
+
113
+ @dataclass
114
+ class InstanceActionInfo:
115
+ """
116
+ Represents the request body for the Firecracker Instance Action API.
117
+ Maps 1-to-1 with the InstanceActionInfo definition in the Firecracker Swagger spec.
118
+
119
+ Attributes:
120
+ action_type (ActionType): Action type to execute.
121
+ """
122
+ action_type: ActionType
123
+
124
+ def __post__init__(self):
125
+ if self.action_type not in [
126
+ ActionType.FLUSH_METRICS,
127
+ ActionType.INSTANCE_START,
128
+ ActionType.SEND_CTRL_ALT_DEL
129
+ ]:
130
+ raise ValueError(
131
+ f"InstanceActionInfo.action_type must be one of '{ActionType.FLUSH_METRICS}', "
132
+ f"'{ActionType.INSTANCE_START}', or '{ActionType.SEND_CTRL_ALT_DEL}'"
133
+ )
134
+
135
+
136
+ @dataclass
137
+ class NetworkInterface:
138
+ """
139
+ Represents the request body for the Firecracker Network Interface API.
140
+
141
+ Attributes:
142
+ host_dev_name (str): Host level path for the guest network interface (e.g. tap0).
143
+ iface_id (str): Unique identifier for the network interface within Firecracker.
144
+ guest_mac (Optional[str]): MAC address to be assigned to the guest network interface.
145
+ """
146
+ host_dev_name: str
147
+ iface_id: str
148
+ guest_mac: Optional[str] = None
149
+ #rx_rate_limiter: Optional[RateLimiter] = None This needs to be a ref
150
+ #tx_rate_limiter: Optional[RateLimiter] = None This needs to be a ref
151
+
152
+
153
+ class VMState(StrEnum):
154
+ PAUSED = "Paused"
155
+ RESUMED = "Resumed"
156
+
157
+
158
+ @dataclass
159
+ class VM:
160
+ """
161
+ Represents the state of a Firecracker microVM.
162
+
163
+ Attributes:
164
+ state (VMState): The current state of the microVM
165
+ """
166
+ state: VMState
167
+
168
+ def __post__init__(self):
169
+ if self.state not in [VMState.PAUSED, VMState.RESUMED]:
170
+ raise ValueError(
171
+ f"VM.state must be one of '{VMState.PAUSED}' or '{VMState.RESUMED}'"
172
+ )
173
+
174
+
175
+ class SnapshotType(StrEnum):
176
+ FULL = "Full"
177
+ DIFF = "Diff"
178
+
179
+
180
+ @dataclass
181
+ class SnapshotCreateParams:
182
+ """
183
+ Represents the request body for the Firecracker Create Snapshot API.
184
+
185
+ Attributes:
186
+ mem_file_path (str): The path on the host where the memory file will be stored.
187
+ snapshot_path (str): The path on the host where the snapshot will be stored.
188
+ snapshot_type (Optional[SnapshotType]): The type of snapshot to create ('Full' or 'Diff').
189
+ """
190
+ snapshot_path: str
191
+ mem_file_path: str
192
+ snapshot_type: Optional[SnapshotType] = None
193
+
194
+ def __post__init__(self):
195
+ if self.snapshot_type not in [SnapshotType.FULL, SnapshotType.DIFF]:
196
+ raise ValueError(
197
+ f"SnapshotCreateParams.snapshot_type must be either '{SnapshotType.FULL}' or '{SnapshotType.DIFF}'"
198
+ )
199
+
200
+
201
+ @dataclass
202
+ class SnapshotLoadParams:
203
+ """
204
+ Represents the request body for the Firecracker Load Snapshot API.
205
+
206
+ Attributes:
207
+ snapshot_path (str): Path to the file that contains the microVM state to be loaded.
208
+ track_dirty_pages (Optional[bool]): Whether to track dirty pages after loading the snapshot.
209
+ mem_file_path (Optional[str]): The path on the host that contains the guest memory to be loaded.
210
+ resume_vm (Optional[bool]): Whether to resume the VM immediately after loading the snapshot.
211
+ """
212
+ snapshot_path: str
213
+ track_dirty_pages: Optional[bool] = None
214
+ mem_file_path: Optional[str] = None
215
+ # mem_backend: Optional[MemoryBackend] = None This needs to be a ref to another dataclass
216
+ resume_vm: Optional[bool] = None
217
+ # network_overrides: Optional[List[NetworkOverride]] = None This needs to be a ref to a list of dataclasses
218
+ # vsock_override: Optional[VsockOverride] = None This needs to be a ref to another dataclass
@@ -0,0 +1,132 @@
1
+ import subprocess
2
+ from functools import singledispatchmethod
3
+ from typing import Optional, Self
4
+ from pathlib import Path
5
+
6
+
7
+ class CommandError(Exception):
8
+ """Custom exception for command execution errors."""
9
+ pass
10
+
11
+
12
+ class Command:
13
+ """
14
+ A class to build and execute shell commands. This class supports
15
+ adding arguments by chaining calls to `add_arg` or `add_args`.
16
+
17
+ Attributes:
18
+ __name (str): The base command to execute.
19
+ __command_list (list[str]): The list of command components to be executed.
20
+ """
21
+ def __init__(self, name: str, sudo: bool = False) -> None:
22
+ self.__name: str = name
23
+ self.__command_list: list[str] = ["sudo", self.__name] if sudo else [self.__name]
24
+
25
+ def __str__(self) -> str:
26
+ """
27
+ Returns the full command as a string.
28
+
29
+ Returns:
30
+ str: The full command string.
31
+ """
32
+ return " ".join(self.__command_list)
33
+
34
+ def add_arg(self, arg: str) -> Self:
35
+ """
36
+ Adds a single argument to the command's command list. This method
37
+ can be chained to add multiple arguments.
38
+
39
+ Returns:
40
+ Command: The Command instance with the added argument.
41
+ """
42
+ self.__command_list.append(arg)
43
+ return self
44
+
45
+ @singledispatchmethod
46
+ def add_args(self, args) -> Self:
47
+ """
48
+ Adds multiple arguments to the command's command list. This method
49
+ can be chained to add multiple arguments.
50
+ """
51
+ raise NotImplementedError("Unsupported type for add_args")
52
+
53
+ @add_args.register
54
+ def _(self, args: list) -> Self:
55
+ """
56
+ Adds a list of arguments to the command's command list. This method
57
+ can be chained to add multiple arguments.
58
+
59
+ Args:
60
+ args (list[str]): A list of arguments to add to the command.
61
+ Returns:
62
+ Command: The Command instance with the added arguments.
63
+ """
64
+ self.__command_list.extend(args)
65
+ return self
66
+
67
+ @add_args.register
68
+ def _(self, args: str) -> Self:
69
+ """
70
+ Adds a string of arguments to the command's command list. The string
71
+ is split into individual arguments based on whitespace. This method can
72
+ be chained to add multiple arguments.
73
+
74
+ Args:
75
+ args (str): A string of arguments to add to the command.
76
+ Returns:
77
+ Command: The Command instance with the added arguments.
78
+ """
79
+ args_list = args.split()
80
+ self.__command_list.extend(args_list)
81
+ return self
82
+
83
+ def run(self) -> None:
84
+ """
85
+ Executes the command using subprocess.run.
86
+
87
+ Raises:
88
+ RuntimeError: If the command execution fails, a RuntimeError is raised with the command error code.
89
+ """
90
+ try:
91
+ result = subprocess.run(self.__command_list, check=True)
92
+ if result.returncode != 0:
93
+ error_message = f"Command '{str(self)}' failed with exit code {result.returncode} and output: {result.stdout}"
94
+ raise CommandError(error_message)
95
+ except subprocess.CalledProcessError as e:
96
+ error_message = f"Command '{str(self)}' failed with exit code {e.returncode}"
97
+ raise CommandError(error_message) from e
98
+
99
+ def popen(self, log_file_path: Optional[str] = None) -> subprocess.Popen:
100
+ """
101
+ Executes the command by spawning a background process using subprocess.Popen.
102
+ Returns the Popen object to allow the caller to maintain a reference to the process.
103
+
104
+ Returns:
105
+ subprocess.Popen: The Popen object representing the spawned process.
106
+ log_file_path (str): The path to the log file where the command's
107
+ output will be written.
108
+
109
+ Raises:
110
+ RuntimeError: If the command execution fails, a RuntimeError is raised with the command error code.
111
+ """
112
+ try:
113
+ if log_file_path is None:
114
+ process = subprocess.Popen(
115
+ self.__command_list,
116
+ stdin=subprocess.DEVNULL,
117
+ stdout=subprocess.DEVNULL
118
+ )
119
+ return process
120
+ path = Path(log_file_path)
121
+ path.parent.mkdir(parents=True, exist_ok=True)
122
+ with open(path, "w") as log_file:
123
+ process = subprocess.Popen(
124
+ self.__command_list,
125
+ stdin=subprocess.DEVNULL,
126
+ stdout=log_file,
127
+ stderr=log_file
128
+ )
129
+ return process
130
+ except subprocess.CalledProcessError as e:
131
+ error_message = f"Command '{str(self)}' failed with exit code {e.returncode}"
132
+ raise CommandError(error_message) from e