hysn-firecracker-python 1.0.5__tar.gz → 1.0.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. {hysn_firecracker_python-1.0.5/hysn_firecracker_python.egg-info → hysn_firecracker_python-1.0.7}/PKG-INFO +2 -2
  2. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/debian/control +1 -1
  3. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/debian/copyright +2 -2
  4. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/examples/Dockerfile +1 -1
  5. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/firecracker/_version.py +3 -3
  6. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/firecracker/microvm.py +34 -2
  7. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/firecracker/network.py +87 -39
  8. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/firecracker/utils.py +14 -1
  9. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7/hysn_firecracker_python.egg-info}/PKG-INFO +2 -2
  10. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/pyproject.toml +1 -1
  11. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/setup.py +1 -1
  12. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/tests/test_port_forward.py +67 -0
  13. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/tests/test_utils.py +48 -0
  14. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  15. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  16. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/.github/workflows/pypi-release.yml +0 -0
  17. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/.gitignore +0 -0
  18. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/AGENTS.md +0 -0
  19. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/Dockerfile.test +0 -0
  20. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/FIRECRACKER_SETUP.md +0 -0
  21. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/LICENSE +0 -0
  22. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/Makefile +0 -0
  23. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/QUICKSTART.md +0 -0
  24. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/README.md +0 -0
  25. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/TODO.md +0 -0
  26. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/assets/img/firecracker.png +0 -0
  27. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/assets/rootfs/setup-firecracker-official.sh +0 -0
  28. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/cleanup_firecracker.py +0 -0
  29. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/debian/compat +0 -0
  30. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/debian/rules +0 -0
  31. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/docker-compose.test.yml +0 -0
  32. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/docs/README.md +0 -0
  33. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/docs/api-reference.md +0 -0
  34. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/docs/cleanup.md +0 -0
  35. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/docs/configuration.md +0 -0
  36. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/docs/examples.md +0 -0
  37. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/docs/getting-started.md +0 -0
  38. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/docs/index.md +0 -0
  39. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/docs/network.md +0 -0
  40. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/docs/testing-in-docker.md +0 -0
  41. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/examples/configure_vm_network.py +0 -0
  42. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/examples/create_vm.py +0 -0
  43. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/examples/create_vm_with_filesystem_format.py +0 -0
  44. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/examples/id_rsa +0 -0
  45. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/examples/id_rsa.pub +0 -0
  46. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/examples/init +0 -0
  47. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/examples/load_snapshot.py +0 -0
  48. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/examples/sample.py +0 -0
  49. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/examples/setup.sh +0 -0
  50. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/firecracker/__init__.py +0 -0
  51. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/firecracker/api.py +0 -0
  52. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/firecracker/config.py +0 -0
  53. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/firecracker/exceptions.py +0 -0
  54. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/firecracker/logger.py +0 -0
  55. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/firecracker/process.py +0 -0
  56. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/firecracker/scripts.py +0 -0
  57. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/firecracker/vmm.py +0 -0
  58. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/hysn_firecracker_python.egg-info/SOURCES.txt +0 -0
  59. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/hysn_firecracker_python.egg-info/dependency_links.txt +0 -0
  60. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/hysn_firecracker_python.egg-info/entry_points.txt +0 -0
  61. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/hysn_firecracker_python.egg-info/requires.txt +0 -0
  62. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/hysn_firecracker_python.egg-info/top_level.txt +0 -0
  63. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/requirements.txt +0 -0
  64. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/scripts/cleanup_resources.py +0 -0
  65. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/scripts/run-tests-docker.sh +0 -0
  66. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/setup.cfg +0 -0
  67. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/tests/README.md +0 -0
  68. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/tests/conftest.py +0 -0
  69. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/tests/test_api_client.py +0 -0
  70. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/tests/test_cleanup.py +0 -0
  71. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/tests/test_docker_integration.py +0 -0
  72. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/tests/test_error_handling.py +0 -0
  73. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/tests/test_logger.py +0 -0
  74. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/tests/test_microvm.py +0 -0
  75. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/tests/test_microvm_error_paths.py +0 -0
  76. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/tests/test_microvm_initialization.py +0 -0
  77. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/tests/test_network_error_paths.py +0 -0
  78. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/tests/test_networking.py +0 -0
  79. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/tests/test_process_manager.py +0 -0
  80. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/tests/test_rootfs_format.py +0 -0
  81. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/tests/test_scripts.py +0 -0
  82. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/tests/test_snapshots.py +0 -0
  83. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/tests/test_vm_configuration.py +0 -0
  84. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/tests/test_vmm_manager.py +0 -0
  85. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/uv.lock +0 -0
  86. {hysn_firecracker_python-1.0.5 → hysn_firecracker_python-1.0.7}/verify-setup.py +0 -0
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hysn-firecracker-python
3
- Version: 1.0.5
3
+ Version: 1.0.7
4
4
  Summary: A Python client library to interact with Firecracker microVMs
