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/process.py ADDED
@@ -0,0 +1,438 @@
1
+ import os
2
+ import time
3
+ import psutil
4
+ import subprocess
5
+ from datetime import datetime
6
+ from firecracker.logger import Logger
7
+ from firecracker.config import MicroVMConfig
8
+ from firecracker.exceptions import ProcessError
9
+ from tenacity import (
10
+ retry,
11
+ stop_after_delay,
12
+ wait_fixed,
13
+ retry_if_exception_type,
14
+ stop_after_attempt,
15
+ )
16
+
17
+
18
+ class ProcessManager:
19
+ """Manages process-related operations for Firecracker microVMs."""
20
+
21
+ def __init__(self, verbose: bool = False, level: str = "INFO"):
22
+ self._logger = Logger(level=level, verbose=verbose)
23
+ self._config = MicroVMConfig()
24
+ self._config.verbose = verbose
25
+
26
+ def start(self, id: str, args: list) -> int:
27
+ """Start a Firecracker process.
28
+
29
+ Args:
30
+ id (str): The ID of the Firecracker VM
31
+ args (list): List of command arguments
32
+
33
+ Returns:
34
+ int: Process ID if successful
35
+
36
+ Raises:
37
+ ProcessError: If process fails to start or becomes defunct
38
+ """
39
+ try:
40
+ cmd = [self._config.binary_path] + args
41
+
42
+ log_path = f"{self._config.data_path}/{id}/firecracker.log"
43
+ pid_path = f"{self._config.data_path}/{id}/firecracker.pid"
44
+
45
+ # Get parent's process group
46
+ parent_pgid = os.getpgid(0)
47
+
48
+ process = subprocess.Popen(
49
+ cmd,
50
+ stdout=open(log_path, "w"),
51
+ stderr=subprocess.DEVNULL,
52
+ stdin=subprocess.DEVNULL,
53
+ # Explicitly set to parent's group
54
+ preexec_fn=lambda: os.setpgid(0, parent_pgid),
55
+ )
56
+
57
+ time.sleep(0.5)
58
+
59
+ if process.poll() is not None:
60
+ raise ProcessError("Firecracker process exited during startup")
61
+
62
+ proc = psutil.Process(process.pid)
63
+ if proc.status() == psutil.STATUS_ZOMBIE:
64
+ raise ProcessError("Firecracker process became defunct")
65
+
66
+ try:
67
+ proc.wait(timeout=1)
68
+ except psutil.TimeoutExpired:
69
+ pass
70
+ except psutil.NoSuchProcess:
71
+ raise ProcessError("Firecracker process disappeared during startup")
72
+
73
+ with open(pid_path, "w") as f:
74
+ f.write(str(process.pid))
75
+
76
+ if self._logger.verbose:
77
+ self._logger.debug(
78
+ f"Firecracker process started with PID: {process.pid}"
79
+ )
80
+
81
+ return process.pid
82
+
83
+ except Exception as e:
84
+ raise ProcessError(f"Failed to start Firecracker: {str(e)}")
85
+
86
+ @retry(
87
+ stop=stop_after_delay(3),
88
+ wait=wait_fixed(0.5),
89
+ retry=retry_if_exception_type(ProcessError),
90
+ )
91
+ def is_running(self, id: str) -> bool:
92
+ """Check if Firecracker is running."""
93
+ try:
94
+ if os.path.exists(f"{self._config.data_path}/{id}/firecracker.pid"):
95
+ with open(f"{self._config.data_path}/{id}/firecracker.pid", "r") as f:
96
+ pid = int(f.read().strip())
97
+
98
+ try:
99
+ os.kill(pid, 0)
100
+ if self._logger.verbose:
101
+ self._logger.debug(f"Firecracker is running with PID: {pid}")
102
+ return True
103
+ except OSError:
104
+ if self._logger.verbose:
105
+ self._logger.info("Firecracker is not running (stale PID file)")
106
+ os.remove(f"{self._config.data_path}/{id}/firecracker.pid")
107
+ return False
108
+ else:
109
+ if self._logger.verbose:
110
+ self._logger.info("Firecracker is not running")
111
+ return False
112
+
113
+ except Exception as e:
114
+ if self._logger.verbose:
115
+ self._logger.error(f"Error checking status: {e}")
116
+ return False
117
+
118
+ @retry(
119
+ stop=stop_after_attempt(5),
120
+ wait=wait_fixed(1),
121
+ retry=retry_if_exception_type((ProcessError, OSError)),
122
+ )
123
+ def stop(self, id: str) -> bool:
124
+ """Stop Firecracker with retry mechanism.
125
+
126
+ Args:
127
+ id (str): The ID of the Firecracker VM
128
+
129
+ Returns:
130
+ bool: True if successfully stopped, False otherwise
131
+
132
+ Raises:
133
+ ProcessError: If process fails to stop after retries
134
+ """
135
+ try:
136
+ if os.path.exists(f"{self._config.data_path}/{id}/firecracker.pid"):
137
+ with open(f"{self._config.data_path}/{id}/firecracker.pid", "r") as f:
138
+ original_pid = int(f.read().strip())
139
+
140
+ try:
141
+ # Try to stop using the PID from file
142
+ if self._try_stop_process(original_pid, id):
143
+ self._cleanup_files(id)
144
+ return True
145
+ except ProcessError as e:
146
+ if self._logger.verbose:
147
+ self._logger.warn(
148
+ f"Failed to stop Firecracker (1st attempt): {e}"
149
+ )
150
+
151
+ # If PID-based stop failed, search for actual running process
152
+ if self._logger.verbose:
153
+ self._logger.info(
154
+ f"PID {original_pid} not found, searching for running Firecracker process for VM {id}"
155
+ )
156
+
157
+ actual_pid = self._find_running_process(id)
158
+ if actual_pid:
159
+ if self._logger.verbose:
160
+ self._logger.info(
161
+ f"Found running Firecracker process {actual_pid} for VM {id}"
162
+ )
163
+ if self._try_stop_process(actual_pid, id):
164
+ self._cleanup_files(id)
165
+ return True
166
+ else:
167
+ if self._logger.verbose:
168
+ self._logger.info(
169
+ f"No running Firecracker process found for VM {id}"
170
+ )
171
+
172
+ # Clean up files even if no process found
173
+ self._cleanup_files(id)
174
+ return True
175
+ else:
176
+ # Clean up files even if no process found
177
+ self._cleanup_files(id)
178
+
179
+ if self._logger.verbose:
180
+ self._logger.info("Firecracker is not running (no PID file)")
181
+ return False
182
+
183
+ except Exception as e:
184
+ if self._logger.verbose:
185
+ self._logger.error(f"Error stopping Firecracker: {e}")
186
+ raise ProcessError(f"Failed to stop Firecracker {id}: {str(e)}")
187
+
188
+ def _try_stop_process(self, pid: int, id: str) -> bool:
189
+ """Try to stop a specific process by PID.
190
+
191
+ Args:
192
+ pid (int): Process ID to stop
193
+ id (str): VM ID for logging
194
+
195
+ Returns:
196
+ bool: True if process was successfully stopped
197
+
198
+ Raises:
199
+ ProcessError: If process fails to stop
200
+ """
201
+
202
+ def wait_for_process_death(pid: int, timeout: float = 5.0) -> bool:
203
+ """Wait for process to die with timeout."""
204
+ start_time = time.time()
205
+ while time.time() - start_time < timeout:
206
+ try:
207
+ os.kill(pid, 0) # Check if process exists
208
+ time.sleep(1) # Wait 1 second before next check
209
+ except OSError as e:
210
+ if e.errno == 3: # ESRCH - No such process
211
+ return True # Process is dead
212
+ else:
213
+ raise ProcessError(f"Error checking process {pid}: {e}")
214
+ return False # Timeout reached
215
+
216
+ try:
217
+ # First check if process exists
218
+ os.kill(pid, 0)
219
+ except OSError as e:
220
+ if e.errno == 3: # ESRCH - No such process
221
+ if self._logger.verbose:
222
+ self._logger.info(f"Firecracker process {pid} already terminated")
223
+ return True
224
+ else:
225
+ raise ProcessError(f"Failed to check process {pid}: {e}")
226
+
227
+ # Process exists, try graceful shutdown first
228
+ try:
229
+ os.kill(pid, 15) # SIGTERM
230
+ if self._logger.verbose:
231
+ self._logger.debug(
232
+ f"Sent SIGTERM to process {pid}, waiting for termination..."
233
+ )
234
+
235
+ # Wait for process to die after SIGTERM
236
+ if wait_for_process_death(pid, timeout=2):
237
+ if self._logger.verbose:
238
+ self._logger.info(
239
+ f"Firecracker process {pid} terminated after SIGTERM"
240
+ )
241
+ return True
242
+ else:
243
+ if self._logger.verbose:
244
+ self._logger.warn(
245
+ f"Process {pid} did not terminate after SIGTERM, using SIGKILL"
246
+ )
247
+
248
+ except OSError as e:
249
+ if e.errno == 3: # ESRCH - No such process
250
+ if self._logger.verbose:
251
+ self._logger.info(
252
+ f"Firecracker process {pid} terminated after SIGTERM"
253
+ )
254
+ return True
255
+ else:
256
+ raise ProcessError(f"Failed to send SIGTERM to process {pid}: {e}")
257
+
258
+ # Process still running after SIGTERM, try force kill
259
+ try:
260
+ os.kill(pid, 9) # SIGKILL
261
+ if self._logger.verbose:
262
+ self._logger.debug(
263
+ f"Sent SIGKILL to process {pid}, waiting for termination..."
264
+ )
265
+
266
+ # Wait for process to die after SIGKILL
267
+ if wait_for_process_death(pid, timeout=5):
268
+ if self._logger.verbose:
269
+ self._logger.info(
270
+ f"Firecracker process {pid} force killed with SIGKILL"
271
+ )
272
+ return True
273
+ else:
274
+ raise ProcessError(
275
+ f"Firecracker process {pid} still running after SIGKILL timeout"
276
+ )
277
+
278
+ except OSError as e:
279
+ if e.errno == 3: # ESRCH - No such process
280
+ if self._logger.verbose:
281
+ self._logger.info(
282
+ f"Firecracker process {pid} terminated after SIGKILL"
283
+ )
284
+ return True
285
+ else:
286
+ raise ProcessError(f"Failed to kill process {pid}: {e}")
287
+
288
+ def _find_running_process(self, id: str) -> int:
289
+ """Find the actual running Firecracker process for a given VM ID.
290
+
291
+ Args:
292
+ id (str): The VM ID to search for
293
+
294
+ Returns:
295
+ int: Process ID if found, None otherwise
296
+ """
297
+ try:
298
+ socket_path = f"{self._config.data_path}/{id}/firecracker.socket"
299
+
300
+ for pid in psutil.pids():
301
+ try:
302
+ proc = psutil.Process(pid)
303
+ if proc.name() == "firecracker":
304
+ cmdline = proc.cmdline()
305
+ if cmdline and len(cmdline) > 1:
306
+ # Check if this process uses the same socket path
307
+ for i, arg in enumerate(cmdline):
308
+ if arg == "--api-sock" and i + 1 < len(cmdline):
309
+ if cmdline[i + 1] == socket_path:
310
+ return pid
311
+ except (
312
+ psutil.NoSuchProcess,
313
+ psutil.AccessDenied,
314
+ psutil.ZombieProcess,
315
+ ):
316
+ continue
317
+
318
+ except Exception as e:
319
+ if self._logger.verbose:
320
+ self._logger.warn(f"Error searching for running process: {e}")
321
+
322
+ return None
323
+
324
+ def _cleanup_files(self, id: str):
325
+ """Clean up PID and socket files for a VM.
326
+
327
+ Args:
328
+ id (str): The VM ID
329
+ """
330
+ # Clean up PID file
331
+ pid_file = f"{self._config.data_path}/{id}/firecracker.pid"
332
+ if os.path.exists(pid_file):
333
+ try:
334
+ os.remove(pid_file)
335
+ if self._logger.verbose:
336
+ self._logger.debug(f"Removed PID file for VM {id}")
337
+ except OSError as e:
338
+ if self._logger.verbose:
339
+ self._logger.warn(f"Failed to remove PID file: {e}")
340
+
341
+ # Clean up socket file
342
+ socket_path = f"{self._config.data_path}/{id}/firecracker.socket"
343
+ if os.path.exists(socket_path):
344
+ try:
345
+ os.remove(socket_path)
346
+ if self._logger.verbose:
347
+ self._logger.debug(f"Removed socket file: {socket_path}")
348
+ except OSError as e:
349
+ if self._logger.verbose:
350
+ self._logger.warn(f"Failed to remove socket file: {e}")
351
+
352
+ def get_pid(self, id: str) -> tuple:
353
+ """Get the PID of the Firecracker process.
354
+
355
+ Args:
356
+ id (str): The ID of the Firecracker VM
357
+
358
+ Returns:
359
+ tuple: (pid, create_time) if process is found and running
360
+
361
+ Raises:
362
+ ProcessError: If the process is not found or not running
363
+ """
364
+ try:
365
+ pid_file = f"{self._config.data_path}/{id}/firecracker.pid"
366
+ if not os.path.exists(pid_file):
367
+ raise ProcessError(f"No PID file found for VM {id}")
368
+
369
+ with open(pid_file, "r") as f:
370
+ pid = int(f.read().strip())
371
+
372
+ try:
373
+ process = psutil.Process(pid)
374
+ if not process.is_running():
375
+ os.remove(pid_file)
376
+ raise ProcessError(f"Firecracker process {pid} is not running")
377
+
378
+ if process.name() != "firecracker":
379
+ os.remove(pid_file)
380
+ raise ProcessError(f"Process {pid} is not a Firecracker process")
381
+
382
+ create_time = datetime.fromtimestamp(process.create_time()).strftime(
383
+ "%Y-%m-%d %H:%M:%S"
384
+ )
385
+
386
+ if self._logger.verbose:
387
+ self._logger.debug(
388
+ f"Found Firecracker process {pid} created at {create_time}"
389
+ )
390
+
391
+ return pid, create_time
392
+
393
+ except psutil.NoSuchProcess:
394
+ os.remove(pid_file)
395
+ raise ProcessError(f"Firecracker process {pid} is not running")
396
+
397
+ except psutil.AccessDenied:
398
+ raise ProcessError(f"Access denied to process {pid}")
399
+
400
+ except psutil.TimeoutExpired:
401
+ raise ProcessError(f"Timeout while checking process {pid}")
402
+
403
+ except Exception as e:
404
+ raise ProcessError(f"Failed to get Firecracker PID: {str(e)}")
405
+
406
+ def get_pids(self) -> list:
407
+ """
408
+ Get all PIDs of the Firecracker processes that have --api-sock parameter.
409
+
410
+ Returns:
411
+ list: List of process IDs (integers)
412
+ """
413
+ pid_list = []
414
+
415
+ try:
416
+ for pid in psutil.pids():
417
+ try:
418
+ proc = psutil.Process(pid)
419
+ if proc.name() == "firecracker":
420
+ cmdline = proc.cmdline()
421
+ if cmdline and len(cmdline) > 1 and "--api-sock" in cmdline:
422
+ pid_list.append(pid)
423
+ except (
424
+ psutil.NoSuchProcess,
425
+ psutil.AccessDenied,
426
+ psutil.ZombieProcess,
427
+ ):
428
+ continue
429
+
430
+ except Exception as e:
431
+ raise ProcessError(f"Failed to get Firecracker processes: {str(e)}")
432
+
433
+ return pid_list
434
+
435
+ @staticmethod
436
+ def wait_process_running(process: psutil.Process):
437
+ """Wait for a process to run."""
438
+ assert process.is_running()
firecracker/scripts.py ADDED
@@ -0,0 +1,53 @@
1
+ import os
2
+ from .config import MicroVMConfig
3
+ from .exceptions import ConfigurationError
4
+
5
+
6
+ def check_firecracker_binary():
7
+ """Check if Firecracker binary exists and is executable.
8
+
9
+ Raises:
10
+ ConfigurationError: If binary is not found or not executable
11
+ """
12
+ try:
13
+ config = MicroVMConfig()
14
+ binary_path = config.binary_path
15
+
16
+ if not os.path.exists(binary_path):
17
+ raise ConfigurationError(f"Firecracker binary not found, please install Firecracker")
18
+
19
+ if not os.access(binary_path, os.X_OK):
20
+ raise ConfigurationError(f"Firecracker binary is not executable at: {binary_path}")
21
+
22
+ except Exception as e:
23
+ raise ConfigurationError(f"Failed to check Firecracker binary: {str(e)}") from e
24
+
25
+ def create_firecracker_directory():
26
+ """Create the Firecracker data directory if it doesn't exist.
27
+
28
+ Raises:
29
+ ConfigurationError: If directory creation fails
30
+ """
31
+ try:
32
+ config = MicroVMConfig()
33
+ data_path = config.data_path
34
+
35
+ if not os.path.exists(data_path):
36
+ os.makedirs(data_path, mode=0o755)
37
+ print(f"Created Firecracker data directory at: {data_path}")
38
+
39
+ snapshot_path = config.snapshot_path
40
+ if not os.path.exists(snapshot_path):
41
+ os.makedirs(snapshot_path, mode=0o755)
42
+ print(f"Created Firecracker snapshot directory at: {snapshot_path}")
43
+
44
+ except Exception as e:
45
+ raise ConfigurationError(f"Failed to create Firecracker data directory: {str(e)}") from e
46
+
47
+
48
+ if __name__ == "__main__":
49
+ if os.geteuid() != 0:
50
+ raise SystemExit("This script must be run as root.")
51
+
52
+ check_firecracker_binary()
53
+ create_firecracker_directory()
firecracker/utils.py ADDED
@@ -0,0 +1,192 @@
1
+ import os
2
+ import random
3
+ import string
4
+ import signal
5
+ import requests
6
+ import subprocess
7
+ import socket
8
+ from faker import Faker
9
+ from tenacity import retry, stop_after_attempt, wait_fixed, retry_if_exception_type
10
+
11
+
12
+ def run(cmd, **kwargs):
13
+ """Execute a shell command with configurable options.
14
+
15
+ Provides a convenient wrapper around subprocess.run with sensible defaults
16
+ for shell command execution.
17
+
18
+ Args:
19
+ cmd (str): Shell command to execute
20
+ **kwargs: Additional arguments to pass to subprocess.run
21
+ Defaults if not specified:
22
+ - shell=True: Enable shell command interpretation
23
+ - capture_output=True: Capture stdout/stderr
24
+ - text=True: Return string output instead of bytes
25
+
26
+ Returns:
27
+ subprocess.CompletedProcess: Process completion information including:
28
+ - returncode: Exit code of the command
29
+ - stdout: Standard output (if captured)
30
+ - stderr: Standard error (if captured)
31
+
32
+ Note:
33
+ Default behavior can be overridden by passing explicit kwargs
34
+ """
35
+ default_kwargs = {"shell": True, "capture_output": True, "text": True}
36
+ default_kwargs.update(kwargs)
37
+
38
+ return subprocess.run(cmd, **default_kwargs)
39
+
40
+
41
+ def safe_kill(pid: int, sig: signal.Signals = signal.SIGTERM) -> bool:
42
+ """Safely kill a process."""
43
+ try:
44
+ os.kill(pid, sig)
45
+ return True
46
+ except ProcessLookupError:
47
+ return True
48
+ except PermissionError:
49
+ return False
50
+
51
+
52
+ def generate_id() -> str:
53
+ """Generate a random ID for the MicroVM instance.
54
+
55
+ Returns:
56
+ str: A random identifier (exactly 8 lowercase alphanumeric characters)
57
+ """
58
+ chars = string.ascii_lowercase + string.digits
59
+ generated_id = "".join(random.choice(chars) for _ in range(8))
60
+ return generated_id
61
+
62
+
63
+ def generate_name() -> str:
64
+ """Generate a random name for the MicroVM instance using Faker.
65
+
66
+ Returns:
67
+ str: A random name
68
+ """
69
+ fake = Faker()
70
+ generated_name = fake.name().replace(" ", "_").lower()
71
+ return generated_name
72
+
73
+
74
+ def generate_mac_address() -> str:
75
+ """Generate a random MAC address for the MicroVM instance.
76
+
77
+ Returns:
78
+ str: A random MAC address in the format XX:XX:XX:XX:XX:XX
79
+ """
80
+ # Generate 6 random bytes
81
+ mac_bytes = [random.randint(0, 255) for _ in range(6)]
82
+ # Convert to hex and format with colons
83
+ mac_address = ":".join([f"{b:02x}" for b in mac_bytes])
84
+ return mac_address
85
+
86
+
87
+ def requires_id(func):
88
+ """Decorator to check if VMM ID is provided."""
89
+
90
+ def wrapper(*args, **kwargs):
91
+ id = kwargs.get("id") or (len(args) > 1 and args[1])
92
+ if not id:
93
+ raise RuntimeError("VMM ID required")
94
+ return func(*args, **kwargs)
95
+
96
+ return wrapper
97
+
98
+
99
+ def validate_hostname(hostname):
100
+ """Validate hostname according to RFC 1123."""
101
+ import re
102
+
103
+ if not re.match(
104
+ r"^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$",
105
+ hostname,
106
+ ):
107
+ raise ValueError(f"Invalid hostname: {hostname}")
108
+
109
+
110
+ def validate_ip_address(ip_addr: str) -> bool:
111
+ """Validate an IP address according to standard format rules.
112
+
113
+ Args:
114
+ ip_addr (str): The IP address to validate
115
+
116
+ Returns:
117
+ bool: True if the IP address is valid
118
+
119
+ Raises:
120
+ Exception: If the IP address is invalid, with a descriptive message
121
+ """
122
+ if not ip_addr:
123
+ raise Exception("IP address cannot be empty")
124
+
125
+ try:
126
+ # Test IP address format
127
+ socket.inet_aton(ip_addr)
128
+
129
+ # Check if the IP has exactly 4 parts
130
+ ip_parts = ip_addr.split(".")
131
+ if len(ip_parts) != 4:
132
+ raise Exception(f"Invalid IP address format: {ip_addr}")
133
+
134
+ # Check if any part is outside the valid range
135
+ for part in ip_parts:
136
+ if not (0 <= int(part) <= 255):
137
+ raise Exception(f"IP address contains invalid octet: {part}")
138
+
139
+ # Check if it's a reserved address (like .0 ending)
140
+ if ip_parts[-1] == "0":
141
+ raise Exception(f"IP address with .0 suffix is reserved: {ip_addr}")
142
+
143
+ return True
144
+
145
+ except (socket.error, ValueError):
146
+ raise Exception(f"Invalid IP address: {ip_addr}")
147
+
148
+
149
+ @retry(
150
+ stop=stop_after_attempt(3),
151
+ wait=wait_fixed(1),
152
+ retry=retry_if_exception_type(requests.RequestException),
153
+ )
154
+ def _try_get_ip_from_url(url: str, timeout: int = 5) -> str:
155
+ """Try to get public IP from a specific URL with retry logic.
156
+
157
+ Args:
158
+ url (str): URL to try for getting public IP
159
+ timeout (int): Request timeout in seconds
160
+
161
+ Returns:
162
+ str: Public IP address
163
+
164
+ Raises:
165
+ requests.RequestException: If all retry attempts fail
166
+ """
167
+ response = requests.get(url, timeout=timeout)
168
+ response.raise_for_status()
169
+ return response.text.strip()
170
+
171
+
172
+ def get_public_ip(timeout: int = 5):
173
+ """Get the public IP address by trying multiple services.
174
+
175
+ Args:
176
+ timeout (int): Request timeout in seconds
177
+
178
+ Returns:
179
+ str: Public IP address
180
+
181
+ Raises:
182
+ RuntimeError: If all services fail to return a valid IP
183
+ """
184
+ URLS = ["https://ifconfig.me", "https://ipinfo.io/ip", "https://api.ipify.org"]
185
+
186
+ for url in URLS:
187
+ try:
188
+ return _try_get_ip_from_url(url, timeout)
189
+ except requests.RequestException:
190
+ continue
191
+
192
+ raise RuntimeError("Failed to get public IP")