fix(delivery): validate the recovery broker as read-only

This commit is contained in:
2026-10-19 22:19:15 +08:00
parent 6071994695
commit ecce953fbb
4 changed files with 50 additions and 12 deletions
+38 -6
View File
@@ -1362,19 +1362,51 @@ def test_dependency_templates_keep_socket_auth_network_and_actual_limit_scope():
("options", ["rw"]),
],
)
def test_dependency_storage_refuses_soft_unbounded_or_shared_capacity(field, value):
@pytest.mark.parametrize("name,read_only", [("postgres", False), ("rabbitmq", True)])
def test_dependency_storage_refuses_soft_unbounded_or_shared_capacity(
field, value, name, read_only
):
observed = {
"root": "/",
"mountpoint": dependencies.STORAGE["postgres"]["path"],
"mountpoint": dependencies.STORAGE[name]["path"],
"filesystem": "ext4",
"options": ["rw", "nosuid", "nodev", "noexec"],
"options": ["ro" if read_only else "rw", "nosuid", "nodev", "noexec"],
"shared_elsewhere": False,
"capacity_bytes": 2 * 1024**3,
"capacity_bytes": dependencies.STORAGE[name]["filesystem_bytes"],
}
dependencies.validate_storage("postgres", observed)
dependencies.validate_storage(name, observed, read_only=read_only)
observed[field] = value
with pytest.raises(policy.DeliveryError, match="hard dependency storage"):
dependencies.validate_storage("postgres", observed)
dependencies.validate_storage(name, observed, read_only=read_only)
def test_recovery_broker_read_only_view_is_not_normal_service_writability_proof():
observed = {
"root": "/",
"mountpoint": dependencies.STORAGE["rabbitmq"]["path"],
"filesystem": "ext4",
"options": ["ro", "nosuid", "nodev", "noexec"],
"shared_elsewhere": False,
"capacity_bytes": dependencies.STORAGE["rabbitmq"]["filesystem_bytes"],
}
dependencies.validate_storage("rabbitmq", observed, read_only=True)
with pytest.raises(policy.DeliveryError, match="hard dependency storage"):
dependencies.validate_storage("rabbitmq", observed)
observed["options"][0] = "rw"
dependencies.validate_storage("rabbitmq", observed)
with pytest.raises(policy.DeliveryError, match="hard dependency storage"):
dependencies.validate_storage("rabbitmq", observed, read_only=True)
observed["options"].append("ro")
with pytest.raises(policy.DeliveryError, match="hard dependency storage"):
dependencies.validate_storage("rabbitmq", observed, read_only=True)
@pytest.mark.parametrize("name,read_only", [("postgres", True), ("rabbitmq", 1)])
def test_recovery_storage_read_only_exception_is_only_for_explicit_broker_observation(
name, read_only
):
with pytest.raises(policy.DeliveryError, match="observation purpose"):
dependencies.validate_storage(name, {}, read_only=read_only)
@pytest.mark.parametrize("mutation", ["admin", "password", "privilege", "queue", "limit"])
+3 -1
View File
@@ -391,7 +391,9 @@ schema-changing upgrades need a separate Backend Alembic procedure.
The operator reads the backup through a read-only mount, checks the private receipt
and every byte, then copies to fixed targets under 512 MiB, zero swap, 32 tasks,
50% CPU and a 30-minute deadline. Original identities/modes and replay IDs are
preserved. Old configuration and maintenance receipts go into inactive
preserved. Its PostgreSQL target is writable; the fresh RabbitMQ volume is
checked through a read-only mount and receives no copied broker state.
Old configuration and maintenance receipts go into inactive
`recovery/ID/history/`. The new boot's Sandbox preparation owns cgroup recreation.
Unknown/nonempty targets and differing existing files are refused; partial restores
remain stopped for inspection.
+7 -4
View File
@@ -21,7 +21,7 @@ from .services import python_environment
CONFIGURATION_ROOT = Path("/etc/kf-dependencies")
def storage_observation(name: str) -> dict:
def storage_observation(name: str, *, read_only: bool = False) -> dict:
"""Read an already provisioned dedicated filesystem, never create/mount one."""
directory = Path(STORAGE[name]["path"])
preflight.protected(directory.parent, directory=True)
@@ -43,18 +43,21 @@ def storage_observation(name: str) -> dict:
"shared_elsewhere": any(other[2] == row[2] and other != row for other in mounts),
"capacity_bytes": fs.f_frsize * fs.f_blocks,
}
validate_storage(name, observation)
validate_storage(name, observation, read_only=read_only)
return observation
def validate_storage(name: str, observation: dict) -> None:
def validate_storage(name: str, observation: dict, *, read_only: bool = False) -> None:
if type(read_only) is not bool or (read_only and name != "rabbitmq"):
raise DeliveryError("unexpected dependency storage observation purpose")
required = STORAGE[name]["filesystem_bytes"]
if (
observation["root"] != "/"
or observation["mountpoint"] != STORAGE[name]["path"]
or observation["filesystem"] != "ext4"
or observation["shared_elsewhere"] is not False
or not {"rw", "nosuid", "nodev", "noexec"} <= set(observation["options"])
or not {"nosuid", "nodev", "noexec"} <= set(observation["options"])
or set(observation["options"]) & {"ro", "rw"} != ({"ro"} if read_only else {"rw"})
or type(observation["capacity_bytes"]) is not int
or not required * 9 // 10 <= observation["capacity_bytes"] <= required
):
+2 -1
View File
@@ -184,7 +184,8 @@ def restore(recovery_id: str, backup_id: str, expected: str) -> dict:
restore_policy.compatible(value, payload, data, root)
restore_policy.identity_check(value, selected, root)
for name in ("postgres", "rabbitmq"):
dependencies.storage_observation(name)
# The copy operator writes PostgreSQL only; the fresh broker stays read-only.
dependencies.storage_observation(name, read_only=name == "rabbitmq")
member(
Path(dependencies.STORAGE[name]["path"]),
dependency_install.IDENTITIES[name][0],