5
- Author: Muhammad Yuga Nugraha
5
+ Author: PDSO Platform
6
6
  License: MIT License
7
7
 
8
8
  Copyright (c) 2024 Practical DevSecOps
@@ -1,7 +1,7 @@
1
1
  Source: firecracker-python
2
2
  Section: python
3
3
  Priority: optional
4
- Maintainer: Muhammad Yuga Nugraha <myugan59@gmail.com>
4
+ Maintainer: PDSO Platform
5
5
  Standards-Version: 4.6.2
6
6
  Build-Depends: debhelper (>= 11), dh-python, python3-all, python3-setuptools, curl, jq
7
7
 
@@ -1,10 +1,10 @@
1
1
  Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
2
2
  Upstream-Name: firecracker-python
3
- Upstream-Contact: Muhammad Yuga Nugraha <myugan59@gmail.com>
3
+ Upstream-Contact: PDSO Platform
4
4
  Source: https://github.com/myugan/firecracker-python
5
5
 
6
6
  Files: *
7
- Copyright: 2025 Muhammad Yuga Nugraha <myugan59@gmail.com>
7
+ Copyright: 2025 PDSO Platform
8
8
  License: MIT
9
9
 
10
10
  License: MIT
@@ -1,5 +1,5 @@
1
1
  FROM ubuntu:24.04
2
- LABEL maintainer="Muhammad Yuga Nugraha"
2
+ LABEL maintainer="PDSO Platform"
3
3
 
4
4
  COPY id_rsa.pub /root/.ssh/authorized_keys
5
5
  COPY init /usr/local/bin/init
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '1.0.5'
22
- __version_tuple__ = version_tuple = (1, 0, 5)
21
+ __version__ = version = '1.0.7'
22
+ __version_tuple__ = version_tuple = (1, 0, 7)
23
23
 
24
- __commit_id__ = commit_id = 'g01bf110fc'
24
+ __commit_id__ = commit_id = 'g3fc03cf51'
@@ -1541,6 +1541,8 @@ class MicroVM:
1541
1541
  container_name = image.split("/")[-1].replace(":", "-")
1542
1542
  tar_file = f"{self._config.data_path}/rootfs_{container_name}.tar"
1543
1543
 
1544
+ container = None
1545
+ merged_dir = None
1544
1546
  try:
1545
1547
  if not image:
1546
1548
  raise VMMError(f"Failed to download Docker image {image}")
@@ -1549,6 +1551,21 @@ class MicroVM:
1549
1551
  self._logger.debug(f"Creating container: {container_name}")
1550
1552
 
1551
1553
  container = self._docker.containers.create(image, name=container_name)
1554
+
1555
+ # container.export() makes the daemon mount the container's overlay
1556
+ # rootfs (overlay2/<id>/merged), and container.remove() does not
1557
+ # unmount it. The orphaned mount pins the image layers, so a later
1558
+ # `docker image prune` reclaims 0B and disk fills up over repeated
1559
+ # builds. Capture the merged path now (populated at create time) so
1560
+ # we can unmount it explicitly in the finally block below.
1561
+ try:
1562
+ graph = self._docker.api.inspect_container(
1563
+ container.id
1564
+ ).get("GraphDriver", {})
1565
+ merged_dir = graph.get("Data", {}).get("MergedDir")
1566
+ except Exception:
1567
+ merged_dir = None
1568
+
1552
1569
  export_data = container.export()
1553
1570
 
1554
1571
  if self._config.verbose:
@@ -1558,8 +1575,6 @@ class MicroVM:
1558
1575
  for chunk in export_data:
1559
1576
  f.write(chunk)
1560
1577
 
1561
- container.remove(force=True)
1562
-
1563
1578
  if self._config.verbose:
1564
1579
  self._logger.debug(f"Successfully exported container to {tar_file}")
1565
1580
 
@@ -1569,6 +1584,23 @@ class MicroVM:
1569
1584
  raise VMMError(f"Docker error: {e}")
1570
1585
  except Exception as e:
1571
1586
  raise VMMError(f"Unexpected error: {e}")
