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/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}")