216 lines
11 KiB
Python
216 lines
11 KiB
Python
"""Unit tests for the fail-closed P12 RC isolation preflight."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
SPEC = importlib.util.spec_from_file_location("rc_isolation_preflight", ROOT / "scripts" / "rc_isolation_preflight.py")
|
|
assert SPEC is not None and SPEC.loader is not None
|
|
preflight = importlib.util.module_from_spec(SPEC)
|
|
SPEC.loader.exec_module(preflight)
|
|
|
|
|
|
PROJECT = "modelforge-validation-p12a"
|
|
|
|
|
|
def compose_plan(root: Path) -> dict[str, object]:
|
|
return {
|
|
"services": {
|
|
"api": {
|
|
"networks": ["default"],
|
|
"volumes": [
|
|
{"type": "volume", "source": "postgres-data", "target": "/data"},
|
|
{"type": "bind", "source": str(root / "config"), "target": "/app/config", "read_only": True},
|
|
],
|
|
"ports": [{"host_ip": "127.0.0.1", "published": "0", "target": 8000}],
|
|
},
|
|
"node-agent": {
|
|
"networks": ["default"],
|
|
"volumes": [{"type": "volume", "source": "node-state", "target": "/data/state"}],
|
|
},
|
|
},
|
|
"volumes": {
|
|
"postgres-data": {"name": f"{PROJECT}_postgres-data"},
|
|
"node-state": {"name": f"{PROJECT}_node-state"},
|
|
},
|
|
"networks": {"default": {"name": f"{PROJECT}_default"}},
|
|
}
|
|
|
|
|
|
class RcIsolationPreflightTests(unittest.TestCase):
|
|
def plan_root(self, *, with_manifest: bool = True) -> tempfile.TemporaryDirectory[str]:
|
|
temporary = tempfile.TemporaryDirectory()
|
|
config = Path(temporary.name, "config")
|
|
config.mkdir()
|
|
if with_manifest:
|
|
manifest = config / "models" / "initial-candidates.yaml"
|
|
manifest.parent.mkdir()
|
|
manifest.write_text("candidates: []\n", encoding="utf-8")
|
|
self.addCleanup(temporary.cleanup)
|
|
return temporary
|
|
|
|
def validate(
|
|
self,
|
|
plan: dict[str, object],
|
|
root: Path,
|
|
*,
|
|
ports: set[int] | None = None,
|
|
names: set[str] | None = None,
|
|
) -> dict[str, object]:
|
|
return preflight.validate_compose(
|
|
plan,
|
|
project=PROJECT,
|
|
candidate_root=root,
|
|
protected_ports=ports or set(),
|
|
protected_resource_names=names or set(),
|
|
)
|
|
|
|
def test_valid_validation_compose_plan_is_accepted(self) -> None:
|
|
temporary = self.plan_root()
|
|
root = Path(temporary.name)
|
|
result = self.validate(
|
|
compose_plan(root), root, ports={18000, 13000}, names={"modelforge-postgres-1"}
|
|
)
|
|
self.assertEqual(result["project"], PROJECT)
|
|
self.assertEqual(result["published_ports"], [])
|
|
|
|
def test_project_validation_rejects_production_and_legacy_rc_names(self) -> None:
|
|
for project in (
|
|
"modelforge",
|
|
"modelforge-rc-p12a",
|
|
"modelforge-validation-",
|
|
"modelforge-validation-prod-",
|
|
):
|
|
with self.subTest(project=project), self.assertRaises(preflight.IsolationError):
|
|
preflight.validate_project(project)
|
|
|
|
def test_missing_bind_source_is_rejected(self) -> None:
|
|
temporary = self.plan_root(with_manifest=False)
|
|
root = Path(temporary.name)
|
|
plan = compose_plan(root)
|
|
(root / "config").rmdir()
|
|
with self.assertRaisesRegex(preflight.IsolationError, "missing bind source"):
|
|
self.validate(plan, root)
|
|
|
|
def test_config_manifest_must_exist_under_exact_bind_source(self) -> None:
|
|
temporary = self.plan_root(with_manifest=False)
|
|
root = Path(temporary.name)
|
|
plan = compose_plan(root)
|
|
# A manifest elsewhere in the candidate tree cannot satisfy this bind's contract.
|
|
manifest = root / "models" / "initial-candidates.yaml"
|
|
manifest.parent.mkdir()
|
|
manifest.write_text("candidates: []\n", encoding="utf-8")
|
|
with self.assertRaisesRegex(preflight.IsolationError, "missing models/initial-candidates.yaml under its source"):
|
|
self.validate(plan, root)
|
|
|
|
def test_rendered_compose_network_mapping_is_accepted(self) -> None:
|
|
temporary = self.plan_root()
|
|
root = Path(temporary.name)
|
|
plan = compose_plan(root)
|
|
plan["services"]["api"]["networks"] = {"default": None} # type: ignore[index]
|
|
self.assertEqual(self.validate(plan, root)["declared_networks"], ["default"])
|
|
|
|
def test_unsafe_compose_plans_are_rejected(self) -> None:
|
|
cases = [
|
|
(lambda plan: plan["services"]["api"].update(network_mode="host"), "host network"),
|
|
(lambda plan: plan["services"]["api"].update(container_name="modelforge-api-1"), "global container name"),
|
|
(lambda plan: plan["services"]["api"]["ports"].__setitem__(0, {"host_ip": "0.0.0.0", "published": "19000", "target": 8000}), "non-loopback"),
|
|
(lambda plan: plan["services"]["api"]["ports"].__setitem__(0, {"host_ip": "127.0.0.1", "published": "18000", "target": 8000}), "protected host port"),
|
|
(lambda plan: plan["services"]["api"]["volumes"].__setitem__(1, {"type": "bind", "source": "/mnt/user/appdata/modelforge", "target": "/data", "read_only": True}), "escapes the candidate root"),
|
|
(lambda plan: plan["services"]["api"]["volumes"].__setitem__(1, {"type": "bind", "source": "config", "target": "/app/config", "read_only": False}), "writable bind mount"),
|
|
(lambda plan: plan["volumes"]["postgres-data"].update(external=True), "is external"),
|
|
(lambda plan: plan["networks"]["default"].update(name="modelforge_default"), "non-project name"),
|
|
]
|
|
for mutate, message in cases:
|
|
with self.subTest(message=message):
|
|
temporary = self.plan_root()
|
|
root = Path(temporary.name)
|
|
plan = compose_plan(root)
|
|
mutate(plan)
|
|
with self.assertRaisesRegex(preflight.IsolationError, message):
|
|
self.validate(plan, root, ports={18000})
|
|
|
|
def test_protected_resource_reference_is_rejected(self) -> None:
|
|
temporary = self.plan_root()
|
|
root = Path(temporary.name)
|
|
plan = compose_plan(root)
|
|
plan["services"]["api"]["environment"] = {"UNSAFE": "modelforge-postgres-1"} # type: ignore[index]
|
|
with self.assertRaisesRegex(preflight.IsolationError, "protected resources"):
|
|
self.validate(plan, root, names={"modelforge-postgres-1"})
|
|
|
|
def test_only_isolated_tls_service_can_publish_test_port(self) -> None:
|
|
temporary = self.plan_root()
|
|
root = Path(temporary.name)
|
|
source = root / "source" / "deploy" / "validation"
|
|
source.mkdir(parents=True)
|
|
(source / "Caddyfile").write_text("https://modelforge.example.com:8443 {}\n", encoding="utf-8")
|
|
certificates = root / "tls"
|
|
certificates.mkdir()
|
|
(certificates / "fullchain.pem").write_text("certificate", encoding="utf-8")
|
|
(certificates / "privkey.pem").write_text("key", encoding="utf-8")
|
|
plan = compose_plan(root)
|
|
plan["networks"]["ingress"] = {"name": f"{PROJECT}_ingress"}
|
|
plan["services"]["tls"] = {
|
|
"image": "caddy:2.10-alpine@sha256:4c6e91c6ed0e2fa03efd5b44747b625fec79bc9cd06ac5235a779726618e530d",
|
|
"networks": ["ingress"], "read_only": True, "cap_drop": ["ALL"],
|
|
"security_opt": ["no-new-privileges:true"],
|
|
"volumes": [
|
|
{"type": "bind", "source": str(source / "Caddyfile"), "target": "/etc/caddy/Caddyfile", "read_only": True},
|
|
{"type": "bind", "source": str(certificates / "fullchain.pem"), "target": "/run/tls/fullchain.pem", "read_only": True},
|
|
{"type": "bind", "source": str(certificates / "privkey.pem"), "target": "/run/tls/privkey.pem", "read_only": True},
|
|
],
|
|
"ports": [{"host_ip": "0.0.0.0", "published": "18444", "target": 8443, "protocol": "tcp"}],
|
|
}
|
|
with self.assertRaisesRegex(preflight.IsolationError, "non-loopback"):
|
|
self.validate(plan, root)
|
|
self.assertEqual(preflight.validate_compose(
|
|
plan, project=PROJECT, candidate_root=root,
|
|
protected_ports={443, 8443}, protected_resource_names=set(), public_tls_port=18444,
|
|
)["published_ports"], [18444])
|
|
plan["services"]["api"]["ports"][0] = {"host_ip": "0.0.0.0", "published": "18444", "target": 8000}
|
|
with self.assertRaisesRegex(preflight.IsolationError, "non-loopback"):
|
|
preflight.validate_compose(
|
|
plan, project=PROJECT, candidate_root=root,
|
|
protected_ports=set(), protected_resource_names=set(), public_tls_port=18444,
|
|
)
|
|
|
|
def test_observed_resources_require_exact_labels_and_project_names(self) -> None:
|
|
observed = {
|
|
"containers": [{"name": f"{PROJECT}-api-1", "labels": {"com.docker.compose.project": PROJECT}}],
|
|
"volumes": [{"name": f"{PROJECT}_postgres-data", "labels": {"com.docker.compose.project": PROJECT}}],
|
|
"networks": [{"name": f"{PROJECT}_default", "labels": {"com.docker.compose.project": PROJECT}}],
|
|
}
|
|
self.assertEqual(preflight.validate_observed_resources(observed, project=PROJECT)["containers"], [f"{PROJECT}-api-1"])
|
|
observed["volumes"][0]["labels"] = {}
|
|
with self.assertRaisesRegex(preflight.IsolationError, "no exact project label"):
|
|
preflight.validate_observed_resources(observed, project=PROJECT)
|
|
|
|
def test_docker_resource_collection_uses_exact_inspection(self) -> None:
|
|
label = {"com.docker.compose.project": PROJECT}
|
|
responses = {
|
|
("container", "ls", "--all", "--quiet", "--filter", f"label=com.docker.compose.project={PROJECT}"): "container-id\n",
|
|
("volume", "ls", "--quiet", "--filter", f"label=com.docker.compose.project={PROJECT}"): "volume-id\n",
|
|
("network", "ls", "--quiet", "--filter", f"label=com.docker.compose.project={PROJECT}"): "network-id\n",
|
|
("container", "inspect", "container-id"): '[{"Name":"/' + PROJECT + '-api-1","Config":{"Labels":{"com.docker.compose.project":"' + PROJECT + '"}}}]',
|
|
("volume", "inspect", "volume-id"): '[{"Name":"' + PROJECT + '_data","Labels":{"com.docker.compose.project":"' + PROJECT + '"}}]',
|
|
("network", "inspect", "network-id"): '[{"Name":"' + PROJECT + '_default","Labels":{"com.docker.compose.project":"' + PROJECT + '"}}]',
|
|
}
|
|
|
|
def run(command, **_kwargs):
|
|
self.assertEqual(command[0], "docker")
|
|
return type("Result", (), {"returncode": 0, "stdout": responses[tuple(command[1:])]})()
|
|
|
|
with patch.object(preflight.subprocess, "run", run):
|
|
observed = preflight.collect_observed_resources(PROJECT)
|
|
self.assertEqual(observed["containers"][0]["labels"], label)
|
|
self.assertEqual(
|
|
preflight.validate_observed_resources(observed, project=PROJECT)["networks"],
|
|
[f"{PROJECT}_default"],
|
|
)
|