1587
+ finally:
1588
+ # Remove the container, then ensure its overlay rootfs is unmounted
1589
+ # so the image layers can actually be pruned/reclaimed afterwards.
1590
+ if container is not None:
1591
+ try:
1592
+ container.remove(force=True)
1593
+ except Exception as e:
1594
+ if self._config.verbose:
1595
+ self._logger.debug(
1596
+ f"Failed to remove container {container_name}: {e}"
1597
+ )
1598
+ if merged_dir and os.path.ismount(merged_dir):
1599
+ # Plain unmount first; lazy unmount if briefly busy after teardown.
1600
+ if run(f"umount {merged_dir}").returncode != 0:
1601
+ run(f"umount -l {merged_dir}")
1602
+ if self._config.verbose:
1603
+ self._logger.debug(f"Unmounted leaked overlay: {merged_dir}")
1572
1604
 
1573
1605
  def _build_rootfs(self, image: str, file: str, size: str, fs_format: str = "ext4"):
1574
1606
  """Create a filesystem image from a tar file.
@@ -497,6 +497,18 @@ class NetworkManager:
497
497
  has_correct_dnat = False
498
498
 
499
499
  for e in expr:
500
+ # Current form: `fib daddr type local` (no host-IP match).
501
+ if (
502
+ "match" in e
503
+ and e["match"]["op"] == "=="
504
+ and "fib" in e["match"].get("left", {})
505
+ and "daddr"
506
+ in e["match"]["left"]["fib"].get("flags", [])
507
+ and e["match"]["right"] == "local"
508
+ ):
509
+ has_daddr_match = True
510
+
511
+ # Legacy form: exact `ip daddr <host_ip>` payload match.
500
512
  if (
501
513
  "match" in e
502
514
  and e["match"]["op"] == "=="
@@ -684,6 +696,71 @@ class NetworkManager:
684
696
  )
685
697
  return False
686
698
 
699
+ def _build_prerouting_rule(
700
+ self,
701
+ id: str,
702
+ family: str,
703
+ host_port: int,
704
+ dest_ip: str,
705
+ dest_port: int,
706
+ protocol: str = "tcp",
707
+ ):
708
+ """Build the PREROUTING DNAT rule for a port forward.
709
+
710
+ The destination-address match uses ``fib daddr type local`` instead of a
711
+ single detected host IP. This matches any address the host owns, on every
712
+ provider, with zero host-IP detection -- fixing the ``ip daddr 0.0.0.0``
713
+ bug where a failed/empty public-IP lookup produced a rule that never fired.
714
+
715
+ Args:
716
+ id (str): Machine ID, used in the rule comment.
717
+ family (str): nftables family ("ip" or "ip6").
718
+ host_port (int): Host port to forward.
719
+ dest_ip (str): IP address to forward to.
720
+ dest_port (int): Port to forward to.
721
+ protocol (str): Protocol to forward (default: "tcp").
722
+
723
+ Returns:
724
+ dict: An nftables ``add`` rule object for the PREROUTING chain.
725
+ """
726
+ return {
727
+ "add": {
728
+ "rule": {
729
+ "family": family,
730
+ "table": "nat",
731
+ "chain": "PREROUTING",
732
+ "comment": f"machine_id={id} host_port={host_port} vm_port={dest_port}",
733
+ "expr": [
734
+ {
735
+ "match": {
736
+ "op": "==",
737
+ "left": {
738
+ "fib": {
739
+ "result": "type",
740
+ "flags": ["daddr"],
741
+ }
742
+ },
743
+ "right": "local",
744
+ }
745
+ },
746
+ {
747
+ "match": {
748
+ "op": "==",
749
+ "left": {
750
+ "payload": {
751
+ "protocol": protocol,
752
+ "field": "dport",
753
+ }
754
+ },
755
+ "right": host_port,
756
+ }
757
+ },
758
+ {"dnat": {"addr": dest_ip, "port": dest_port}},
759
+ ],
760
+ }
761
+ }
762
+ }
763
+
687
764
  def add_port_forward(
688
765
  self,
689
766
  id: str,
@@ -695,8 +772,13 @@ class NetworkManager:
695
772
  ):
696
773
  """Port forward a port to a new IP and port.
697
774
 
775
+ The PREROUTING rule matches ``fib daddr type local`` (any address the host
776
+ owns), so ``host_ip`` is used only to detect the IP family (IPv4 vs IPv6);
777
+ its value no longer appears in the rule match. This avoids the
778
+ ``ip daddr 0.0.0.0`` bug on hosts where public-IP detection fails.
779
+
698
780
  Args:
699
- host_ip (str): IP address to forward from.
781
+ host_ip (str): IP address used only to detect IPv4/IPv6 family.
700
782
  host_port (int): Port to forward.
701
783
  dest_ip (str): IP address to forward to.
702
784
  dest_port (int): Port to forward to.
