Dev #1
+10
-8
@@ -58,7 +58,8 @@ Packet integration tests must remain inside disposable isolated namespaces.
|
|||||||
```text
|
```text
|
||||||
./
|
./
|
||||||
├── DEVELOPER.md # This document
|
├── DEVELOPER.md # This document
|
||||||
├── README.md # Operator-facing usage guide
|
├── README.md # Project overview
|
||||||
|
├── USAGE.md # Operator command/deployment guide
|
||||||
├── pyproject.toml # Package metadata and dependencies
|
├── pyproject.toml # Package metadata and dependencies
|
||||||
├── uvm.py # Executable source-tree launcher
|
├── uvm.py # Executable source-tree launcher
|
||||||
├── uvm/
|
├── uvm/
|
||||||
@@ -345,12 +346,12 @@ processes, and allocation reuse.
|
|||||||
|
|
||||||
### Start and Restart
|
### Start and Restart
|
||||||
|
|
||||||
Start requires managed ports, a proven-gone process (or an already identified
|
To boot a stopped/proven-gone VM, start requires managed ports, a complete safe
|
||||||
running VM), a complete safe private disk, and compatible retained gateway,
|
private disk, and compatible retained gateway, network, and bridge settings.
|
||||||
network, and bridge settings. It verifies the VMM and installed **kernel**;
|
It verifies the VMM and installed **kernel**; the original rootfs template is
|
||||||
the original rootfs template is not needed. It never recopies the rootfs,
|
not needed. It never recopies the rootfs, reprovisions credentials, or
|
||||||
reprovisions credentials, or regenerates guest SSH host keys. Name/IP/MAC,
|
regenerates guest SSH host keys. Name/IP/MAC, ports, and guest disk changes
|
||||||
ports, and guest disk changes survive stop/start; guest RAM does not.
|
survive stop/start; guest RAM does not.
|
||||||
|
|
||||||
Start on a healthy running VM synchronizes policy without spawning another
|
Start on a healthy running VM synchronizes policy without spawning another
|
||||||
VMM. An ambiguous process or interrupted live boot/stop is not adopted as a
|
VMM. An ambiguous process or interrupted live boot/stop is not adopted as a
|
||||||
@@ -779,6 +780,7 @@ The packet test skipped in this workspace because namespace creation was
|
|||||||
denied (`Operation not permitted`). Document that permission limitation, not
|
denied (`Operation not permitted`). Document that permission limitation, not
|
||||||
an assertion that real packets or a real host were validated. Unit tests and
|
an assertion that real packets or a real host were validated. Unit tests and
|
||||||
nft JSON parser checks are not substitutes for kernel packet-path execution.
|
nft JSON parser checks are not substitutes for kernel packet-path execution.
|
||||||
|
|
||||||
### Isolated Host Validation
|
### Isolated Host Validation
|
||||||
|
|
||||||
No current test automatically boots a real microVM. Before a release, validate
|
No current test automatically boots a real microVM. Before a release, validate
|
||||||
@@ -841,7 +843,7 @@ The code is intentionally honest about current limits:
|
|||||||
- Firecracker is launched directly, even though the Jailer binary is
|
- Firecracker is launched directly, even though the Jailer binary is
|
||||||
downloaded.
|
downloaded.
|
||||||
- No cgroup CPU, memory, I/O, or process limits are applied.
|
- No cgroup CPU, memory, I/O, or process limits are applied.
|
||||||
- Fractional CPU is represented by rounding vCPUs up and printing an advisory.
|
- Fractional CPU requests round vCPUs up without host quota enforcement.
|
||||||
- Guest health is inferred from VMM process state, not application readiness.
|
- Guest health is inferred from VMM process state, not application readiness.
|
||||||
- No quotas, DHCP, host-port mapping, UDP/IPv6 services, or network tenancy.
|
- No quotas, DHCP, host-port mapping, UDP/IPv6 services, or network tenancy.
|
||||||
- No guest snapshots, extra disks, production image builder, or SSH-key injection.
|
- No guest snapshots, extra disks, production image builder, or SSH-key injection.
|
||||||
|
|||||||
@@ -386,9 +386,13 @@ Host and API settings can be overridden through environment variables:
|
|||||||
|
|
||||||
## Project Layout
|
## Project Layout
|
||||||
|
|
||||||
|
Selected files:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
./
|
./
|
||||||
├── README.md, USAGE.md, DEVELOPER.md
|
├── README.md
|
||||||
|
├── USAGE.md
|
||||||
|
├── DEVELOPER.md
|
||||||
├── pyproject.toml
|
├── pyproject.toml
|
||||||
├── uvm.py # Executable compatibility launcher
|
├── uvm.py # Executable compatibility launcher
|
||||||
├── uvm/ # Private application implementation
|
├── uvm/ # Private application implementation
|
||||||
|
|||||||
@@ -97,6 +97,8 @@ SSH to a known guest IP does not require root.
|
|||||||
The following is syntax notation, not a shell script. `VM` means an exact VM
|
The following is syntax notation, not a shell script. `VM` means an exact VM
|
||||||
ID, local name, or guest IPv4 address. Flags are command-specific; in
|
ID, local name, or guest IPv4 address. Flags are command-specific; in
|
||||||
particular, create has no `--dry-run`, and start has no `--timeout`.
|
particular, create has no `--dry-run`, and start has no `--timeout`.
|
||||||
|
Examples elsewhere are separate scenarios; substitute your VM identifiers
|
||||||
|
and do not run every destructive example as a single script.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
uvm install [--force]
|
uvm install [--force]
|
||||||
@@ -235,7 +237,7 @@ literal secret in the command line or history. Do not enable shell tracing:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo -v
|
sudo -v
|
||||||
read -r -s -p 'Guest password: ' guest_password
|
IFS= read -r -s -p 'Guest password: ' guest_password
|
||||||
printf '\n'
|
printf '\n'
|
||||||
printf '%s\n' "$guest_password" | sudo ./uvm.py create --name app \
|
printf '%s\n' "$guest_password" | sudo ./uvm.py create --name app \
|
||||||
--cpu 1 --ram 1G --guest-ip 10.42.0.10 \
|
--cpu 1 --ram 1G --guest-ip 10.42.0.10 \
|
||||||
@@ -346,8 +348,9 @@ adopt a legacy process, or reconcile a VM. Policy status is a separate snapshot:
|
|||||||
| `unknown` | Inspection failed or metadata could not establish policy status. |
|
| `unknown` | Inspection failed or metadata could not establish policy status. |
|
||||||
|
|
||||||
`enforced` is not a service-connectivity test or a lasting guarantee against
|
`enforced` is not a service-connectivity test or a lasting guarantee against
|
||||||
external rule changes. Mutation JSON may report policy `unknown` without a
|
external rule changes. Dry-run target snapshots may show policy `unknown`
|
||||||
fresh observation; use inspect or ports list for a current readback.
|
because a preview does not verify enforcement. Use inspect or ports list for
|
||||||
|
a current readback.
|
||||||
|
|
||||||
### Logs and Doctor
|
### Logs and Doctor
|
||||||
|
|
||||||
@@ -441,11 +444,12 @@ support, and compatible network settings are still required. Removing the
|
|||||||
original rootfs template does not prevent retained-disk start, although doctor
|
original rootfs template does not prevent retained-disk start, although doctor
|
||||||
checks templates and can report it missing.
|
checks templates and can report it missing.
|
||||||
|
|
||||||
Start on an already running, identified VM synchronizes its managed port
|
Start on a VM recorded as running with a verified process identity synchronizes
|
||||||
policy without rebooting. Restart is stop followed by start under the same
|
its managed port policy without rebooting. Restart is stop followed by start
|
||||||
operation lock. Unknown process identity, an incomplete/unsafe disk, or pending
|
under the same operation lock. Unknown process identity, an incomplete/unsafe
|
||||||
deletion blocks a new boot; inspect the error rather than editing registry
|
disk, or pending deletion blocks a new boot; inspect the error rather than
|
||||||
flags. An interrupted boot with a live VMM must be stopped before start.
|
editing registry flags. An interrupted boot with a live VMM must be stopped
|
||||||
|
before start.
|
||||||
|
|
||||||
### Scenario: Permanently Remove a VM
|
### Scenario: Permanently Remove a VM
|
||||||
|
|
||||||
@@ -508,10 +512,11 @@ or NAT can change reachability; a private IP is not by itself an exposure
|
|||||||
guarantee, and UVM does not integrate with external firewall managers yet.
|
guarantee, and UVM does not integrate with external firewall managers yet.
|
||||||
|
|
||||||
UDP and IPv6 service policies are not yet supported. Unsolicited inbound UDP
|
UDP and IPv6 service policies are not yet supported. Unsolicited inbound UDP
|
||||||
is blocked, as is IPv6 in both directions on managed TAPs. Guest-initiated IPv4 TCP/UDP (including
|
is blocked, as is IPv6 in both directions on managed TAPs. Guest-initiated IPv4
|
||||||
DNS queries), replies to that egress, ARP, ICMP echo, and required related ICMP
|
TCP/UDP (including DNS queries), replies to that egress, ARP, ICMP echo, and
|
||||||
errors remain allowed. An empty TCP allowlist is not a complete network
|
required related ICMP errors remain allowed. An empty TCP allowlist is not a
|
||||||
disconnect. Other VMs' policies still apply to guest-to-guest egress.
|
complete network disconnect. Other VMs' policies still apply to guest-to-guest
|
||||||
|
egress.
|
||||||
|
|
||||||
### List, Open, Close, and Set
|
### List, Open, Close, and Set
|
||||||
|
|
||||||
|
|||||||
@@ -396,7 +396,7 @@ class NetworkManagerTests(unittest.TestCase):
|
|||||||
self.runner.links = {self.vm.tap: {**self.tap_link, "linkinfo": info}}
|
self.runner.links = {self.vm.tap: {**self.tap_link, "linkinfo": info}}
|
||||||
self.runner.calls.clear()
|
self.runner.calls.clear()
|
||||||
self.vm.tap_owned = True
|
self.vm.tap_owned = True
|
||||||
with self.assertRaises(UvmError):
|
with self.assertRaises(ConflictError):
|
||||||
getattr(self.network, operation)(self.vm)
|
getattr(self.network, operation)(self.vm)
|
||||||
self.assertEqual(self.runner.calls, [LINK_INVENTORY])
|
self.assertEqual(self.runner.calls, [LINK_INVENTORY])
|
||||||
self.assertIs(self.vm.tap_owned, True)
|
self.assertIs(self.vm.tap_owned, True)
|
||||||
|
|||||||
+26
-3
@@ -86,7 +86,7 @@ class StateStoreTests(unittest.TestCase):
|
|||||||
self.assertIsNone(vm.network)
|
self.assertIsNone(vm.network)
|
||||||
self.assertIsNone(vm.bridge)
|
self.assertIsNone(vm.bridge)
|
||||||
self.assertFalse(vm.disk_ready)
|
self.assertFalse(vm.disk_ready)
|
||||||
self.assertFalse(vm.runtime_owned)
|
self.assertTrue(vm.runtime_owned)
|
||||||
self.assertFalse(vm.tap_owned)
|
self.assertFalse(vm.tap_owned)
|
||||||
self.assertFalse(vm.spawn_pending)
|
self.assertFalse(vm.spawn_pending)
|
||||||
self.assertEqual(vm.updated_at, vm.created_at)
|
self.assertEqual(vm.updated_at, vm.created_at)
|
||||||
@@ -103,6 +103,8 @@ class StateStoreTests(unittest.TestCase):
|
|||||||
self.assertEqual(persisted["next_mac_index"], 2)
|
self.assertEqual(persisted["next_mac_index"], 2)
|
||||||
self.assertEqual(persisted["vms"]["vm-test"]["status"], "stopped")
|
self.assertEqual(persisted["vms"]["vm-test"]["status"], "stopped")
|
||||||
self.assertIsNone(persisted["vms"]["vm-test"]["ports"])
|
self.assertIsNone(persisted["vms"]["vm-test"]["ports"])
|
||||||
|
self.assertIs(persisted["vms"]["vm-test"]["runtime_owned"], True)
|
||||||
|
self.assertIs(persisted["vms"]["vm-test"]["disk_ready"], False)
|
||||||
|
|
||||||
def test_round_trip_preserves_policy_ownership_and_recovery_fields(self) -> None:
|
def test_round_trip_preserves_policy_ownership_and_recovery_fields(self) -> None:
|
||||||
for ports in (None, (), (PortRule(22), PortRule(8080, "http"))):
|
for ports in (None, (), (PortRule(22), PortRule(8080, "http"))):
|
||||||
@@ -258,8 +260,8 @@ class DomainStateTests(unittest.TestCase):
|
|||||||
("stopped", "/tmp/rootfs.ext4", None, (True, True, False)),
|
("stopped", "/tmp/rootfs.ext4", None, (True, True, False)),
|
||||||
("error", "/tmp/rootfs.ext4", 123, (False, True, True)),
|
("error", "/tmp/rootfs.ext4", 123, (False, True, True)),
|
||||||
("starting", "/tmp/rootfs.ext4", None, (False, True, False)),
|
("starting", "/tmp/rootfs.ext4", None, (False, True, False)),
|
||||||
("running", "", 123, (False, False, True)),
|
("running", "", 123, (False, True, True)),
|
||||||
("unknown", "", None, (False, False, False)),
|
("unknown", "", None, (False, True, False)),
|
||||||
)
|
)
|
||||||
for status, disk, pid, expected in cases:
|
for status, disk, pid, expected in cases:
|
||||||
with self.subTest(status=status, disk=disk, pid=pid):
|
with self.subTest(status=status, disk=disk, pid=pid):
|
||||||
@@ -270,6 +272,27 @@ class DomainStateTests(unittest.TestCase):
|
|||||||
self.assertEqual((vm.disk_ready, vm.runtime_owned, vm.tap_owned), expected)
|
self.assertEqual((vm.disk_ready, vm.runtime_owned, vm.tap_owned), expected)
|
||||||
self.assertIsNone(vm.ports)
|
self.assertIsNone(vm.ports)
|
||||||
|
|
||||||
|
def test_empty_disk_legacy_runtime_is_owned_but_explicit_false_is_respected(self) -> None:
|
||||||
|
for disk_fields in ({}, {"disk": ""}):
|
||||||
|
with self.subTest(disk_fields=disk_fields):
|
||||||
|
raw = replace(make_vm(), status="stopped").to_dict()
|
||||||
|
for field in ("disk", "ports", "disk_ready", "runtime_owned"):
|
||||||
|
raw.pop(field)
|
||||||
|
raw.update(disk_fields)
|
||||||
|
|
||||||
|
legacy = VmRecord.from_dict(raw)
|
||||||
|
self.assertEqual(legacy.disk, "")
|
||||||
|
self.assertIs(legacy.runtime_owned, True)
|
||||||
|
self.assertIs(legacy.disk_ready, False)
|
||||||
|
self.assertIsNone(legacy.ports)
|
||||||
|
self.assertEqual(VmRecord.from_dict(legacy.to_dict()), legacy)
|
||||||
|
|
||||||
|
new = VmRecord.from_dict({**raw, "ports": [], "runtime_owned": False})
|
||||||
|
self.assertIs(new.runtime_owned, False)
|
||||||
|
self.assertIs(new.disk_ready, False)
|
||||||
|
self.assertEqual(new.ports, ())
|
||||||
|
self.assertEqual(VmRecord.from_dict(new.to_dict()), new)
|
||||||
|
|
||||||
def test_missing_spawn_pending_recovers_only_a_legacy_spawning_operation(self) -> None:
|
def test_missing_spawn_pending_recovers_only_a_legacy_spawning_operation(self) -> None:
|
||||||
for operation in (None, "spawning", "configuring", "stopping", "policy"):
|
for operation in (None, "spawning", "configuring", "stopping", "policy"):
|
||||||
with self.subTest(operation=operation):
|
with self.subTest(operation=operation):
|
||||||
|
|||||||
+1
-1
@@ -193,7 +193,7 @@ class VmRecord:
|
|||||||
name=str(value["name"]) if value.get("name") is not None else None,
|
name=str(value["name"]) if value.get("name") is not None else None,
|
||||||
ports=ports,
|
ports=ports,
|
||||||
disk_ready=value.get("disk_ready", legacy_ready),
|
disk_ready=value.get("disk_ready", legacy_ready),
|
||||||
runtime_owned=value.get("runtime_owned", bool(value.get("disk"))),
|
runtime_owned=value.get("runtime_owned", True),
|
||||||
tap_owned=value.get("tap_owned", bool(value.get("pid"))),
|
tap_owned=value.get("tap_owned", bool(value.get("pid"))),
|
||||||
operation=str(value["operation"]) if value.get("operation") is not None else None,
|
operation=str(value["operation"]) if value.get("operation") is not None else None,
|
||||||
boot_id=str(value["boot_id"]) if value.get("boot_id") is not None else None,
|
boot_id=str(value["boot_id"]) if value.get("boot_id") is not None else None,
|
||||||
|
|||||||
+14
-2
@@ -7,6 +7,7 @@ import shutil
|
|||||||
import time
|
import time
|
||||||
from collections.abc import Callable, Sequence
|
from collections.abc import Callable, Sequence
|
||||||
from dataclasses import dataclass, replace
|
from dataclasses import dataclass, replace
|
||||||
|
from ipaddress import IPv4Network
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .config import Settings
|
from .config import Settings
|
||||||
@@ -99,6 +100,9 @@ class LifecycleService:
|
|||||||
raise UvmError(message) from error
|
raise UvmError(message) from error
|
||||||
|
|
||||||
def _check_boot(self) -> None:
|
def _check_boot(self) -> None:
|
||||||
|
if not any(self._settings.network.subnet_of(IPv4Network(network))
|
||||||
|
for network in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16")):
|
||||||
|
raise ValidationError("private ports require a network inside an RFC 1918 IPv4 range")
|
||||||
check_kvm()
|
check_kvm()
|
||||||
manifest = load_manifest(self._settings.integrity_manifest_path)
|
manifest = load_manifest(self._settings.integrity_manifest_path)
|
||||||
checksum = self._settings.firecracker_binary_sha256
|
checksum = self._settings.firecracker_binary_sha256
|
||||||
@@ -258,12 +262,13 @@ class LifecycleService:
|
|||||||
self._network.firewall.remove(vm)
|
self._network.firewall.remove(vm)
|
||||||
|
|
||||||
def set_ports(self, identifier: str, ports: tuple[PortRule, ...], *, mode: str = "set") -> VmRecord:
|
def set_ports(self, identifier: str, ports: tuple[PortRule, ...], *, mode: str = "set") -> VmRecord:
|
||||||
|
"""Mutate a frozen registry ID; the CLI resolves names before confirmation."""
|
||||||
require_root()
|
require_root()
|
||||||
ports = self._validated_ports(ports)
|
ports = self._validated_ports(ports)
|
||||||
if mode not in {"open", "close", "set"}:
|
if mode not in {"open", "close", "set"}:
|
||||||
raise ValidationError(f"invalid port operation: {mode}")
|
raise ValidationError(f"invalid port operation: {mode}")
|
||||||
with self._state_store.operation_lock():
|
with self._state_store.operation_lock():
|
||||||
vm = self._load_for_operation(identifier)
|
vm = self._load_for_operation(identifier, exact_id=True)
|
||||||
self._not_deleting(vm)
|
self._not_deleting(vm)
|
||||||
observation = self._process.observe(vm)
|
observation = self._process.observe(vm)
|
||||||
if observation == "unknown":
|
if observation == "unknown":
|
||||||
@@ -321,7 +326,8 @@ class LifecycleService:
|
|||||||
containment_errors = []
|
containment_errors = []
|
||||||
for contain in (
|
for contain in (
|
||||||
lambda: self._network.deactivate_tap(vm),
|
lambda: self._network.deactivate_tap(vm),
|
||||||
lambda: self._network.firewall.apply(vm, quarantine=True),
|
lambda: (self._network.firewall.apply(vm, quarantine=True)
|
||||||
|
if self._network.has_tap(vm) else None),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
contain()
|
contain()
|
||||||
@@ -457,6 +463,12 @@ class LifecycleService:
|
|||||||
|
|
||||||
def _reserve_vm(self, spec: VmSpec) -> VmRecord:
|
def _reserve_vm(self, spec: VmSpec) -> VmRecord:
|
||||||
with self._state_store.transaction() as state:
|
with self._state_store.transaction() as state:
|
||||||
|
for existing in state.vms.values():
|
||||||
|
if (existing.guest_ip == str(self._settings.gateway)
|
||||||
|
or existing.gateway != str(self._settings.gateway)
|
||||||
|
or existing.network not in {None, str(self._settings.network)}
|
||||||
|
or existing.bridge not in {None, self._settings.bridge}):
|
||||||
|
raise ConflictError("configured topology conflicts with retained VMs; keep their network settings")
|
||||||
if spec.name is not None and any(spec.name in {vm.name, vm.id} for vm in state.vms.values()):
|
if spec.name is not None and any(spec.name in {vm.name, vm.id} for vm in state.vms.values()):
|
||||||
raise ConflictError(f"VM name already exists: {spec.name}")
|
raise ConflictError(f"VM name already exists: {spec.name}")
|
||||||
vm_id = new_vm_id()
|
vm_id = new_vm_id()
|
||||||
|
|||||||
+7
-2
@@ -141,7 +141,10 @@ class NetworkManager:
|
|||||||
result = self._runner.run(("ip", "-j", "-d", "link", "show"), capture=True)
|
result = self._runner.run(("ip", "-j", "-d", "link", "show"), capture=True)
|
||||||
try:
|
try:
|
||||||
links = json.loads(result.stdout)
|
links = json.loads(result.stdout)
|
||||||
if not isinstance(links, list) or any(not isinstance(link, dict) for link in links):
|
if not isinstance(links, list) or any(
|
||||||
|
not isinstance(link, dict) or not isinstance(link.get("ifname"), str)
|
||||||
|
or not link["ifname"] for link in links
|
||||||
|
):
|
||||||
raise ValueError("invalid interface inventory")
|
raise ValueError("invalid interface inventory")
|
||||||
matches = [link for link in links if link.get("ifname") == name]
|
matches = [link for link in links if link.get("ifname") == name]
|
||||||
if len(matches) > 1:
|
if len(matches) > 1:
|
||||||
@@ -155,7 +158,9 @@ class NetworkManager:
|
|||||||
if link is None:
|
if link is None:
|
||||||
return None
|
return None
|
||||||
info = link.get("linkinfo", {})
|
info = link.get("linkinfo", {})
|
||||||
is_tap = info.get("info_kind") == "tun" and info.get("info_data", {}).get("type") == "tap"
|
is_tap = (isinstance(info, dict) and info.get("info_kind") == "tun"
|
||||||
|
and isinstance(info.get("info_data"), dict)
|
||||||
|
and info["info_data"].get("type") == "tap")
|
||||||
legacy = vm.ports is None and vm.tap_owned and not link.get("ifalias")
|
legacy = vm.ports is None and vm.tap_owned and not link.get("ifalias")
|
||||||
if not is_tap or (link.get("ifalias") != f"uvm:{vm.id}" and not legacy):
|
if not is_tap or (link.get("ifalias") != f"uvm:{vm.id}" and not legacy):
|
||||||
raise ConflictError(f"refusing to modify unowned TAP {vm.tap}")
|
raise ConflictError(f"refusing to modify unowned TAP {vm.tap}")
|
||||||
|
|||||||
+5
-1
@@ -86,7 +86,7 @@ def parse_password(value: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def parse_name(value: str) -> str:
|
def parse_name(value: str) -> str:
|
||||||
if re.fullmatch(r"[a-z][a-z0-9-]{0,62}", value) is None:
|
if not isinstance(value, str) or re.fullmatch(r"[a-z][a-z0-9-]{0,62}", value) is None:
|
||||||
raise ValidationError("VM name must be 1-63 lowercase letters, digits, or hyphens, starting with a letter")
|
raise ValidationError("VM name must be 1-63 lowercase letters, digits, or hyphens, starting with a letter")
|
||||||
if re.fullmatch(r"vm-[0-9a-f]{32}", value):
|
if re.fullmatch(r"vm-[0-9a-f]{32}", value):
|
||||||
raise ValidationError("VM names must not impersonate generated VM IDs")
|
raise ValidationError("VM names must not impersonate generated VM IDs")
|
||||||
@@ -94,8 +94,12 @@ def parse_name(value: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def parse_ports(values: Sequence[str]) -> tuple[PortRule, ...]:
|
def parse_ports(values: Sequence[str]) -> tuple[PortRule, ...]:
|
||||||
|
if not isinstance(values, Sequence) or isinstance(values, (str, bytes)):
|
||||||
|
raise ValidationError("ports must be a sequence of PORT[:TYPE] strings")
|
||||||
ports: dict[int, PortRule] = {}
|
ports: dict[int, PortRule] = {}
|
||||||
for value in values:
|
for value in values:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise ValidationError("each port specification must be a string")
|
||||||
for item in value.split(","):
|
for item in value.split(","):
|
||||||
match = re.fullmatch(r"\s*(\d+)(?::(TCP|HTTP|HTTPS))?\s*", item, re.IGNORECASE)
|
match = re.fullmatch(r"\s*(\d+)(?::(TCP|HTTP|HTTPS))?\s*", item, re.IGNORECASE)
|
||||||
if match is None:
|
if match is None:
|
||||||
|
|||||||
Reference in New Issue
Block a user