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