@@ -767,45 +849,11 @@ class NetworkManager:
767
849
  }
768
850
  )
769
851
 
770
- # Add PREROUTING rule
852
+ # Add PREROUTING rule (fib daddr type local -- no host-IP detection)
771
853
  rules["nftables"].append(
772
- {
773
- "add": {
774
- "rule": {
775
- "family": family,
776
- "table": "nat",
777
- "chain": "PREROUTING",
778
- "comment": f"machine_id={id} host_port={host_port} vm_port={dest_port}",
779
- "expr": [
780
- {
781
- "match": {
782
- "op": "==",
783
- "left": {
784
- "payload": {
785
- "protocol": family,
786
- "field": "daddr",
787
- }
788
- },
789
- "right": host_ip,
790
- }
791
- },
792
- {
793
- "match": {
794
- "op": "==",
795
- "left": {
796
- "payload": {
797
- "protocol": protocol,
798
- "field": "dport",
799
- }
800
- },
801
- "right": host_port,
802
- }
803
- },
804
- {"dnat": {"addr": dest_ip, "port": dest_port}},
805
- ],
806
- }
807
- }
808
- }
854
+ self._build_prerouting_rule(
855
+ id, family, host_port, dest_ip, dest_port, protocol
856
+ )
809
857
  )
810
858
 
811
859
  # Only add POSTROUTING rule if it doesn't already exist
@@ -2,6 +2,7 @@ import os
2
2
  import random
3
3
  import string
4
4
  import signal
5
+ import ipaddress
5
6
  import requests
6
7
  import subprocess
7
8
  import socket
@@ -185,8 +186,20 @@ def get_public_ip(timeout: int = 5):
185
186
 
186
187
  for url in URLS:
187
188
  try:
188
- return _try_get_ip_from_url(url, timeout)
189
+ ip = _try_get_ip_from_url(url, timeout)
189
190
  except requests.RequestException:
190
191
  continue
191
192
 
193
+ # Reject empty/garbage and unspecified (0.0.0.0 / ::) responses so a bad
194
+ # service can never silently produce a dead "daddr 0.0.0.0" forward rule.
195
+ try:
196
+ ip_obj = ipaddress.ip_address(ip)
197
+ except ValueError:
198
+ continue
199
+
200
+ if ip_obj.is_unspecified:
201
+ continue
202
+
203
+ return str(ip_obj)
204
+
192
205
  raise RuntimeError("Failed to get public IP")
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hysn-firecracker-python
3
- Version: 1.0.5
3
+ Version: 1.0.7
4
4
  Summary: A Python client library to interact with Firecracker microVMs
5
- Author: Muhammad Yuga Nugraha
5
+ Author: PDSO Platform
6
6
  License: MIT License
7
7
 
8
8
  Copyright (c) 2024 Practical DevSecOps
@@ -7,7 +7,7 @@ name = "hysn-firecracker-python"
7
7
  dynamic = ["version"]
8
8
  description = "A Python client library to interact with Firecracker microVMs"
9
9
  authors = [
10
- {name = "Muhammad Yuga Nugraha"}
10
+ {name = "PDSO Platform"}
11
11
  ]
12
12
  requires-python = ">=3.9"
