hysn-firecracker-python 1.0.3.post0__py3-none-any.whl
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.
- firecracker/__init__.py +23 -0
- firecracker/_version.py +34 -0
- firecracker/api.py +183 -0
- firecracker/config.py +30 -0
- firecracker/exceptions.py +33 -0
- firecracker/logger.py +98 -0
- firecracker/microvm.py +1738 -0
- firecracker/network.py +1230 -0
- firecracker/process.py +438 -0
- firecracker/scripts.py +53 -0
- firecracker/utils.py +192 -0
- firecracker/vmm.py +508 -0
- hysn_firecracker_python-1.0.3.post0.dist-info/METADATA +246 -0
- hysn_firecracker_python-1.0.3.post0.dist-info/RECORD +18 -0
- hysn_firecracker_python-1.0.3.post0.dist-info/WHEEL +5 -0
- hysn_firecracker_python-1.0.3.post0.dist-info/entry_points.txt +2 -0
- hysn_firecracker_python-1.0.3.post0.dist-info/licenses/LICENSE +21 -0
- hysn_firecracker_python-1.0.3.post0.dist-info/top_level.txt +1 -0
firecracker/microvm.py
ADDED
|
@@ -0,0 +1,1738 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import tty
|
|
4
|
+
import time
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import select
|
|
8
|
+
import termios
|
|
9
|
+
import docker
|
|
10
|
+
import tarfile
|
|
11
|
+
import tempfile
|
|
12
|
+
from http import HTTPStatus
|
|
13
|
+
from typing import List, Dict
|
|
14
|
+
from firecracker.config import MicroVMConfig
|
|
15
|
+
from firecracker.api import Api
|
|
16
|
+
from firecracker.logger import Logger
|
|
17
|
+
from firecracker.network import NetworkManager
|
|
18
|
+
from firecracker.process import ProcessManager
|
|
19
|
+
from firecracker.vmm import VMMManager
|
|
20
|
+
from firecracker.utils import (
|
|
21
|
+
run,
|
|
22
|
+
get_public_ip,
|
|
23
|
+
validate_ip_address,
|
|
24
|
+
generate_id,
|
|
25
|
+
generate_name,
|
|
26
|
+
generate_mac_address,
|
|
27
|
+
)
|
|
28
|
+
from firecracker.exceptions import VMMError, ConfigurationError
|
|
29
|
+
from paramiko import SSHClient, AutoAddPolicy, SSHException
|
|
30
|
+
from tenacity import retry, stop_after_attempt, wait_fixed, retry_if_exception_type
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class MicroVM:
|
|
34
|
+
"""A class to manage Firecracker microVMs.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
id (str, optional): ID for the MicroVM
|
|
38
|
+
name (str, optional): Name for the MicroVM
|
|
39
|
+
kernel_file (str, optional): Path to the kernel file
|
|
40
|
+
kernel_url (str, optional): URL to the kernel file
|
|
41
|
+
initrd_file (str, optional): Path to the initrd file
|
|
42
|
+
init_file (str, optional): Path to the init file
|
|
43
|
+
image (str, optional): Docker image to use for the MicroVM
|
|
44
|
+
base_rootfs (str, optional): Path to the base rootfs file
|
|
45
|
+
rootfs_size (str, optional): Size of the rootfs file
|
|
46
|
+
overlayfs (bool, optional): Whether to use overlayfs
|
|
47
|
+
overlayfs_file (str, optional): Path to the overlayfs file
|
|
48
|
+
vcpu (int, optional): Number of vCPUs
|
|
49
|
+
memory (int, optional): Amount of memory
|
|
50
|
+
ip_addr (str, optional): IP address for the MicroVM
|
|
51
|
+
mmds_enabled (bool, optional): Whether to enable mmds
|
|
52
|
+
mmds_ip (str, optional): IP address for the mmds
|
|
53
|
+
user_data (str, optional): User data for the MicroVM
|
|
54
|
+
user_data_file (str, optional): Path to the user data file
|
|
55
|
+
labels (dict, optional): Labels for the MicroVM
|
|
56
|
+
expose_ports (bool, optional): Whether to expose ports
|
|
57
|
+
host_port (int, optional): Host port to expose
|
|
58
|
+
dest_port (int, optional): Destination port to expose
|
|
59
|
+
verbose (bool, optional): Whether to enable verbose logging
|
|
60
|
+
level (str, optional): Logging level
|
|
61
|
+
|
|
62
|
+
Raises:
|
|
63
|
+
ValueError: If the configuration is invalid
|
|
64
|
+
VMMError: If the VMM creation fails
|
|
65
|
+
SSHException: If the SSH connection fails
|
|
66
|
+
ConfigurationError: If the configuration is invalid
|
|
67
|
+
ProcessError: If the process fails
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
name: str = None,
|
|
73
|
+
kernel_file: str = None,
|
|
74
|
+
kernel_url: str = None,
|
|
75
|
+
initrd_file: str = None,
|
|
76
|
+
init_file: str = None,
|
|
77
|
+
image: str = None,
|
|
78
|
+
base_rootfs: str = None,
|
|
79
|
+
rootfs_size: str = None,
|
|
80
|
+
overlayfs: bool = False,
|
|
81
|
+
overlayfs_file: str = None,
|
|
82
|
+
vcpu: int = None,
|
|
83
|
+
memory: int = None,
|
|
84
|
+
ip_addr: str = None,
|
|
85
|
+
mmds_enabled: bool = None,
|
|
86
|
+
mmds_ip: str = None,
|
|
87
|
+
user_data: str = None,
|
|
88
|
+
user_data_file: str = None,
|
|
89
|
+
labels: dict = None,
|
|
90
|
+
expose_ports: bool = False,
|
|
91
|
+
host_port: int = None,
|
|
92
|
+
dest_port: int = None,
|
|
93
|
+
vsock_enabled: bool = False,
|
|
94
|
+
vsock_guest_cid: int = None,
|
|
95
|
+
verbose: bool = False,
|
|
96
|
+
level: str = "INFO",
|
|
97
|
+
) -> None:
|
|
98
|
+
self._microvm_id = generate_id()
|
|
99
|
+
self._microvm_name = generate_name() if name is None else name
|
|
100
|
+
|
|
101
|
+
self._config = MicroVMConfig()
|
|
102
|
+
self._config.verbose = verbose
|
|
103
|
+
self._logger = Logger(level=level, verbose=verbose)
|
|
104
|
+
self._logger.set_level(level)
|
|
105
|
+
|
|
106
|
+
self._network = NetworkManager(verbose=verbose, level=level)
|
|
107
|
+
self._process = ProcessManager(verbose=verbose, level=level)
|
|
108
|
+
self._vmm = VMMManager(verbose=verbose, level=level)
|
|
109
|
+
|
|
110
|
+
if vcpu is not None:
|
|
111
|
+
if not isinstance(vcpu, int) or vcpu <= 0:
|
|
112
|
+
raise ValueError("vcpu must be a positive integer (greater than zero)")
|
|
113
|
+
self._vcpu = vcpu
|
|
114
|
+
else:
|
|
115
|
+
self._vcpu = self._config.vcpu
|
|
116
|
+
|
|
117
|
+
self._memory = int(MicroVM._convert_memory_size(memory or self._config.memory))
|
|
118
|
+
self._mmds_enabled = (
|
|
119
|
+
mmds_enabled if mmds_enabled is not None else self._config.mmds_enabled
|
|
120
|
+
)
|
|
121
|
+
self._mmds_ip = mmds_ip or self._config.mmds_ip
|
|
122
|
+
|
|
123
|
+
if user_data_file and user_data:
|
|
124
|
+
raise ValueError(
|
|
125
|
+
"Cannot specify both user_data and user_data_file. Use only one of them."
|
|
126
|
+
)
|
|
127
|
+
if user_data_file:
|
|
128
|
+
if not os.path.exists(user_data_file):
|
|
129
|
+
raise ValueError(f"User data file not found: {user_data_file}")
|
|
130
|
+
with open(user_data_file, "r") as f:
|
|
131
|
+
self._user_data = f.read()
|
|
132
|
+
else:
|
|
133
|
+
self._user_data = user_data
|
|
134
|
+
|
|
135
|
+
self._labels = labels or {}
|
|
136
|
+
|
|
137
|
+
self._iface_name = self._network.get_interface_name()
|
|
138
|
+
self._host_dev_name = f"tap_{self._microvm_id}"
|
|
139
|
+
self._mac_addr = generate_mac_address()
|
|
140
|
+
if ip_addr:
|
|
141
|
+
validate_ip_address(ip_addr)
|
|
142
|
+
self._network.detect_cidr_conflict(ip_addr, 24)
|
|
143
|
+
self._ip_addr = ip_addr
|
|
144
|
+
else:
|
|
145
|
+
self._ip_addr = self._config.ip_addr
|
|
146
|
+
self._gateway_ip = self._network.get_gateway_ip(self._ip_addr)
|
|
147
|
+
|
|
148
|
+
self._socket_file = (
|
|
149
|
+
f"{self._config.data_path}/{self._microvm_id}/firecracker.socket"
|
|
150
|
+
)
|
|
151
|
+
self._vmm_dir = f"{self._config.data_path}/{self._microvm_id}"
|
|
152
|
+
self._log_dir = f"{self._vmm_dir}/logs"
|
|
153
|
+
self._rootfs_dir = f"{self._vmm_dir}/rootfs"
|
|
154
|
+
|
|
155
|
+
self._docker = docker.from_env()
|
|
156
|
+
self._docker_image = image
|
|
157
|
+
|
|
158
|
+
if image:
|
|
159
|
+
if not base_rootfs:
|
|
160
|
+
raise ValueError("base_rootfs is required when image is provided")
|
|
161
|
+
if not self._is_valid_docker_image(image):
|
|
162
|
+
raise ValueError(f"Invalid Docker image: {image}")
|
|
163
|
+
self._download_docker(image)
|
|
164
|
+
|
|
165
|
+
if kernel_url and kernel_file:
|
|
166
|
+
self._kernel_file = kernel_file
|
|
167
|
+
self._download_kernel(kernel_url, self._kernel_file)
|
|
168
|
+
elif kernel_file:
|
|
169
|
+
self._kernel_file = kernel_file
|
|
170
|
+
elif kernel_url:
|
|
171
|
+
self._kernel_file = None
|
|
172
|
+
elif image:
|
|
173
|
+
self._kernel_file = None
|
|
174
|
+
else:
|
|
175
|
+
self._kernel_file = None
|
|
176
|
+
|
|
177
|
+
if initrd_file:
|
|
178
|
+
if not os.path.exists(initrd_file):
|
|
179
|
+
raise FileNotFoundError(f"Initrd file not found: {initrd_file}")
|
|
180
|
+
self._initrd_file = initrd_file
|
|
181
|
+
else:
|
|
182
|
+
self._initrd_file = None
|
|
183
|
+
|
|
184
|
+
self._init_file = init_file or self._config.init_file
|
|
185
|
+
|
|
186
|
+
if base_rootfs:
|
|
187
|
+
self._base_rootfs = base_rootfs
|
|
188
|
+
base_rootfs_name = os.path.basename(self._base_rootfs.replace("./", ""))
|
|
189
|
+
self._rootfs_file = os.path.join(self._rootfs_dir, base_rootfs_name)
|
|
190
|
+
|
|
191
|
+
self._rootfs_size = rootfs_size or self._config.rootfs_size
|
|
192
|
+
self._overlayfs = overlayfs or self._config.overlayfs
|
|
193
|
+
if self._overlayfs:
|
|
194
|
+
self._overlayfs_file = overlayfs_file or os.path.join(
|
|
195
|
+
self._rootfs_dir, "overlayfs.ext4"
|
|
196
|
+
)
|
|
197
|
+
self._overlayfs_name = os.path.basename(
|
|
198
|
+
self._overlayfs_file.replace("./", "")
|
|
199
|
+
)
|
|
200
|
+
self._overlayfs_dir = os.path.join(self._rootfs_dir, self._overlayfs_name)
|
|
201
|
+
|
|
202
|
+
self._mem_file_path = f"{self._config.snapshot_path}/{self._microvm_id}/memory"
|
|
203
|
+
self._snapshot_path = (
|
|
204
|
+
f"{self._config.snapshot_path}/{self._microvm_id}/snapshot"
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
self._ssh_client = SSHClient()
|
|
208
|
+
self._expose_ports = expose_ports
|
|
209
|
+
self._host_ip = "0.0.0.0"
|
|
210
|
+
self._host_port = MicroVM._parse_ports(host_port)
|
|
211
|
+
self._dest_port = MicroVM._parse_ports(dest_port)
|
|
212
|
+
|
|
213
|
+
self._vsock_enabled = vsock_enabled or self._config.vsock_enabled
|
|
214
|
+
self._vsock_guest_cid = vsock_guest_cid or self._config.vsock_guest_cid
|
|
215
|
+
self._vsock_uds_path = f"{self._config.data_path}/{self._microvm_id}/v.sock"
|
|
216
|
+
|
|
217
|
+
self._api = self._vmm.get_api(self._microvm_id)
|
|
218
|
+
|
|
219
|
+
@staticmethod
|
|
220
|
+
def list() -> List[Dict]:
|
|
221
|
+
"""List all running Firecracker VMs.
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
List[Dict]: List of dictionaries containing VMM details
|
|
225
|
+
"""
|
|
226
|
+
vmm_manager = VMMManager()
|
|
227
|
+
return vmm_manager.list_vmm()
|
|
228
|
+
|
|
229
|
+
def find(self, state=None, labels=None):
|
|
230
|
+
"""Find a VMM by ID or labels.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
state (str, optional): State of the VMM to find.
|
|
234
|
+
labels (dict, optional): Labels to filter VMMs by.
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
str: ID of the found VMM or error message.
|
|
238
|
+
"""
|
|
239
|
+
if state:
|
|
240
|
+
return self._vmm.find_vmm_by_labels(state, labels)
|
|
241
|
+
else:
|
|
242
|
+
return "No state provided"
|
|
243
|
+
|
|
244
|
+
def config(self, id=None):
|
|
245
|
+
"""Get the configuration for the current VMM or a specific VMM.
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
id (str, optional): ID of the VMM to query. If not provided,
|
|
249
|
+
uses the current VMM's ID.
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
dict: Response from the VMM configuration endpoint or error message.
|
|
253
|
+
"""
|
|
254
|
+
id = id if id else self._microvm_id
|
|
255
|
+
if not id:
|
|
256
|
+
return "No VMM ID specified for checking configuration"
|
|
257
|
+
return self._vmm.get_vmm_config(id)
|
|
258
|
+
|
|
259
|
+
def inspect(self, id=None):
|
|
260
|
+
"""Inspect a VMM by ID.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
id (str, optional): ID of the VMM to inspect. If not provided,
|
|
264
|
+
uses the current VMM's ID.
|
|
265
|
+
"""
|
|
266
|
+
id = id if id else self._microvm_id
|
|
267
|
+
|
|
268
|
+
if not id:
|
|
269
|
+
return f"VMM with ID {id} does not exist"
|
|
270
|
+
|
|
271
|
+
config_file = f"{self._config.data_path}/{id}/config.json"
|
|
272
|
+
if not os.path.exists(config_file):
|
|
273
|
+
return "VMM ID not exist"
|
|
274
|
+
|
|
275
|
+
try:
|
|
276
|
+
with open(config_file, "r") as f:
|
|
277
|
+
config = json.load(f)
|
|
278
|
+
return config
|
|
279
|
+
except Exception as e:
|
|
280
|
+
raise VMMError(f"Failed to inspect VMM {id}: {str(e)}")
|
|
281
|
+
|
|
282
|
+
def status(self, id=None):
|
|
283
|
+
"""Get the status of the current VMM or a specific VMM.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
id (str, optional): ID of the VMM to check. If not provided,
|
|
287
|
+
uses the current VMM's ID.
|
|
288
|
+
"""
|
|
289
|
+
id = id if id else self._microvm_id
|
|
290
|
+
if not id:
|
|
291
|
+
return "No VMM ID specified for checking status"
|
|
292
|
+
|
|
293
|
+
try:
|
|
294
|
+
with open(f"{self._config.data_path}/{id}/config.json", "r") as f:
|
|
295
|
+
config = json.load(f)
|
|
296
|
+
if config["State"]["Running"]:
|
|
297
|
+
return f"VMM {id} is running"
|
|
298
|
+
elif config["State"]["Paused"]:
|
|
299
|
+
return f"VMM {id} is paused"
|
|
300
|
+
|
|
301
|
+
except Exception as e:
|
|
302
|
+
raise VMMError(f"Failed to get status for VMM {id}: {str(e)}")
|
|
303
|
+
|
|
304
|
+
def build(self):
|
|
305
|
+
"""Build the rootfs from the Docker image.
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
str: Status message indicating the result of the build operation.
|
|
309
|
+
"""
|
|
310
|
+
try:
|
|
311
|
+
if not self._docker_image:
|
|
312
|
+
return "No Docker image specified for building rootfs"
|
|
313
|
+
|
|
314
|
+
self._build_rootfs(self._docker_image, self._base_rootfs, self._rootfs_size)
|
|
315
|
+
|
|
316
|
+
return f"Rootfs built at {self._base_rootfs}"
|
|
317
|
+
|
|
318
|
+
except Exception as e:
|
|
319
|
+
raise VMMError(f"Failed to build rootfs from Docker image: {str(e)}")
|
|
320
|
+
|
|
321
|
+
def create(
|
|
322
|
+
self,
|
|
323
|
+
snapshot: bool = False,
|
|
324
|
+
memory_path: str = None,
|
|
325
|
+
snapshot_path: str = None,
|
|
326
|
+
rootfs_path: str = None,
|
|
327
|
+
) -> dict:
|
|
328
|
+
"""Create a new microVM.
|
|
329
|
+
|
|
330
|
+
Args:
|
|
331
|
+
snapshot (bool, optional): Whether to create a snapshot of the microVM.
|
|
332
|
+
memory_path (str, optional): Path to the memory file.
|
|
333
|
+
snapshot_path (str, optional): Path to the snapshot file.
|
|
334
|
+
rootfs_path (str, optional): Path to the rootfs file. Used when loading from snapshot to override the
|
|
335
|
+
rootfs path saved in the snapshot metadata. If not provided, will use the default rootfs path.
|
|
336
|
+
|
|
337
|
+
Returns:
|
|
338
|
+
dict: Status message indicating the result of the create operation.
|
|
339
|
+
"""
|
|
340
|
+
vmm_dir = f"{self._config.data_path}/{self._microvm_id}"
|
|
341
|
+
if os.path.exists(vmm_dir):
|
|
342
|
+
return f"VMM with ID {self._microvm_id} already exists"
|
|
343
|
+
|
|
344
|
+
try:
|
|
345
|
+
if self._kernel_file is None:
|
|
346
|
+
raise ValueError(
|
|
347
|
+
"kernel_file is required when no kernel_url or image is provided"
|
|
348
|
+
)
|
|
349
|
+
if self._base_rootfs is None:
|
|
350
|
+
raise ValueError(
|
|
351
|
+
"base_rootfs is required when no kernel_url or image is provided"
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
for file_path, name in [
|
|
355
|
+
(self._kernel_file, "kernel file"),
|
|
356
|
+
(self._base_rootfs, "base rootfs"),
|
|
357
|
+
]:
|
|
358
|
+
if not os.path.exists(file_path):
|
|
359
|
+
raise FileNotFoundError(
|
|
360
|
+
f"{name.capitalize()} not found: {file_path}"
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
if self._vmm.check_network_overlap(self._ip_addr):
|
|
364
|
+
return f"IP address {self._ip_addr} is already in use"
|
|
365
|
+
|
|
366
|
+
if self._docker_image:
|
|
367
|
+
if not os.path.exists(self._base_rootfs):
|
|
368
|
+
if self._config.verbose:
|
|
369
|
+
self._logger.info(
|
|
370
|
+
f"Building rootfs from Docker image: {self._docker_image}"
|
|
371
|
+
)
|
|
372
|
+
self._build_rootfs(
|
|
373
|
+
self._docker_image, self._base_rootfs, self._rootfs_size
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
self._network.setup(
|
|
377
|
+
tap_name=self._host_dev_name,
|
|
378
|
+
iface_name=self._iface_name,
|
|
379
|
+
gateway_ip=self._gateway_ip,
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
self._run_firecracker()
|
|
383
|
+
if snapshot:
|
|
384
|
+
if not memory_path or not snapshot_path:
|
|
385
|
+
raise ValueError(
|
|
386
|
+
"memory_path and snapshot_path are required when snapshot is True"
|
|
387
|
+
)
|
|
388
|
+
self.snapshot(
|
|
389
|
+
id=self._microvm_id,
|
|
390
|
+
action="load",
|
|
391
|
+
memory_path=memory_path,
|
|
392
|
+
snapshot_path=snapshot_path,
|
|
393
|
+
rootfs_path=rootfs_path,
|
|
394
|
+
)
|
|
395
|
+
# Note: load_snapshot with resume_vm=True already starts the VM
|
|
396
|
+
# No need to call InstanceStart again
|
|
397
|
+
if self._config.verbose:
|
|
398
|
+
self._logger.info(f"VMM {self._microvm_id} started from snapshot")
|
|
399
|
+
else:
|
|
400
|
+
self._configure_vmm_boot_source()
|
|
401
|
+
self._configure_vmm_root_drive()
|
|
402
|
+
self._configure_vmm_resources()
|
|
403
|
+
self._configure_vmm_network()
|
|
404
|
+
if self._mmds_enabled:
|
|
405
|
+
self._configure_vmm_mmds()
|
|
406
|
+
if self._vsock_enabled:
|
|
407
|
+
self._configure_vmm_vsock()
|
|
408
|
+
|
|
409
|
+
# Start the VM (only for non-snapshot boot)
|
|
410
|
+
self._api.actions.put(action_type="InstanceStart")
|
|
411
|
+
if self._config.verbose:
|
|
412
|
+
self._logger.info(f"VMM {self._microvm_id} started")
|
|
413
|
+
|
|
414
|
+
if self._expose_ports:
|
|
415
|
+
if not self._host_port or not self._dest_port:
|
|
416
|
+
raise ValueError(
|
|
417
|
+
"Port forwarding requested but no ports specified. Both host_port and dest_port must be set."
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
ports = self._setup_port_forwarding(
|
|
421
|
+
self._host_port, self._dest_port, update_config=False
|
|
422
|
+
)
|
|
423
|
+
else:
|
|
424
|
+
ports = {}
|
|
425
|
+
|
|
426
|
+
pid, create_time = self._process.get_pid(self._microvm_id)
|
|
427
|
+
|
|
428
|
+
if self._process.is_running(self._microvm_id):
|
|
429
|
+
self._vmm.create_vmm_json_file(
|
|
430
|
+
id=self._microvm_id,
|
|
431
|
+
Name=self._microvm_name,
|
|
432
|
+
CreatedAt=create_time,
|
|
433
|
+
Rootfs=self._rootfs_file,
|
|
434
|
+
Kernel=self._kernel_file,
|
|
435
|
+
Pid=pid,
|
|
436
|
+
Ports=ports,
|
|
437
|
+
IPAddress=self._ip_addr,
|
|
438
|
+
Labels=self._labels,
|
|
439
|
+
)
|
|
440
|
+
return f"VMM {self._microvm_id} created"
|
|
441
|
+
else:
|
|
442
|
+
self._vmm.delete_vmm(self._microvm_id)
|
|
443
|
+
return f"VMM {self._microvm_id} failed to create"
|
|
444
|
+
|
|
445
|
+
except Exception as e:
|
|
446
|
+
raise VMMError(f"Failed to create VMM {self._microvm_id}: {str(e)}")
|
|
447
|
+
|
|
448
|
+
finally:
|
|
449
|
+
self._api.close()
|
|
450
|
+
|
|
451
|
+
def pause(self, id=None):
|
|
452
|
+
"""Pause the configured microVM.
|
|
453
|
+
|
|
454
|
+
Args:
|
|
455
|
+
id (str, optional): ID of the VMM to pause. If not provided,
|
|
456
|
+
uses the current VMM's ID.
|
|
457
|
+
|
|
458
|
+
Returns:
|
|
459
|
+
str: Status message indicating the result of the pause operation.
|
|
460
|
+
|
|
461
|
+
Raises:
|
|
462
|
+
FirecrackerError: If the pause operation fails.
|
|
463
|
+
"""
|
|
464
|
+
try:
|
|
465
|
+
id = id if id else self._microvm_id
|
|
466
|
+
self._vmm.update_vmm_state(id, "Paused")
|
|
467
|
+
|
|
468
|
+
config_path = f"{self._config.data_path}/{id}/config.json"
|
|
469
|
+
try:
|
|
470
|
+
with open(config_path, "r+") as file:
|
|
471
|
+
config = json.load(file)
|
|
472
|
+
config["State"]["Paused"] = "true"
|
|
473
|
+
file.seek(0)
|
|
474
|
+
json.dump(config, file)
|
|
475
|
+
file.truncate()
|
|
476
|
+
except Exception as e:
|
|
477
|
+
raise VMMError(f"Failed to update VMM state: {str(e)}")
|
|
478
|
+
|
|
479
|
+
return f"VMM {id} paused successfully"
|
|
480
|
+
|
|
481
|
+
except Exception as e:
|
|
482
|
+
raise VMMError(str(e))
|
|
483
|
+
|
|
484
|
+
def resume(self, id=None):
|
|
485
|
+
"""Resume the configured microVM.
|
|
486
|
+
|
|
487
|
+
Args:
|
|
488
|
+
id (str, optional): ID of the VMM to resume. If not provided,
|
|
489
|
+
uses the current VMM's ID.
|
|
490
|
+
|
|
491
|
+
Returns:
|
|
492
|
+
str: Status message indicating the result of the resume operation.
|
|
493
|
+
|
|
494
|
+
Raises:
|
|
495
|
+
FirecrackerError: If the resume operation fails.
|
|
496
|
+
"""
|
|
497
|
+
try:
|
|
498
|
+
id = id if id else self._microvm_id
|
|
499
|
+
self._vmm.update_vmm_state(id, "Resumed")
|
|
500
|
+
|
|
501
|
+
config_path = f"{self._config.data_path}/{id}/config.json"
|
|
502
|
+
try:
|
|
503
|
+
with open(config_path, "r+") as file:
|
|
504
|
+
config = json.load(file)
|
|
505
|
+
config["State"]["Paused"] = "false"
|
|
506
|
+
file.seek(0)
|
|
507
|
+
json.dump(config, file)
|
|
508
|
+
file.truncate()
|
|
509
|
+
except Exception as e:
|
|
510
|
+
raise VMMError(f"Failed to update VMM state: {str(e)}")
|
|
511
|
+
|
|
512
|
+
return f"VMM {id} resumed successfully"
|
|
513
|
+
|
|
514
|
+
except Exception as e:
|
|
515
|
+
raise VMMError(str(e))
|
|
516
|
+
|
|
517
|
+
def delete(self, id=None, all=False) -> str:
|
|
518
|
+
"""Delete a specific VMM or all VMMs and clean up associated resources.
|
|
519
|
+
|
|
520
|
+
Args:
|
|
521
|
+
id (str, optional): The ID of the VMM to delete. If not provided, the current VMM's ID is used.
|
|
522
|
+
all (bool, optional): If True, delete all running VMMs. Defaults to False.
|
|
523
|
+
|
|
524
|
+
Returns:
|
|
525
|
+
str: A status message indicating the result of the deletion operation.
|
|
526
|
+
|
|
527
|
+
Raises:
|
|
528
|
+
VMMError: If an error occurs during the deletion process.
|
|
529
|
+
"""
|
|
530
|
+
try:
|
|
531
|
+
vmm_list = self._vmm.list_vmm()
|
|
532
|
+
if not vmm_list:
|
|
533
|
+
return "No VMMs available to delete"
|
|
534
|
+
|
|
535
|
+
if all:
|
|
536
|
+
for vmm in vmm_list:
|
|
537
|
+
self._vmm.delete_vmm(vmm["id"])
|
|
538
|
+
return "All VMMs are deleted"
|
|
539
|
+
|
|
540
|
+
target_id = id if id else self._microvm_id
|
|
541
|
+
if not target_id:
|
|
542
|
+
return "No VMM ID specified for deletion"
|
|
543
|
+
|
|
544
|
+
if target_id not in [vmm["id"] for vmm in vmm_list]:
|
|
545
|
+
return f"VMM with ID {target_id} not found"
|
|
546
|
+
|
|
547
|
+
self._vmm.delete_vmm(target_id)
|
|
548
|
+
return f"VMM {target_id} is deleted"
|
|
549
|
+
|
|
550
|
+
except Exception as e:
|
|
551
|
+
self._logger.error(f"Error deleting VMM: {str(e)}")
|
|
552
|
+
raise VMMError(str(e))
|
|
553
|
+
|
|
554
|
+
def connect(self, id=None, username: str = None, key_path: str = None):
|
|
555
|
+
"""Connect to the microVM via SSH.
|
|
556
|
+
|
|
557
|
+
Args:
|
|
558
|
+
id (str, optional): ID of the microVM to connect to. If not provided,
|
|
559
|
+
uses the current VMM's ID.
|
|
560
|
+
username (str, optional): SSH username. Defaults to 'root'.
|
|
561
|
+
key_path (str, optional): Path to SSH private key.
|
|
562
|
+
|
|
563
|
+
Returns:
|
|
564
|
+
str: Status message indicating the SSH session was closed.
|
|
565
|
+
|
|
566
|
+
Raises:
|
|
567
|
+
VMMError: If the SSH connection fails for any reason.
|
|
568
|
+
"""
|
|
569
|
+
if not key_path:
|
|
570
|
+
return "SSH key path is required"
|
|
571
|
+
|
|
572
|
+
if not os.path.exists(key_path):
|
|
573
|
+
return f"SSH key file not found: {key_path}"
|
|
574
|
+
|
|
575
|
+
try:
|
|
576
|
+
vmm_list = self._vmm.list_vmm()
|
|
577
|
+
if not vmm_list:
|
|
578
|
+
return "No VMMs available to connect"
|
|
579
|
+
|
|
580
|
+
id = id if id else self._microvm_id
|
|
581
|
+
available_vmm_ids = [vmm["id"] for vmm in vmm_list]
|
|
582
|
+
|
|
583
|
+
if id not in available_vmm_ids:
|
|
584
|
+
return f"VMM with ID {id} does not exist"
|
|
585
|
+
|
|
586
|
+
with open(f"{self._config.data_path}/{id}/config.json", "r") as f:
|
|
587
|
+
ip_addr = json.load(f)["Network"][f"tap_{id}"]["IPAddress"]
|
|
588
|
+
|
|
589
|
+
self._establish_ssh_connection(ip_addr, username, key_path, id)
|
|
590
|
+
|
|
591
|
+
if self._config.verbose:
|
|
592
|
+
self._logger.info(
|
|
593
|
+
f"Attempting SSH connection to {ip_addr} with user {self._config.ssh_user}"
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
try:
|
|
597
|
+
channel = self._ssh_client.invoke_shell()
|
|
598
|
+
try:
|
|
599
|
+
old_settings = termios.tcgetattr(sys.stdin)
|
|
600
|
+
tty.setraw(sys.stdin)
|
|
601
|
+
except (termios.error, AttributeError):
|
|
602
|
+
old_settings = None
|
|
603
|
+
|
|
604
|
+
try:
|
|
605
|
+
while True:
|
|
606
|
+
if channel.exit_status_ready():
|
|
607
|
+
break
|
|
608
|
+
|
|
609
|
+
if channel.recv_ready():
|
|
610
|
+
data = channel.recv(1024)
|
|
611
|
+
if len(data) == 0:
|
|
612
|
+
break
|
|
613
|
+
sys.stdout.buffer.write(data)
|
|
614
|
+
sys.stdout.flush()
|
|
615
|
+
|
|
616
|
+
if (
|
|
617
|
+
old_settings
|
|
618
|
+
and sys.stdin in select.select([sys.stdin], [], [], 0.1)[0]
|
|
619
|
+
):
|
|
620
|
+
char = sys.stdin.read(1)
|
|
621
|
+
if not char:
|
|
622
|
+
break
|
|
623
|
+
channel.send(char)
|
|
624
|
+
elif not old_settings:
|
|
625
|
+
time.sleep(5)
|
|
626
|
+
break
|
|
627
|
+
finally:
|
|
628
|
+
if old_settings:
|
|
629
|
+
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
|
|
630
|
+
channel.close()
|
|
631
|
+
finally:
|
|
632
|
+
self._ssh_client.close()
|
|
633
|
+
|
|
634
|
+
message = f"SSH session to VMM {id or self._microvm_id} closed"
|
|
635
|
+
print(f"\n{message}\n")
|
|
636
|
+
|
|
637
|
+
except Exception as e:
|
|
638
|
+
raise VMMError(str(e))
|
|
639
|
+
|
|
640
|
+
def port_forward(
|
|
641
|
+
self,
|
|
642
|
+
id=None,
|
|
643
|
+
host_port: int = None,
|
|
644
|
+
dest_port: int = None,
|
|
645
|
+
remove: bool = False,
|
|
646
|
+
):
|
|
647
|
+
"""Forward a port from the host to the microVM and maintain the connection until interrupted.
|
|
648
|
+
|
|
649
|
+
Args:
|
|
650
|
+
host_port (int): Port on the host to forward
|
|
651
|
+
dest_port (int): Port on the destination
|
|
652
|
+
id (str, optional): ID of the VMM to forward ports to. If not provided, uses the last created VMM.
|
|
653
|
+
remove (bool, optional): If True, remove the port forwarding rule instead of adding it.
|
|
654
|
+
|
|
655
|
+
Raises:
|
|
656
|
+
VMMError: If VMM IP address cannot be found or port forwarding fails
|
|
657
|
+
ValueError: If the provided ports are not valid port numbers
|
|
658
|
+
"""
|
|
659
|
+
try:
|
|
660
|
+
vmm_list = self._vmm.list_vmm()
|
|
661
|
+
if not vmm_list:
|
|
662
|
+
return "No VMMs available"
|
|
663
|
+
|
|
664
|
+
id = id if id else self._microvm_id
|
|
665
|
+
available_vmm_ids = [vmm["id"] for vmm in vmm_list]
|
|
666
|
+
if id not in available_vmm_ids:
|
|
667
|
+
return f"VMM with ID {id} does not exist"
|
|
668
|
+
|
|
669
|
+
config_path = f"{self._config.data_path}/{id}/config.json"
|
|
670
|
+
with open(config_path, "r") as f:
|
|
671
|
+
config = json.load(f)
|
|
672
|
+
if "Network" not in config or f"tap_{id}" not in config["Network"]:
|
|
673
|
+
raise VMMError(f"Network configuration not found for VMM {id}")
|
|
674
|
+
dest_ip = config["Network"][f"tap_{id}"]["IPAddress"]
|
|
675
|
+
|
|
676
|
+
if not dest_ip:
|
|
677
|
+
raise VMMError(
|
|
678
|
+
f"Could not determine destination IP address for VMM {id}"
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
if not host_port or not dest_port:
|
|
682
|
+
raise ValueError("Both host_port and dest_port must be provided")
|
|
683
|
+
|
|
684
|
+
if not isinstance(host_port, (int, list)) or not isinstance(
|
|
685
|
+
dest_port, (int, list)
|
|
686
|
+
):
|
|
687
|
+
raise ValueError("Ports must be integers or lists of integers")
|
|
688
|
+
|
|
689
|
+
if remove:
|
|
690
|
+
self._remove_port_forwarding(host_port, dest_port, id)
|
|
691
|
+
return f"Port forwarding removed successfully for VMM {id}"
|
|
692
|
+
else:
|
|
693
|
+
self._setup_port_forwarding(host_port, dest_port, id, dest_ip)
|
|
694
|
+
return f"Port forwarding added successfully for VMM {id}"
|
|
695
|
+
|
|
696
|
+
except Exception as e:
|
|
697
|
+
raise VMMError(f"Failed to configure port forwarding: {str(e)}")
|
|
698
|
+
|
|
699
|
+
def snapshot(
|
|
700
|
+
self,
|
|
701
|
+
id=None,
|
|
702
|
+
action: str = None,
|
|
703
|
+
memory_path: str = None,
|
|
704
|
+
snapshot_path: str = None,
|
|
705
|
+
rootfs_path: str = None,
|
|
706
|
+
):
|
|
707
|
+
"""Create a snapshot of the microVM.
|
|
708
|
+
|
|
709
|
+
Args:
|
|
710
|
+
id (str, optional): ID of the VMM to create a snapshot of. If not provided, uses the last created VMM.
|
|
711
|
+
action (str, optional): Action to perform on the snapshot.
|
|
712
|
+
memory_path (str, optional): Path to the memory file. If not provided, uses the default memory path.
|
|
713
|
+
snapshot_path (str, optional): Path to the snapshot file. If not provided, uses the default snapshot path.
|
|
714
|
+
rootfs_path (str, optional): Path to the rootfs file. If not provided, uses the default rootfs path.
|
|
715
|
+
This parameter is particularly important when loading snapshots to override the original rootfs path
|
|
716
|
+
that was saved in the snapshot metadata.
|
|
717
|
+
"""
|
|
718
|
+
try:
|
|
719
|
+
id = id if id else self._microvm_id
|
|
720
|
+
self._api = self._vmm.get_api(id)
|
|
721
|
+
|
|
722
|
+
if action == "create":
|
|
723
|
+
if self._vmm.get_vmm_state(id) == "Paused":
|
|
724
|
+
if self._config.verbose:
|
|
725
|
+
self._logger.info(f"VMM {id} is already paused")
|
|
726
|
+
else:
|
|
727
|
+
if self._config.verbose:
|
|
728
|
+
self._logger.info(f"Pausing VMM {id} to create snapshot")
|
|
729
|
+
self._vmm.update_vmm_state(id, "Paused")
|
|
730
|
+
|
|
731
|
+
if not os.path.exists(f"{self._config.snapshot_path}/{id}"):
|
|
732
|
+
os.makedirs(f"{self._config.snapshot_path}/{id}", mode=0o755)
|
|
733
|
+
self._logger.info(f"Created VMM {id} snapshot directory")
|
|
734
|
+
|
|
735
|
+
self._api.create_snapshot.put(
|
|
736
|
+
mem_file_path=self._mem_file_path
|
|
737
|
+
if memory_path is None
|
|
738
|
+
else memory_path,
|
|
739
|
+
snapshot_path=self._snapshot_path
|
|
740
|
+
if snapshot_path is None
|
|
741
|
+
else snapshot_path,
|
|
742
|
+
)
|
|
743
|
+
if self._config.verbose:
|
|
744
|
+
self._logger.debug(f"Snapshot created at {self._snapshot_path}")
|
|
745
|
+
self._logger.info(f"Snapshot created for VMM {id}")
|
|
746
|
+
self._vmm.update_vmm_state(id, "Resumed")
|
|
747
|
+
elif action == "load":
|
|
748
|
+
# Determine the rootfs path to use
|
|
749
|
+
if rootfs_path is None:
|
|
750
|
+
# Use overlayfs logic to determine correct rootfs path
|
|
751
|
+
if self._overlayfs and self._base_rootfs:
|
|
752
|
+
rootfs_path = self._base_rootfs
|
|
753
|
+
else:
|
|
754
|
+
rootfs_path = self._rootfs_file
|
|
755
|
+
|
|
756
|
+
# Verify required files exist before attempting to load snapshot
|
|
757
|
+
snapshot_file = (
|
|
758
|
+
snapshot_path if snapshot_path is not None else self._snapshot_path
|
|
759
|
+
)
|
|
760
|
+
mem_file = (
|
|
761
|
+
memory_path if memory_path is not None else self._mem_file_path
|
|
762
|
+
)
|
|
763
|
+
|
|
764
|
+
# Validate snapshot file
|
|
765
|
+
if not os.path.exists(snapshot_file):
|
|
766
|
+
raise FileNotFoundError(f"Snapshot file not found: {snapshot_file}")
|
|
767
|
+
|
|
768
|
+
# Validate memory file
|
|
769
|
+
if not os.path.exists(mem_file):
|
|
770
|
+
raise FileNotFoundError(f"Memory file not found: {mem_file}")
|
|
771
|
+
|
|
772
|
+
# Validate rootfs file
|
|
773
|
+
if not os.path.exists(rootfs_path):
|
|
774
|
+
raise FileNotFoundError(f"Rootfs file not found: {rootfs_path}")
|
|
775
|
+
|
|
776
|
+
# Check file sizes and provide helpful info
|
|
777
|
+
snapshot_size = os.path.getsize(snapshot_file)
|
|
778
|
+
mem_size = os.path.getsize(mem_file)
|
|
779
|
+
rootfs_size = os.path.getsize(rootfs_path)
|
|
780
|
+
|
|
781
|
+
if self._config.verbose:
|
|
782
|
+
self._logger.debug(
|
|
783
|
+
f"Snapshot file: {snapshot_file} ({snapshot_size} bytes)"
|
|
784
|
+
)
|
|
785
|
+
self._logger.debug(f"Memory file: {mem_file} ({mem_size} bytes)")
|
|
786
|
+
self._logger.debug(
|
|
787
|
+
f"Rootfs file: {rootfs_path} ({rootfs_size} bytes)"
|
|
788
|
+
)
|
|
789
|
+
|
|
790
|
+
# Validate memory file is not empty or too small
|
|
791
|
+
if mem_size < 1024: # Less than 1KB is suspicious
|
|
792
|
+
raise ValueError(
|
|
793
|
+
f"Memory file appears to be corrupt or incomplete: {mem_file} (size: {mem_size} bytes)"
|
|
794
|
+
)
|
|
795
|
+
|
|
796
|
+
# Validate snapshot file is not empty
|
|
797
|
+
if snapshot_size < 100: # Less than 100 bytes is suspicious
|
|
798
|
+
raise ValueError(
|
|
799
|
+
f"Snapshot file appears to be corrupt or incomplete: {snapshot_file} (size: {snapshot_size} bytes)"
|
|
800
|
+
)
|
|
801
|
+
|
|
802
|
+
if self._config.verbose:
|
|
803
|
+
self._logger.debug(
|
|
804
|
+
f"Using rootfs path for snapshot load: {rootfs_path}"
|
|
805
|
+
)
|
|
806
|
+
|
|
807
|
+
# Parse snapshot to find expected rootfs path and create symlink if needed
|
|
808
|
+
# This is a workaround for older Firecracker versions that don't support backend_overrides
|
|
809
|
+
self._prepare_snapshot_rootfs_symlink(snapshot_file, rootfs_path)
|
|
810
|
+
|
|
811
|
+
# Try to load the snapshot
|
|
812
|
+
try:
|
|
813
|
+
self._api.load_snapshot.put(
|
|
814
|
+
enable_diff_snapshots=True,
|
|
815
|
+
mem_backend={
|
|
816
|
+
"backend_type": "File",
|
|
817
|
+
"backend_path": memory_path
|
|
818
|
+
if memory_path is not None
|
|
819
|
+
else self._mem_file_path,
|
|
820
|
+
},
|
|
821
|
+
snapshot_path=snapshot_file,
|
|
822
|
+
resume_vm=True,
|
|
823
|
+
network_overrides=[
|
|
824
|
+
{
|
|
825
|
+
"iface_id": self._iface_name,
|
|
826
|
+
"host_dev_name": self._host_dev_name,
|
|
827
|
+
}
|
|
828
|
+
],
|
|
829
|
+
)
|
|
830
|
+
if self._config.verbose:
|
|
831
|
+
self._logger.debug(f"Snapshot loaded from {snapshot_file}")
|
|
832
|
+
self._logger.info(f"Snapshot loaded for VMM {id}")
|
|
833
|
+
|
|
834
|
+
except Exception as load_error:
|
|
835
|
+
error_msg = str(load_error)
|
|
836
|
+
|
|
837
|
+
# Check for memory file corruption/truncation error
|
|
838
|
+
if (
|
|
839
|
+
"file offset and length is greater" in error_msg
|
|
840
|
+
or "Cannot create mmap region" in error_msg
|
|
841
|
+
):
|
|
842
|
+
# Memory file is corrupt, truncated, or incompatible
|
|
843
|
+
raise VMMError(
|
|
844
|
+
f"Memory file is corrupt, truncated, or incompatible with snapshot.\n"
|
|
845
|
+
f" Memory file: {mem_file} (size: {mem_size} bytes)\n"
|
|
846
|
+
f" Snapshot file: {snapshot_file} (size: {snapshot_size} bytes)\n"
|
|
847
|
+
f" Error: {error_msg}\n\n"
|
|
848
|
+
f"Possible causes:\n"
|
|
849
|
+
f" 1. Memory file was not fully written during snapshot creation\n"
|
|
850
|
+
f" 2. Memory file was truncated or corrupted\n"
|
|
851
|
+
f" 3. Snapshot and memory files are from different snapshots\n"
|
|
852
|
+
f" 4. Disk was full during snapshot creation\n\n"
|
|
853
|
+
f"Solution: Re-create the snapshot from the source VM."
|
|
854
|
+
)
|
|
855
|
+
|
|
856
|
+
# If load failed due to missing rootfs file, try to extract path from error and create symlink
|
|
857
|
+
if "No such file or directory" in error_msg and ".img" in error_msg:
|
|
858
|
+
# Extract the expected path from error message
|
|
859
|
+
# Error format: "... No such file or directory (os error 2) /path/to/file.img"
|
|
860
|
+
match = re.search(r"(\S+\.img)", error_msg)
|
|
861
|
+
if match:
|
|
862
|
+
expected_path = match.group(1)
|
|
863
|
+
if self._config.verbose:
|
|
864
|
+
self._logger.info(
|
|
865
|
+
f"Snapshot load failed: rootfs not found at {expected_path}"
|
|
866
|
+
)
|
|
867
|
+
self._logger.info(
|
|
868
|
+
f"Creating symlink from error path: {expected_path} -> {rootfs_path}"
|
|
869
|
+
)
|
|
870
|
+
|
|
871
|
+
# Create symlink and retry
|
|
872
|
+
try:
|
|
873
|
+
expected_dir = os.path.dirname(expected_path)
|
|
874
|
+
if not os.path.exists(expected_dir):
|
|
875
|
+
os.makedirs(expected_dir, mode=0o755, exist_ok=True)
|
|
876
|
+
|
|
877
|
+
# Remove existing file/symlink if needed
|
|
878
|
+
if os.path.exists(expected_path) or os.path.islink(
|
|
879
|
+
expected_path
|
|
880
|
+
):
|
|
881
|
+
os.remove(expected_path)
|
|
882
|
+
|
|
883
|
+
# Create symlink
|
|
884
|
+
os.symlink(rootfs_path, expected_path)
|
|
885
|
+
if self._config.verbose:
|
|
886
|
+
self._logger.info(
|
|
887
|
+
f"Created symlink: {expected_path} -> {rootfs_path}"
|
|
888
|
+
)
|
|
889
|
+
|
|
890
|
+
# Firecracker process crashed after first failed load attempt
|
|
891
|
+
# Need to restart it before retry
|
|
892
|
+
if self._config.verbose:
|
|
893
|
+
self._logger.info(
|
|
894
|
+
f"Restarting Firecracker process for retry..."
|
|
895
|
+
)
|
|
896
|
+
|
|
897
|
+
# Close old API connection
|
|
898
|
+
try:
|
|
899
|
+
self._api.close()
|
|
900
|
+
except:
|
|
901
|
+
pass
|
|
902
|
+
|
|
903
|
+
# Kill old Firecracker process if it's still running
|
|
904
|
+
try:
|
|
905
|
+
self._process.kill(id)
|
|
906
|
+
except:
|
|
907
|
+
pass
|
|
908
|
+
|
|
909
|
+
# Start new Firecracker process
|
|
910
|
+
self._run_firecracker()
|
|
911
|
+
|
|
912
|
+
# Get new API connection
|
|
913
|
+
self._api = self._vmm.get_api(id)
|
|
914
|
+
|
|
915
|
+
# Retry snapshot load
|
|
916
|
+
self._api.load_snapshot.put(
|
|
917
|
+
enable_diff_snapshots=True,
|
|
918
|
+
mem_backend={
|
|
919
|
+
"backend_type": "File",
|
|
920
|
+
"backend_path": memory_path
|
|
921
|
+
if memory_path is not None
|
|
922
|
+
else self._mem_file_path,
|
|
923
|
+
},
|
|
924
|
+
snapshot_path=snapshot_file,
|
|
925
|
+
resume_vm=True,
|
|
926
|
+
network_overrides=[
|
|
927
|
+
{
|
|
928
|
+
"iface_id": self._iface_name,
|
|
929
|
+
"host_dev_name": self._host_dev_name,
|
|
930
|
+
}
|
|
931
|
+
],
|
|
932
|
+
)
|
|
933
|
+
if self._config.verbose:
|
|
934
|
+
self._logger.info(
|
|
935
|
+
f"Snapshot loaded successfully after symlink creation and process restart"
|
|
936
|
+
)
|
|
937
|
+
except Exception as retry_error:
|
|
938
|
+
raise VMMError(
|
|
939
|
+
f"Failed to load snapshot even after creating symlink: {str(retry_error)}"
|
|
940
|
+
)
|
|
941
|
+
else:
|
|
942
|
+
# Could not extract path from error, re-raise original error
|
|
943
|
+
raise
|
|
944
|
+
else:
|
|
945
|
+
# Different error, re-raise
|
|
946
|
+
raise
|
|
947
|
+
else:
|
|
948
|
+
raise ValueError("Invalid action. Must be 'create' or 'load'")
|
|
949
|
+
|
|
950
|
+
except Exception as e:
|
|
951
|
+
raise VMMError(f"Failed to create snapshot: {str(e)}")
|
|
952
|
+
|
|
953
|
+
def _prepare_snapshot_rootfs_symlink(
|
|
954
|
+
self, snapshot_path: str, target_rootfs_path: str
|
|
955
|
+
):
|
|
956
|
+
"""Prepare symlink from snapshot's expected rootfs path to actual rootfs path.
|
|
957
|
+
|
|
958
|
+
This is a workaround for Firecracker versions that don't support backend_overrides.
|
|
959
|
+
It parses the snapshot file to find the expected rootfs path and creates a symlink
|
|
960
|
+
from that path to the actual rootfs file.
|
|
961
|
+
|
|
962
|
+
Args:
|
|
963
|
+
snapshot_path (str): Path to the snapshot file
|
|
964
|
+
target_rootfs_path (str): Actual path to the rootfs file to use
|
|
965
|
+
"""
|
|
966
|
+
try:
|
|
967
|
+
# Read and parse snapshot file to find the expected rootfs path
|
|
968
|
+
with open(snapshot_path, "r", encoding="utf-8") as f:
|
|
969
|
+
snapshot_data = json.load(f)
|
|
970
|
+
|
|
971
|
+
# Look for block devices in the snapshot
|
|
972
|
+
if "block_devices" in snapshot_data:
|
|
973
|
+
for device in snapshot_data["block_devices"]:
|
|
974
|
+
# Find the rootfs device
|
|
975
|
+
if device.get("drive_id") == "rootfs" or device.get(
|
|
976
|
+
"is_root_device"
|
|
977
|
+
):
|
|
978
|
+
expected_path = device.get("path_on_host")
|
|
979
|
+
|
|
980
|
+
if expected_path and expected_path != target_rootfs_path:
|
|
981
|
+
# The snapshot expects a different path
|
|
982
|
+
if self._config.verbose:
|
|
983
|
+
self._logger.info(
|
|
984
|
+
f"Snapshot expects rootfs at: {expected_path}"
|
|
985
|
+
)
|
|
986
|
+
self._logger.info(
|
|
987
|
+
f"Creating symlink to actual rootfs: {target_rootfs_path}"
|
|
988
|
+
)
|
|
989
|
+
|
|
990
|
+
# Create parent directories if they don't exist
|
|
991
|
+
expected_dir = os.path.dirname(expected_path)
|
|
992
|
+
if not os.path.exists(expected_dir):
|
|
993
|
+
os.makedirs(expected_dir, mode=0o755, exist_ok=True)
|
|
994
|
+
if self._config.verbose:
|
|
995
|
+
self._logger.debug(
|
|
996
|
+
f"Created directory: {expected_dir}"
|
|
997
|
+
)
|
|
998
|
+
|
|
999
|
+
# Remove existing file/symlink if it exists and is not the target
|
|
1000
|
+
if os.path.exists(expected_path) or os.path.islink(
|
|
1001
|
+
expected_path
|
|
1002
|
+
):
|
|
1003
|
+
# Check if it's already a valid symlink to our target
|
|
1004
|
+
if (
|
|
1005
|
+
os.path.islink(expected_path)
|
|
1006
|
+
and os.readlink(expected_path) == target_rootfs_path
|
|
1007
|
+
):
|
|
1008
|
+
if self._config.verbose:
|
|
1009
|
+
self._logger.debug(
|
|
1010
|
+
f"Symlink already exists and is correct: {expected_path} -> {target_rootfs_path}"
|
|
1011
|
+
)
|
|
1012
|
+
return
|
|
1013
|
+
|
|
1014
|
+
# Remove the existing file/symlink
|
|
1015
|
+
os.remove(expected_path)
|
|
1016
|
+
if self._config.verbose:
|
|
1017
|
+
self._logger.debug(
|
|
1018
|
+
f"Removed existing file/symlink: {expected_path}"
|
|
1019
|
+
)
|
|
1020
|
+
|
|
1021
|
+
# Create the symlink
|
|
1022
|
+
os.symlink(target_rootfs_path, expected_path)
|
|
1023
|
+
if self._config.verbose:
|
|
1024
|
+
self._logger.info(
|
|
1025
|
+
f"Created symlink: {expected_path} -> {target_rootfs_path}"
|
|
1026
|
+
)
|
|
1027
|
+
|
|
1028
|
+
break
|
|
1029
|
+
elif expected_path == target_rootfs_path:
|
|
1030
|
+
# Paths match, no symlink needed
|
|
1031
|
+
if self._config.verbose:
|
|
1032
|
+
self._logger.debug(
|
|
1033
|
+
f"Rootfs paths match, no symlink needed: {target_rootfs_path}"
|
|
1034
|
+
)
|
|
1035
|
+
else:
|
|
1036
|
+
if self._config.verbose:
|
|
1037
|
+
self._logger.warn(
|
|
1038
|
+
"Could not find path_on_host in snapshot block device"
|
|
1039
|
+
)
|
|
1040
|
+
else:
|
|
1041
|
+
if self._config.verbose:
|
|
1042
|
+
self._logger.warn(
|
|
1043
|
+
"No block_devices found in snapshot, skipping symlink creation"
|
|
1044
|
+
)
|
|
1045
|
+
|
|
1046
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
|
1047
|
+
# Snapshot is in binary format, cannot parse to extract rootfs path
|
|
1048
|
+
# This is normal for some Firecracker versions
|
|
1049
|
+
# Silently skip symlink creation and let the load attempt proceed
|
|
1050
|
+
if self._config.verbose:
|
|
1051
|
+
self._logger.warn(
|
|
1052
|
+
f"Snapshot is in binary format, cannot extract rootfs path for symlink creation"
|
|
1053
|
+
)
|
|
1054
|
+
self._logger.warn(
|
|
1055
|
+
"Proceeding without symlink - snapshot load may fail if paths don't match"
|
|
1056
|
+
)
|
|
1057
|
+
except Exception as e:
|
|
1058
|
+
# Other errors during symlink preparation - log but don't fail
|
|
1059
|
+
# Let the snapshot load attempt proceed anyway
|
|
1060
|
+
if self._config.verbose:
|
|
1061
|
+
self._logger.warn(f"Error preparing rootfs symlink: {e}")
|
|
1062
|
+
self._logger.warn(
|
|
1063
|
+
"Proceeding without symlink - snapshot load may fail if paths don't match"
|
|
1064
|
+
)
|
|
1065
|
+
|
|
1066
|
+
@staticmethod
|
|
1067
|
+
def _parse_ports(port_value, default_value=None):
|
|
1068
|
+
"""Parse port values from various input formats.
|
|
1069
|
+
|
|
1070
|
+
Args:
|
|
1071
|
+
port_value: Port specification that could be None, an integer, a string with comma-separated values,
|
|
1072
|
+
or a list of integers
|
|
1073
|
+
default_value: Default value to use if port_value is None
|
|
1074
|
+
|
|
1075
|
+
Returns:
|
|
1076
|
+
list: A list of integer port values
|
|
1077
|
+
"""
|
|
1078
|
+
if port_value is None:
|
|
1079
|
+
return [default_value] if default_value is not None else []
|
|
1080
|
+
|
|
1081
|
+
if isinstance(port_value, int):
|
|
1082
|
+
return [port_value]
|
|
1083
|
+
|
|
1084
|
+
if isinstance(port_value, str):
|
|
1085
|
+
if "," in port_value:
|
|
1086
|
+
return [
|
|
1087
|
+
int(p.strip()) for p in port_value.split(",") if p.strip().isdigit()
|
|
1088
|
+
]
|
|
1089
|
+
elif port_value.isdigit():
|
|
1090
|
+
return [int(port_value)]
|
|
1091
|
+
|
|
1092
|
+
if isinstance(port_value, list):
|
|
1093
|
+
ports = []
|
|
1094
|
+
for p in port_value:
|
|
1095
|
+
if isinstance(p, int):
|
|
1096
|
+
ports.append(p)
|
|
1097
|
+
elif isinstance(p, str) and p.isdigit():
|
|
1098
|
+
ports.append(int(p))
|
|
1099
|
+
return ports
|
|
1100
|
+
|
|
1101
|
+
return []
|
|
1102
|
+
|
|
1103
|
+
@property
|
|
1104
|
+
def _boot_args(self):
|
|
1105
|
+
"""Generate boot arguments using current configuration.
|
|
1106
|
+
|
|
1107
|
+
Returns:
|
|
1108
|
+
str: Boot arguments
|
|
1109
|
+
"""
|
|
1110
|
+
common_args = (
|
|
1111
|
+
"console=ttyS0 reboot=k pci=off panic=1 "
|
|
1112
|
+
f"ip={self._ip_addr}::{self._gateway_ip}:255.255.255.0:"
|
|
1113
|
+
f"{self._microvm_name}:eth0:on"
|
|
1114
|
+
)
|
|
1115
|
+
|
|
1116
|
+
if self._mmds_enabled:
|
|
1117
|
+
return f"{common_args} init={self._init_file}"
|
|
1118
|
+
elif self._overlayfs:
|
|
1119
|
+
return f"{common_args} init={self._init_file} overlay_root=/vdb"
|
|
1120
|
+
else:
|
|
1121
|
+
return f"{common_args}"
|
|
1122
|
+
|
|
1123
|
+
def _configure_vmm_boot_source(self):
|
|
1124
|
+
"""Configure the boot source for the microVM.
|
|
1125
|
+
|
|
1126
|
+
Raises:
|
|
1127
|
+
ConfigurationError: If boot source configuration fails
|
|
1128
|
+
"""
|
|
1129
|
+
try:
|
|
1130
|
+
boot_params = {
|
|
1131
|
+
"kernel_image_path": self._kernel_file,
|
|
1132
|
+
"boot_args": self._boot_args,
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
if self._initrd_file:
|
|
1136
|
+
boot_params["initrd_path"] = self._initrd_file
|
|
1137
|
+
self._logger.info(f"Using initrd file: {self._initrd_file}")
|
|
1138
|
+
|
|
1139
|
+
boot_response = self._api.boot.put(**boot_params)
|
|
1140
|
+
|
|
1141
|
+
if self._config.verbose:
|
|
1142
|
+
self._logger.debug(
|
|
1143
|
+
f"Boot configuration response: {boot_response.status_code}"
|
|
1144
|
+
)
|
|
1145
|
+
self._logger.info("Boot source configured")
|
|
1146
|
+
|
|
1147
|
+
except Exception as e:
|
|
1148
|
+
raise ConfigurationError(f"Failed to configure boot source: {str(e)}")
|
|
1149
|
+
|
|
1150
|
+
def _configure_vmm_root_drive(self):
|
|
1151
|
+
"""Configure the root drive for the microVM.
|
|
1152
|
+
|
|
1153
|
+
Raises:
|
|
1154
|
+
ConfigurationError: If root drive configuration fails
|
|
1155
|
+
"""
|
|
1156
|
+
try:
|
|
1157
|
+
rootfs_path = self._rootfs_file
|
|
1158
|
+
if self._overlayfs and self._base_rootfs:
|
|
1159
|
+
rootfs_path = self._base_rootfs
|
|
1160
|
+
|
|
1161
|
+
self._api.drive.put(
|
|
1162
|
+
drive_id="rootfs",
|
|
1163
|
+
path_on_host=rootfs_path,
|
|
1164
|
+
is_root_device=True if self._initrd_file is None else False,
|
|
1165
|
+
is_read_only=self._overlayfs is True,
|
|
1166
|
+
)
|
|
1167
|
+
if self._config.verbose:
|
|
1168
|
+
self._logger.info("Root drive configured")
|
|
1169
|
+
|
|
1170
|
+
if self._overlayfs:
|
|
1171
|
+
self._api.drive.put(
|
|
1172
|
+
drive_id="overlayfs",
|
|
1173
|
+
path_on_host=self._overlayfs_file,
|
|
1174
|
+
is_root_device=False,
|
|
1175
|
+
is_read_only=False,
|
|
1176
|
+
)
|
|
1177
|
+
|
|
1178
|
+
if self._config.verbose:
|
|
1179
|
+
self._logger.info("Overlayfs drive configured")
|
|
1180
|
+
|
|
1181
|
+
except Exception:
|
|
1182
|
+
raise ConfigurationError("Failed to configure root drive")
|
|
1183
|
+
|
|
1184
|
+
def _configure_vmm_resources(self):
|
|
1185
|
+
"""Configure machine resources (vCPUs and memory).
|
|
1186
|
+
|
|
1187
|
+
Raises:
|
|
1188
|
+
ConfigurationError: If machine configuration fails
|
|
1189
|
+
"""
|
|
1190
|
+
try:
|
|
1191
|
+
self._api.machine_config.put(
|
|
1192
|
+
vcpu_count=self._vcpu, mem_size_mib=self._memory
|
|
1193
|
+
)
|
|
1194
|
+
|
|
1195
|
+
if self._config.verbose:
|
|
1196
|
+
self._logger.info(
|
|
1197
|
+
f"Configured VMM with {self._vcpu} vCPUs and {self._memory} MiB RAM"
|
|
1198
|
+
)
|
|
1199
|
+
|
|
1200
|
+
except Exception as e:
|
|
1201
|
+
raise ConfigurationError(f"Failed to configure VMM resources: {str(e)}")
|
|
1202
|
+
|
|
1203
|
+
def _configure_vmm_network(self):
|
|
1204
|
+
"""Configure network interface.
|
|
1205
|
+
|
|
1206
|
+
Raises:
|
|
1207
|
+
NetworkError: If network configuration fails
|
|
1208
|
+
"""
|
|
1209
|
+
try:
|
|
1210
|
+
response = self._api.network.put(
|
|
1211
|
+
iface_id="eth0", host_dev_name=self._host_dev_name
|
|
1212
|
+
)
|
|
1213
|
+
|
|
1214
|
+
if self._config.verbose:
|
|
1215
|
+
self._logger.debug(
|
|
1216
|
+
f"Network configuration response: {response.status_code}"
|
|
1217
|
+
)
|
|
1218
|
+
self._logger.info("Configured network interface")
|
|
1219
|
+
|
|
1220
|
+
except Exception as e:
|
|
1221
|
+
raise ConfigurationError(f"Failed to configure network: {str(e)}")
|
|
1222
|
+
|
|
1223
|
+
def _configure_vmm_mmds(self):
|
|
1224
|
+
"""Configure MMDS (Microvm Metadata Service) if enabled.
|
|
1225
|
+
|
|
1226
|
+
MMDS is a service that provides metadata to the microVM.
|
|
1227
|
+
"""
|
|
1228
|
+
try:
|
|
1229
|
+
if self._config.verbose:
|
|
1230
|
+
self._logger.debug(
|
|
1231
|
+
"MMDS is "
|
|
1232
|
+
+ (
|
|
1233
|
+
"disabled"
|
|
1234
|
+
if not self._mmds_enabled
|
|
1235
|
+
else "enabled, configuring MMDS network..."
|
|
1236
|
+
)
|
|
1237
|
+
)
|
|
1238
|
+
|
|
1239
|
+
if not self._mmds_enabled:
|
|
1240
|
+
return
|
|
1241
|
+
|
|
1242
|
+
self._api.mmds_config.put(
|
|
1243
|
+
version="V2",
|
|
1244
|
+
ipv4_address=self._mmds_ip,
|
|
1245
|
+
network_interfaces=["eth0"],
|
|
1246
|
+
)
|
|
1247
|
+
|
|
1248
|
+
user_data = {
|
|
1249
|
+
"latest": {
|
|
1250
|
+
"meta-data": {
|
|
1251
|
+
"instance-id": self._microvm_id,
|
|
1252
|
+
"local-hostname": self._microvm_name,
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
if self._user_data:
|
|
1258
|
+
user_data["latest"]["user-data"] = self._user_data
|
|
1259
|
+
if hasattr(self, "_user_data_file") and self._user_data_file:
|
|
1260
|
+
user_data["latest"]["meta-data"]["user-data-file"] = (
|
|
1261
|
+
self._user_data_file
|
|
1262
|
+
)
|
|
1263
|
+
|
|
1264
|
+
mmds_data_response = self._api.mmds.put(**user_data)
|
|
1265
|
+
|
|
1266
|
+
if self._config.verbose:
|
|
1267
|
+
self._logger.debug(
|
|
1268
|
+
f"MMDS data response: {mmds_data_response.status_code}"
|
|
1269
|
+
)
|
|
1270
|
+
self._logger.info("MMDS data configured")
|
|
1271
|
+
|
|
1272
|
+
except Exception as e:
|
|
1273
|
+
raise ConfigurationError(f"Failed to configure MMDS: {str(e)}")
|
|
1274
|
+
|
|
1275
|
+
def _configure_vmm_vsock(self):
|
|
1276
|
+
"""Configure Vsock if enabled.
|
|
1277
|
+
|
|
1278
|
+
Vsock is a communication channel between the microVM and the host.
|
|
1279
|
+
"""
|
|
1280
|
+
try:
|
|
1281
|
+
if self._config.verbose:
|
|
1282
|
+
self._logger.debug(
|
|
1283
|
+
"Vsock is "
|
|
1284
|
+
+ (
|
|
1285
|
+
"disabled"
|
|
1286
|
+
if not self._vsock_enabled
|
|
1287
|
+
else "enabled, configuring Vsock..."
|
|
1288
|
+
)
|
|
1289
|
+
)
|
|
1290
|
+
|
|
1291
|
+
self._api.vsock.put(
|
|
1292
|
+
guest_cid=self._vsock_guest_cid, uds_path=self._vsock_uds_path
|
|
1293
|
+
)
|
|
1294
|
+
|
|
1295
|
+
if self._config.verbose:
|
|
1296
|
+
self._logger.debug(
|
|
1297
|
+
f"Vsock configured with guest CID {self._vsock_guest_cid} and UDS path {self._vsock_uds_path}"
|
|
1298
|
+
)
|
|
1299
|
+
self._logger.info("Vsock configured")
|
|
1300
|
+
|
|
1301
|
+
except Exception as e:
|
|
1302
|
+
raise ConfigurationError(f"Failed to configure Vsock: {str(e)}")
|
|
1303
|
+
|
|
1304
|
+
def _run_firecracker(self):
|
|
1305
|
+
"""Run the Firecracker process.
|
|
1306
|
+
|
|
1307
|
+
Raises:
|
|
1308
|
+
VMMError: If Firecracker process fails to start
|
|
1309
|
+
ConfigurationError: If Firecracker configuration fails
|
|
1310
|
+
NetworkError: If network configuration fails
|
|
1311
|
+
SSHException: If SSH connection fails
|
|
1312
|
+
"""
|
|
1313
|
+
try:
|
|
1314
|
+
self._vmm.socket_file(self._microvm_id)
|
|
1315
|
+
|
|
1316
|
+
paths = [self._vmm_dir, f"{self._vmm_dir}/rootfs", f"{self._vmm_dir}/logs"]
|
|
1317
|
+
for path in paths:
|
|
1318
|
+
self._vmm.create_vmm_dir(path)
|
|
1319
|
+
|
|
1320
|
+
if (
|
|
1321
|
+
not self._overlayfs
|
|
1322
|
+
and self._base_rootfs
|
|
1323
|
+
and os.path.exists(self._base_rootfs)
|
|
1324
|
+
):
|
|
1325
|
+
run(f"cp {self._base_rootfs} {self._rootfs_file}", capture_output=True)
|
|
1326
|
+
if self._config.verbose:
|
|
1327
|
+
self._logger.debug(
|
|
1328
|
+
f"Copied base rootfs from {self._base_rootfs} to {self._rootfs_file}"
|
|
1329
|
+
)
|
|
1330
|
+
|
|
1331
|
+
self._vmm.create_log_file(self._microvm_id, f"{self._microvm_id}.log")
|
|
1332
|
+
|
|
1333
|
+
args = [
|
|
1334
|
+
"--api-sock",
|
|
1335
|
+
self._socket_file,
|
|
1336
|
+
"--id",
|
|
1337
|
+
self._microvm_id,
|
|
1338
|
+
"--log-path",
|
|
1339
|
+
f"{self._log_dir}/{self._microvm_id}.log",
|
|
1340
|
+
]
|
|
1341
|
+
|
|
1342
|
+
self._process.start(self._microvm_id, args)
|
|
1343
|
+
self._process.is_running(self._microvm_id)
|
|
1344
|
+
|
|
1345
|
+
for _ in range(3):
|
|
1346
|
+
try:
|
|
1347
|
+
response = self._api.describe.get()
|
|
1348
|
+
if response.status_code == HTTPStatus.OK:
|
|
1349
|
+
return Api(self._socket_file)
|
|
1350
|
+
except Exception:
|
|
1351
|
+
pass
|
|
1352
|
+
time.sleep(0.5)
|
|
1353
|
+
|
|
1354
|
+
except Exception as exc:
|
|
1355
|
+
self._vmm.cleanup(self._microvm_id)
|
|
1356
|
+
raise VMMError(str(exc))
|
|
1357
|
+
|
|
1358
|
+
def _download_kernel(self, url: str, path: str):
|
|
1359
|
+
"""Download the kernel file from the provided URL.
|
|
1360
|
+
|
|
1361
|
+
Args:
|
|
1362
|
+
url (str): URL to download the kernel from
|
|
1363
|
+
path (str): Local path where to save the kernel file
|
|
1364
|
+
|
|
1365
|
+
Raises:
|
|
1366
|
+
ValueError: If URL is invalid or doesn't contain http/https
|
|
1367
|
+
VMMError: If download fails
|
|
1368
|
+
"""
|
|
1369
|
+
import urllib.request
|
|
1370
|
+
import urllib.parse
|
|
1371
|
+
|
|
1372
|
+
if not url or not isinstance(url, str):
|
|
1373
|
+
return "URL must be a non-empty string"
|
|
1374
|
+
|
|
1375
|
+
if not (url.startswith("http://") or url.startswith("https://")):
|
|
1376
|
+
return "URL must start with http:// or https://"
|
|
1377
|
+
|
|
1378
|
+
try:
|
|
1379
|
+
parsed_url = urllib.parse.urlparse(url)
|
|
1380
|
+
if not parsed_url.scheme or not parsed_url.netloc:
|
|
1381
|
+
raise ValueError("Invalid URL format")
|
|
1382
|
+
|
|
1383
|
+
except Exception as e:
|
|
1384
|
+
raise ValueError(f"Invalid URL format: {str(e)}")
|
|
1385
|
+
|
|
1386
|
+
if os.path.exists(path):
|
|
1387
|
+
if self._config.verbose:
|
|
1388
|
+
self._logger.info(f"Kernel file already exists: {path}")
|
|
1389
|
+
return
|
|
1390
|
+
|
|
1391
|
+
try:
|
|
1392
|
+
if self._config.verbose:
|
|
1393
|
+
self._logger.info(f"Downloading kernel file from {url}...")
|
|
1394
|
+
|
|
1395
|
+
urllib.request.urlretrieve(url, path)
|
|
1396
|
+
|
|
1397
|
+
if not os.path.exists(path) or os.path.getsize(path) == 0:
|
|
1398
|
+
raise VMMError("Download failed: file is empty or was not created")
|
|
1399
|
+
|
|
1400
|
+
if self._config.verbose:
|
|
1401
|
+
self._logger.info(f"Kernel file downloaded successfully: {path}")
|
|
1402
|
+
|
|
1403
|
+
except Exception as e:
|
|
1404
|
+
if os.path.exists(path):
|
|
1405
|
+
os.remove(path)
|
|
1406
|
+
raise VMMError(f"Failed to download kernel from {url}: {str(e)}")
|
|
1407
|
+
|
|
1408
|
+
@staticmethod
|
|
1409
|
+
def _convert_memory_size(size):
|
|
1410
|
+
"""Convert memory size to MiB.
|
|
1411
|
+
|
|
1412
|
+
Args:
|
|
1413
|
+
size: Memory size in format like '1G', '2G', or plain number (assumed to be MiB)
|
|
1414
|
+
|
|
1415
|
+
Returns:
|
|
1416
|
+
int: Memory size in MiB
|
|
1417
|
+
"""
|
|
1418
|
+
MIN_MEMORY = 128 # Minimum memory size in MiB
|
|
1419
|
+
|
|
1420
|
+
if isinstance(size, int):
|
|
1421
|
+
return max(size, MIN_MEMORY)
|
|
1422
|
+
|
|
1423
|
+
if isinstance(size, str):
|
|
1424
|
+
size = size.upper().strip()
|
|
1425
|
+
try:
|
|
1426
|
+
if size.endswith("G"):
|
|
1427
|
+
# Convert GB to MiB and ensure minimum
|
|
1428
|
+
mem_size = int(float(size[:-1]) * 1024)
|
|
1429
|
+
elif size.endswith("M"):
|
|
1430
|
+
# Already in MiB, just convert
|
|
1431
|
+
mem_size = int(float(size[:-1]))
|
|
1432
|
+
else:
|
|
1433
|
+
# If no unit specified, assume MiB
|
|
1434
|
+
mem_size = int(float(size))
|
|
1435
|
+
|
|
1436
|
+
return max(mem_size, MIN_MEMORY)
|
|
1437
|
+
except ValueError:
|
|
1438
|
+
raise ValueError(f"Invalid memory size format: {size}")
|
|
1439
|
+
raise ValueError(f"Invalid memory size type: {type(size)}")
|
|
1440
|
+
|
|
1441
|
+
def _is_valid_docker_image(self, name: str) -> bool:
|
|
1442
|
+
"""
|
|
1443
|
+
Check if a Docker image is valid by checking both local images and registry
|
|
1444
|
+
|
|
1445
|
+
Args:
|
|
1446
|
+
name (str): Docker image name (e.g., 'alpine', 'nginx:latest')
|
|
1447
|
+
|
|
1448
|
+
Returns:
|
|
1449
|
+
bool: True if image exists locally or in registry, False otherwise
|
|
1450
|
+
"""
|
|
1451
|
+
try:
|
|
1452
|
+
try:
|
|
1453
|
+
local_image = self._docker.images.get(name)
|
|
1454
|
+
if local_image:
|
|
1455
|
+
return True
|
|
1456
|
+
except docker.errors.ImageNotFound:
|
|
1457
|
+
pass
|
|
1458
|
+
|
|
1459
|
+
try:
|
|
1460
|
+
inspect = self._docker.api.inspect_distribution(name)
|
|
1461
|
+
if inspect:
|
|
1462
|
+
return True
|
|
1463
|
+
else:
|
|
1464
|
+
return False
|
|
1465
|
+
except Exception:
|
|
1466
|
+
return False
|
|
1467
|
+
|
|
1468
|
+
except Exception as e:
|
|
1469
|
+
raise VMMError(f"Failed to check if Docker image {name} is valid: {str(e)}")
|
|
1470
|
+
|
|
1471
|
+
def _download_docker(self, image: str) -> str:
|
|
1472
|
+
"""Download a Docker image and extract its root filesystem.
|
|
1473
|
+
|
|
1474
|
+
Args:
|
|
1475
|
+
image (str): Docker image name (e.g., 'ubuntu:24.04', 'alpine:latest')
|
|
1476
|
+
|
|
1477
|
+
Returns:
|
|
1478
|
+
str: Docker image tag or ID
|
|
1479
|
+
|
|
1480
|
+
Raises:
|
|
1481
|
+
VMMError: If Docker operations fail
|
|
1482
|
+
"""
|
|
1483
|
+
try:
|
|
1484
|
+
local = self._docker.images.get(image)
|
|
1485
|
+
if self._config.verbose:
|
|
1486
|
+
self._logger.info(f"Docker image {image} already exists")
|
|
1487
|
+
if local.tags:
|
|
1488
|
+
return local.tags[0]
|
|
1489
|
+
else:
|
|
1490
|
+
return local.id
|
|
1491
|
+
|
|
1492
|
+
except docker.errors.ImageNotFound:
|
|
1493
|
+
if self._config.verbose:
|
|
1494
|
+
self._logger.info(f"Pulling Docker image: {image}")
|
|
1495
|
+
|
|
1496
|
+
pulled = self._docker.images.pull(image)
|
|
1497
|
+
|
|
1498
|
+
if pulled.tags:
|
|
1499
|
+
return pulled.tags[0]
|
|
1500
|
+
else:
|
|
1501
|
+
raise VMMError(f"Failed to pull Docker image {image}")
|
|
1502
|
+
|
|
1503
|
+
except Exception as e:
|
|
1504
|
+
raise VMMError(f"Unexpected error: {e}")
|
|
1505
|
+
|
|
1506
|
+
def _export_docker_image(self, image: str) -> str:
|
|
1507
|
+
"""
|
|
1508
|
+
Export Docker image to a tar file
|
|
1509
|
+
|
|
1510
|
+
Args:
|
|
1511
|
+
image (str): Docker image name (e.g., 'alpine', 'ubuntu:20.04')
|
|
1512
|
+
|
|
1513
|
+
Returns:
|
|
1514
|
+
str: Path to the exported tar file
|
|
1515
|
+
"""
|
|
1516
|
+
container_name = image.split("/")[-1].replace(":", "-")
|
|
1517
|
+
tar_file = f"{self._config.data_path}/rootfs_{container_name}.tar"
|
|
1518
|
+
|
|
1519
|
+
try:
|
|
1520
|
+
if not image:
|
|
1521
|
+
raise VMMError(f"Failed to download Docker image {image}")
|
|
1522
|
+
|
|
1523
|
+
if self._config.verbose:
|
|
1524
|
+
self._logger.debug(f"Creating container: {container_name}")
|
|
1525
|
+
|
|
1526
|
+
container = self._docker.containers.create(image, name=container_name)
|
|
1527
|
+
export_data = container.export()
|
|
1528
|
+
|
|
1529
|
+
if self._config.verbose:
|
|
1530
|
+
self._logger.debug(f"Exporting container to {tar_file}")
|
|
1531
|
+
|
|
1532
|
+
with open(tar_file, "wb") as f:
|
|
1533
|
+
for chunk in export_data:
|
|
1534
|
+
f.write(chunk)
|
|
1535
|
+
|
|
1536
|
+
container.remove(force=True)
|
|
1537
|
+
|
|
1538
|
+
if self._config.verbose:
|
|
1539
|
+
self._logger.debug(f"Successfully exported container to {tar_file}")
|
|
1540
|
+
|
|
1541
|
+
return tar_file
|
|
1542
|
+
|
|
1543
|
+
except (docker.errors.ImageNotFound, docker.errors.APIError) as e:
|
|
1544
|
+
raise VMMError(f"Docker error: {e}")
|
|
1545
|
+
except Exception as e:
|
|
1546
|
+
raise VMMError(f"Unexpected error: {e}")
|
|
1547
|
+
|
|
1548
|
+
def _build_rootfs(self, image: str, file: str, size: str):
|
|
1549
|
+
"""Create a filesystem image from a tar file.
|
|
1550
|
+
|
|
1551
|
+
Args:
|
|
1552
|
+
image (str): Docker image name
|
|
1553
|
+
file (str): Path to the output image file
|
|
1554
|
+
size (str): Size of the image file
|
|
1555
|
+
|
|
1556
|
+
Returns:
|
|
1557
|
+
str: Path to the created image file
|
|
1558
|
+
"""
|
|
1559
|
+
tmp_dir = None
|
|
1560
|
+
try:
|
|
1561
|
+
self._download_docker(image)
|
|
1562
|
+
tar_file = self._export_docker_image(image)
|
|
1563
|
+
|
|
1564
|
+
if not tar_file or not os.path.exists(tar_file):
|
|
1565
|
+
return f"Failed to export Docker image {image}"
|
|
1566
|
+
|
|
1567
|
+
run(f"fallocate -l {size} {file}")
|
|
1568
|
+
if self._config.verbose:
|
|
1569
|
+
self._logger.debug(f"Image file created: {file}")
|
|
1570
|
+
|
|
1571
|
+
run(f"mkfs.ext4 {file}")
|
|
1572
|
+
if self._config.verbose:
|
|
1573
|
+
self._logger.debug(f"Formatting filesystem: {file} with size {size}")
|
|
1574
|
+
|
|
1575
|
+
run(f"e2fsck -f -y {file}")
|
|
1576
|
+
if self._config.verbose:
|
|
1577
|
+
self._logger.debug(f"Filesystem check completed for: {file}")
|
|
1578
|
+
|
|
1579
|
+
tmp_dir = tempfile.mkdtemp()
|
|
1580
|
+
run(f"mount -o loop {file} {tmp_dir}")
|
|
1581
|
+
|
|
1582
|
+
with tarfile.open(tar_file, "r") as tar:
|
|
1583
|
+
tar.extractall(path=tmp_dir)
|
|
1584
|
+
|
|
1585
|
+
os.remove(tar_file)
|
|
1586
|
+
if self._config.verbose:
|
|
1587
|
+
self._logger.debug(f"Removed tar file: {tar_file}")
|
|
1588
|
+
|
|
1589
|
+
run(f"umount {tmp_dir}")
|
|
1590
|
+
os.rmdir(tmp_dir)
|
|
1591
|
+
if self._config.verbose:
|
|
1592
|
+
self._logger.debug(
|
|
1593
|
+
f"Unmounted and removed temporary directory: {tmp_dir}"
|
|
1594
|
+
)
|
|
1595
|
+
|
|
1596
|
+
if self._config.verbose:
|
|
1597
|
+
self._logger.info("Build rootfs completed")
|
|
1598
|
+
|
|
1599
|
+
except Exception as e:
|
|
1600
|
+
if tmp_dir:
|
|
1601
|
+
run(f"umount {tmp_dir}", "unmounting")
|
|
1602
|
+
os.rmdir(tmp_dir)
|
|
1603
|
+
raise VMMError(f"Failed to create image file: {e}")
|
|
1604
|
+
|
|
1605
|
+
@retry(
|
|
1606
|
+
stop=stop_after_attempt(3),
|
|
1607
|
+
wait=wait_fixed(2),
|
|
1608
|
+
retry=retry_if_exception_type(SSHException),
|
|
1609
|
+
)
|
|
1610
|
+
def _establish_ssh_connection(
|
|
1611
|
+
self, ip_addr: str, username: str, key_path: str, id: str
|
|
1612
|
+
):
|
|
1613
|
+
"""Establish SSH connection to the VMM with retry logic.
|
|
1614
|
+
|
|
1615
|
+
Args:
|
|
1616
|
+
ip_addr (str): IP address of the VMM
|
|
1617
|
+
username (str): SSH username
|
|
1618
|
+
key_path (str): Path to SSH private key
|
|
1619
|
+
id (str): VMM ID for error messages
|
|
1620
|
+
|
|
1621
|
+
Raises:
|
|
1622
|
+
VMMError: If connection fails after all retry attempts
|
|
1623
|
+
"""
|
|
1624
|
+
self._ssh_client.set_missing_host_key_policy(AutoAddPolicy())
|
|
1625
|
+
self._ssh_client.connect(
|
|
1626
|
+
hostname=ip_addr,
|
|
1627
|
+
username=username if username else self._config.ssh_user,
|
|
1628
|
+
key_filename=key_path,
|
|
1629
|
+
)
|
|
1630
|
+
|
|
1631
|
+
def _setup_port_forwarding(
|
|
1632
|
+
self, host_ports, dest_ports, vmm_id=None, dest_ip=None, update_config=True
|
|
1633
|
+
):
|
|
1634
|
+
"""Helper method to set up port forwarding rules.
|
|
1635
|
+
|
|
1636
|
+
Args:
|
|
1637
|
+
host_ports: List of host ports or single port
|
|
1638
|
+
dest_ports: List of destination ports or single port
|
|
1639
|
+
vmm_id: VMM ID (uses self._microvm_id if None)
|
|
1640
|
+
dest_ip: Destination IP (uses self._ip_addr if None)
|
|
1641
|
+
update_config: Whether to update the config file
|
|
1642
|
+
|
|
1643
|
+
Returns:
|
|
1644
|
+
dict: Port configuration dictionary
|
|
1645
|
+
|
|
1646
|
+
Raises:
|
|
1647
|
+
ValueError: If port validation fails
|
|
1648
|
+
VMMError: If port forwarding setup fails
|
|
1649
|
+
"""
|
|
1650
|
+
vmm_id = vmm_id or self._microvm_id
|
|
1651
|
+
dest_ip = dest_ip or self._ip_addr
|
|
1652
|
+
|
|
1653
|
+
host_ports_list = [host_ports] if isinstance(host_ports, int) else host_ports
|
|
1654
|
+
dest_ports_list = [dest_ports] if isinstance(dest_ports, int) else dest_ports
|
|
1655
|
+
|
|
1656
|
+
if len(host_ports_list) != len(dest_ports_list):
|
|
1657
|
+
raise ValueError(
|
|
1658
|
+
"Number of host ports must match number of destination ports"
|
|
1659
|
+
)
|
|
1660
|
+
|
|
1661
|
+
ports_config = {}
|
|
1662
|
+
for host_port, dest_port in zip(host_ports_list, dest_ports_list):
|
|
1663
|
+
self._network.add_port_forward(
|
|
1664
|
+
vmm_id, self._host_ip, host_port, dest_ip, dest_port
|
|
1665
|
+
)
|
|
1666
|
+
|
|
1667
|
+
port_key = f"{dest_port}/tcp"
|
|
1668
|
+
if port_key not in ports_config:
|
|
1669
|
+
ports_config[port_key] = []
|
|
1670
|
+
|
|
1671
|
+
ports_config[port_key].append(
|
|
1672
|
+
{"HostPort": host_port, "DestPort": dest_port}
|
|
1673
|
+
)
|
|
1674
|
+
|
|
1675
|
+
if update_config:
|
|
1676
|
+
config_path = f"{self._config.data_path}/{vmm_id}/config.json"
|
|
1677
|
+
if os.path.exists(config_path):
|
|
1678
|
+
with open(config_path, "r") as f:
|
|
1679
|
+
config = json.load(f)
|
|
1680
|
+
|
|
1681
|
+
if "Ports" not in config:
|
|
1682
|
+
config["Ports"] = {}
|
|
1683
|
+
|
|
1684
|
+
config["Ports"].update(ports_config)
|
|
1685
|
+
|
|
1686
|
+
with open(config_path, "w") as f:
|
|
1687
|
+
json.dump(config, f)
|
|
1688
|
+
|
|
1689
|
+
if self._config.verbose:
|
|
1690
|
+
self._logger.debug(
|
|
1691
|
+
f"Added {host_port} -> {dest_port} to VMM {vmm_id}"
|
|
1692
|
+
)
|
|
1693
|
+
self._logger.info(
|
|
1694
|
+
f"Port forwarding added successfully for VMM {vmm_id}"
|
|
1695
|
+
)
|
|
1696
|
+
|
|
1697
|
+
return ports_config
|
|
1698
|
+
|
|
1699
|
+
def _remove_port_forwarding(
|
|
1700
|
+
self, host_ports, dest_ports, vmm_id=None, update_config=True
|
|
1701
|
+
):
|
|
1702
|
+
"""Helper method to remove port forwarding rules.
|
|
1703
|
+
|
|
1704
|
+
Args:
|
|
1705
|
+
host_ports: List of host ports or single port
|
|
1706
|
+
dest_ports: List of destination ports or single port
|
|
1707
|
+
vmm_id: VMM ID (uses self._microvm_id if None)
|
|
1708
|
+
update_config: Whether to update the config file
|
|
1709
|
+
|
|
1710
|
+
Returns:
|
|
1711
|
+
str: Status message
|
|
1712
|
+
"""
|
|
1713
|
+
vmm_id = vmm_id or self._microvm_id
|
|
1714
|
+
|
|
1715
|
+
host_ports_list = [host_ports] if isinstance(host_ports, int) else host_ports
|
|
1716
|
+
dest_ports_list = [dest_ports] if isinstance(dest_ports, int) else dest_ports
|
|
1717
|
+
|
|
1718
|
+
for host_port, dest_port in zip(host_ports_list, dest_ports_list):
|
|
1719
|
+
self._network.delete_port_forward(vmm_id, host_port, dest_port)
|
|
1720
|
+
if self._config.verbose:
|
|
1721
|
+
self._logger.debug(
|
|
1722
|
+
f"Removed {host_port} -> {dest_port} from VMM {vmm_id}"
|
|
1723
|
+
)
|
|
1724
|
+
|
|
1725
|
+
if update_config:
|
|
1726
|
+
config_path = f"{self._config.data_path}/{vmm_id}/config.json"
|
|
1727
|
+
if os.path.exists(config_path):
|
|
1728
|
+
with open(config_path, "r") as f:
|
|
1729
|
+
config = json.load(f)
|
|
1730
|
+
|
|
1731
|
+
for dest_port in dest_ports_list:
|
|
1732
|
+
config["Ports"].pop(f"{dest_port}/tcp", None)
|
|
1733
|
+
|
|
1734
|
+
with open(config_path, "w") as f:
|
|
1735
|
+
json.dump(config, f)
|
|
1736
|
+
|
|
1737
|
+
if self._config.verbose:
|
|
1738
|
+
self._logger.info(f"Port forwarding removed successfully for VMM {vmm_id}")
|