13
13
  dependencies = [
@@ -4,7 +4,7 @@ from setuptools import setup
4
4
  setup(
5
5
  name="hysn-firecracker-python",
6
6
  description="A Python client library to interact with Firecracker microVMs",
7
- author="Muhammad Yuga Nugraha",
7
+ author="PDSO Platform",
8
8
  packages=find_packages(),
9
9
  install_requires=[
10
10
  "requests==2.32.3",
@@ -2,10 +2,77 @@
2
2
 
3
3
  import json
4
4
  import os
5
+ from unittest.mock import patch
5
6
 
6
7
  import pytest
7
8
 
8
9
  from firecracker import MicroVM
10
+ from firecracker.network import NetworkManager
11
+
12
+
13
+ class TestPreroutingRuleBuild:
14
+ """Unit tests for the PREROUTING DNAT rule builder (no nftables/KVM needed).
15
+
16
+ Guards the `ip daddr 0.0.0.0` regression: the rule must match
17
+ `fib daddr type local`, never an exact host-IP daddr payload.
18
+ """
19
+
20
+ def _build(self, **kwargs):
21
+ with patch("firecracker.network.IPRoute"):
22
+ nm = NetworkManager()
23
+ defaults = dict(
24
+ id="9fngkuf7",
25
+ family="ip",
26
+ host_port=37699,
27
+ dest_ip="172.115.48.209",
28
+ dest_port=22,
29
+ protocol="tcp",
30
+ )
31
+ defaults.update(kwargs)
32
+ return nm._build_prerouting_rule(**defaults)
33
+
34
+ def test_uses_fib_daddr_type_local(self):
35
+ """Rule matches any local address via fib, not a single host IP."""
36
+ expr = self._build()["add"]["rule"]["expr"]
37
+ fib_matches = [
38
+ e
39
+ for e in expr
40
+ if "match" in e and "fib" in e["match"].get("left", {})
41
+ ]
42
+ assert len(fib_matches) == 1
43
+ m = fib_matches[0]["match"]
44
+ assert m["op"] == "=="
45
+ assert m["left"]["fib"]["result"] == "type"
46
+ assert "daddr" in m["left"]["fib"]["flags"]
47
+ assert m["right"] == "local"
48
+
49
+ def test_no_exact_daddr_payload_match(self):
50
+ """Regression: no `ip daddr <addr>` payload match remains in the rule."""
51
+ expr = self._build()["add"]["rule"]["expr"]
52
+ for e in expr:
53
+ left = e.get("match", {}).get("left", {})
54
+ if "payload" in left:
55
+ assert left["payload"]["field"] != "daddr"
56
+
57
+ def test_dport_and_dnat_preserved(self):
58
+ """dport match and dnat target unchanged."""
59
+ expr = self._build()["add"]["rule"]["expr"]
60
+ dport = next(
61
+ e["match"]
62
+ for e in expr
63
+ if "match" in e
64
+ and e["match"]["left"].get("payload", {}).get("field") == "dport"
65
+ )
66
+ assert dport["right"] == 37699
67
+ dnat = next(e["dnat"] for e in expr if "dnat" in e)
68
+ assert dnat == {"addr": "172.115.48.209", "port": 22}
69
+
70
+ def test_comment_format_unchanged(self):
71
+ """Comment key is unchanged -- deletion/idempotency paths depend on it."""
72
+ rule = self._build()["add"]["rule"]
73
+ assert rule["comment"] == "machine_id=9fngkuf7 host_port=37699 vm_port=22"
74
+ assert rule["chain"] == "PREROUTING"
75
+ assert rule["table"] == "nat"
9
76
 
10
77
  KERNEL_FILE = "/var/lib/firecracker/vmlinux-6.1.159"
11
78
  BASE_ROOTFS = "/var/lib/firecracker/devsecops-box.img"
@@ -167,3 +167,51 @@ class TestUtilsFunctions:
167
167
 
168
168
  with pytest.raises(RuntimeError, match="VMM ID required"):
169
169
  test_func(None)
170
+
171
+
172
+ class TestGetPublicIP:
173
+ """Tests for get_public_ip validation (never yield 0.0.0.0/empty)."""
174
+
175
+ def test_returns_valid_ip(self):
176
+ """A valid IP from the first service is returned."""
177
+ from unittest.mock import patch
178
+ from firecracker.utils import get_public_ip
179
+
180
+ with patch(
181
+ "firecracker.utils._try_get_ip_from_url", return_value="138.201.226.62"
182
+ ):
183
+ assert get_public_ip() == "138.201.226.62"
184
+
185
+ def test_skips_unspecified_then_returns_valid(self):
186
+ """0.0.0.0 from one service is skipped; next valid IP wins."""
187
+ from unittest.mock import patch
188
+ from firecracker.utils import get_public_ip
189
+
190
+ with patch(
191
+ "firecracker.utils._try_get_ip_from_url",
192
+ side_effect=["0.0.0.0", "203.0.113.5", "203.0.113.9"],
193
+ ):
194
+ assert get_public_ip() == "203.0.113.5"
195
+
196
+ def test_skips_empty_and_garbage(self):
197
+ """Empty/garbage responses are skipped, not returned."""
198
+ from unittest.mock import patch
199
+ from firecracker.utils import get_public_ip
200
+
201
+ with patch(
202
+ "firecracker.utils._try_get_ip_from_url",
203
+ side_effect=["", "not-an-ip", "203.0.113.7"],
204
+ ):
205
+ assert get_public_ip() == "203.0.113.7"
206
+
207
+ def test_raises_when_all_unspecified_or_invalid(self):
208
+ """All services returning 0.0.0.0/garbage -> RuntimeError, never 0.0.0.0."""
209
+ from unittest.mock import patch
210
+ from firecracker.utils import get_public_ip
211
+
212
+ with patch(
213
+ "firecracker.utils._try_get_ip_from_url",
214
+ side_effect=["0.0.0.0", "", "::"],
215
+ ):
216
+ with pytest.raises(RuntimeError, match="Failed to get public IP"):
217
+ get_public_ip()