fix(delivery): fit bounded health probes into startup budgets

This commit is contained in:
2026-10-15 21:50:57 +08:00
parent 04e5fac311
commit 721c293382
6 changed files with 251 additions and 101 deletions
+131 -18
View File
@@ -2233,46 +2233,159 @@ def test_proxy_render_is_fixed_to_a_selected_snapshot_without_business_credentia
assert "EnvironmentFile=" not in text and "LoadCredential=" not in text
@pytest.mark.parametrize("name", ["queues", "vhost_limits", "users", "permissions"])
@pytest.mark.parametrize("failure", ["deadline", "exit", "output"])
def test_broker_query_failure_identifies_fixed_operation_without_leaking_process_data(
monkeypatch, name, failure
):
import subprocess
def test_broker_snapshot_failure_keeps_process_details_private(monkeypatch, failure):
from types import SimpleNamespace
from scripts.release import broker_health
from scripts.release import broker_snapshot
secret = "synthetic-private-process-detail"
def failed(command, *, stdout, stderr, timeout, env):
assert stderr == subprocess.DEVNULL
assert timeout == 8
assert command[-1] == "--formatter=json"
assert stderr == subprocess.DEVNULL and timeout == 30
assert command == [
str(broker_snapshot.CTL),
"-q",
"-n",
"kf@localhost",
"--timeout",
"5",
"eval",
broker_snapshot.SNAPSHOT_EXPRESSION,
"--formatter=json",
]
assert env["ERL_EPMD_ADDRESS"] == "127.0.0.1"
assert "rpc:call(node(), erlang, apply," in command[7]
assert command[7].endswith("end, []], 5000).")
if failure == "deadline":
raise subprocess.TimeoutExpired([secret], timeout, stderr=secret.encode())
if failure == "output":
stdout.write(b"x" * 32769)
return SimpleNamespace(returncode=1 if failure == "exit" else 0)
monkeypatch.setattr(broker_health.subprocess, "run", failed)
monkeypatch.setattr(broker_snapshot.subprocess, "run", failed)
suffix = {"deadline": "exceeded deadline", "exit": "failed", "output": "output exceeds bound"}
with pytest.raises(policy.DeliveryError) as raised:
broker_health.command(name)
assert str(raised.value) == "broker " + name + " query " + suffix[failure]
broker_snapshot.command()
assert str(raised.value) == "broker snapshot query " + suffix[failure]
assert secret not in str(raised.value)
@pytest.mark.parametrize("change", [None, "node", "result", "rpc_failure", "extra", "duplicate"])
def test_broker_snapshot_accepts_only_the_pinned_node_envelope(monkeypatch, change):
from types import SimpleNamespace
from scripts.release import broker_snapshot
snapshot = {"queues": [], "vhost_limits": [], "users": [], "permissions": []}
envelope = {"result": "ok", "node": "kf@localhost", "value": json.dumps(snapshot)}
if change == "node":
envelope["node"] = "other@localhost"
elif change == "result":
envelope["result"] = "error"
elif change == "rpc_failure":
envelope["value"] = ["badrpc", "timeout"]
elif change == "extra":
envelope["unknown"] = True
raw = json.dumps(envelope).encode()
if change == "duplicate":
raw = b'{"node":"kf@localhost",' + raw[1:]
def query(command, *, stdout, **kwargs):
stdout.write(raw)
return SimpleNamespace(returncode=0)
monkeypatch.setattr(broker_snapshot.subprocess, "run", query)
if change is None:
assert broker_snapshot.command() == snapshot
else:
with pytest.raises(policy.DeliveryError):
broker_snapshot.command()
@pytest.mark.parametrize("name", ["delete_queue", "users --delete", None])
def test_broker_query_selector_never_accepts_extra_commands_or_flags(monkeypatch, name):
from scripts.release import broker_health
def test_broker_snapshot_has_no_command_or_expression_selector(monkeypatch, name):
from scripts.release import broker_snapshot
def unexpected(*args, **kwargs):
pytest.fail("unknown selector launched a process")
pytest.fail("caller text launched a process")
monkeypatch.setattr(broker_health.subprocess, "run", unexpected)
with pytest.raises(policy.DeliveryError, match="unknown broker metadata query"):
broker_health.command(name)
monkeypatch.setattr(broker_snapshot.subprocess, "run", unexpected)
with pytest.raises(TypeError):
broker_snapshot.command(name)
@pytest.mark.parametrize(
"kind,value", [("longstr", "classic"), ("longstr", "quorum"), ("long", "classic")]
)
def test_broker_arguments_allow_only_exact_automatic_classic_type(kind, value):
args = [
[k, "longstr" if k == "x-overflow" else "long", v]
for k, v in dependencies.QUEUE_ARGUMENTS.items()
]
args.append(["x-queue-type", kind, value])
if (kind, value) == ("longstr", "classic"):
assert broker_health.queue_arguments(args) == dependencies.QUEUE_ARGUMENTS
supplied = {**dependencies.QUEUE_ARGUMENTS, "x-queue-type": "classic"}
assert broker_health.queue_arguments(supplied) == dependencies.QUEUE_ARGUMENTS
assert supplied["x-queue-type"] == "classic"
args[-1] = args[0].copy()
with pytest.raises(policy.DeliveryError):
broker_health.queue_arguments(args)
@pytest.mark.parametrize(
"name", ["broker-health", "factory-health", "import-health", "outbox-health"]
)
@pytest.mark.parametrize("cpu", ["100000 100000", "max 100000", "200000 100000"])
def test_fixed_health_guard_still_requires_exact_helper_and_node_cpu(monkeypatch, name, cpu):
import resource
from scripts.release import operator_units
_, uid, memory, pids, expected_cpu, _ = operator_units.PROFILES[name]
assert expected_cpu == 100
monkeypatch.setattr(operator_units.os, "getuid", lambda: uid)
monkeypatch.setattr(operator_units.os, "geteuid", lambda: uid)
monkeypatch.setattr(operator_units.os, "getgid", lambda: uid)
monkeypatch.setattr(operator_units.os, "getgroups", lambda: [uid])
group = Path("/sys/fs/cgroup/kfonlinejudge.slice/kf-" + name + ".service")
monkeypatch.setattr(operator_units.preflight, "current_cgroup", lambda: group)
observed = {
"memory.max": str(memory * 1024**2),
"memory.swap.max": "0",
"pids.max": str(pids),
"cpu.max": cpu,
}
node = {
"memory.max": str(3840 * 1024**2),
"memory.swap.max": "0",
"pids.max": "384",
"cpu.max": "200000 100000",
}
def read(path, *args, **kwargs):
if str(path) == "/proc/self/status":
return "NoNewPrivs: 1\n" + "\n".join(
k + ": 0" for k in ("CapInh", "CapPrm", "CapEff", "CapBnd", "CapAmb")
)
return (observed if path.parent == group else node)[path.name]
monkeypatch.setattr(Path, "read_text", read)
limits = {
resource.RLIMIT_CORE: 0,
resource.RLIMIT_FSIZE: operator_units.FILE_LIMIT_MIB.get(name, 16) * 1024**2,
resource.RLIMIT_NOFILE: 256,
resource.RLIMIT_STACK: 16 * 1024**2,
}
monkeypatch.setattr(
operator_units.resource, "getrlimit", lambda key: (limits[key], limits[key])
)
if cpu == "100000 100000":
operator_units.guarded(name)
else:
with pytest.raises(policy.DeliveryError, match="budgets differ"):
operator_units.guarded(name)
@pytest.mark.parametrize(
+10 -3
View File
@@ -415,9 +415,16 @@ end-to-end target still requires measured acceptance.
Fixed health helpers include interpreter startup in their wall-clock deadline.
Outbox and import queries have 60 seconds each; factory status has 30 seconds and
broker metadata has 45 seconds. Node readiness has 300 seconds for the sequential
checks. Each helper keeps its fixed CPU, memory, process and privilege limits.
These operator deadlines do not change student execution budgets. An expired or
failed query keeps admission closed.
checks. Each ordinary helper has one CPU within the node's two-CPU ceiling;
its memory, process and privilege limits remain fixed. These operator budgets
do not change student execution budgets. Startup opens admission only after all
required readiness queries succeed.
Broker metadata uses one fixed read-only RPC with a five-second remote deadline
and a 30-second CLI process deadline, within the helper's 45-second limit. It reads
queue metadata, vhost limits, usernames/tags and permissions; it does not read
messages or password hashes. Readiness requires exactly the configured classic
queue and budgets, including RabbitMQ's optional `x-queue-type=classic` argument.
The broker helper has a 64 MiB per-file ceiling for Erlang JIT mappings.
[OTP 27.3.4](https://github.com/erlang/otp/blob/OTP-27.3.4/erts/emulator/beam/jit/beam_jit_main.cpp)
+9 -76
View File
@@ -4,56 +4,33 @@ import argparse
import json
import stat
import subprocess
import tempfile
from pathlib import Path
from . import broker_snapshot, operator_units, preflight
from . import dependency_policy as dependencies
from . import operator_units, preflight
from .policy import DeliveryError, canonical, fields, integer
CTL = Path("/usr/lib/rabbitmq/sbin/rabbitmqctl")
QUERIES = {
"queues": [
"list_queues",
"-p",
"kf",
"name",
"durable",
"auto_delete",
"exclusive",
"type",
"state",
"arguments",
"messages_ready",
"messages_unacknowledged",
"message_bytes",
"consumers",
"policy",
"operator_policy",
"effective_policy_definition",
],
"vhost_limits": ["list_vhost_limits", "--global"],
"users": ["list_users"],
"permissions": ["list_permissions", "-p", "kf"],
}
CTL = broker_snapshot.CTL
def queue_arguments(value) -> dict:
if isinstance(value, dict):
result = value
elif isinstance(value, list) and len(value) == 4:
result = dict(value)
elif isinstance(value, list) and len(value) in (4, 5):
result = {}
for row in value:
if not isinstance(row, list) or len(row) != 3 or row[0] in result:
raise DeliveryError("duplicate/unknown broker argument")
name, kind, argument = row
if name not in dependencies.QUEUE_ARGUMENTS or kind != (
"longstr" if name == "x-overflow" else "long"
if name not in {*dependencies.QUEUE_ARGUMENTS, "x-queue-type"} or kind != (
"longstr" if name in {"x-overflow", "x-queue-type"} else "long"
):
raise DeliveryError("broker argument type differs")
result[name] = argument
else:
raise DeliveryError("broker argument shape differs")
if "x-queue-type" in result and result.pop("x-queue-type") != "classic":
raise DeliveryError("actual broker queue type differs")
if canonical(result) != canonical(dependencies.QUEUE_ARGUMENTS):
raise DeliveryError("actual broker queue budgets differ")
return result
@@ -148,49 +125,6 @@ def validate_snapshot(value: dict) -> dict:
}
def command(name: str):
if name not in QUERIES:
raise DeliveryError("unknown broker metadata query")
# A file-backed capped stream prevents malicious metadata/log output from
# allocating an unbounded capture buffer. The unit adds a hard file limit.
env = {
"PATH": "/usr/bin:/bin",
"LC_ALL": "C.UTF-8",
"HOME": dependencies.STORAGE["rabbitmq"]["path"],
"ERL_EPMD_ADDRESS": "127.0.0.1",
"RABBITMQ_NODENAME": "kf@localhost",
"RABBITMQ_CTL_ERL_ARGS": (
"+S 1:1 +A 2 +P 1024 +Q 1024 -kernel inet_dist_use_interface {127,0,0,1}"
),
}
with tempfile.TemporaryFile() as output:
try:
result = subprocess.run(
[
str(CTL),
"-q",
"-n",
"kf@localhost",
"--timeout",
"5",
*QUERIES[name],
"--formatter=json",
],
stdout=output,
stderr=subprocess.DEVNULL,
timeout=8,
env=env,
)
except subprocess.TimeoutExpired as error:
raise DeliveryError("broker " + name + " query exceeded deadline") from error
if result.returncode:
raise DeliveryError("broker " + name + " query failed")
if output.tell() > 32768:
raise DeliveryError("broker " + name + " query output exceeds bound")
output.seek(0)
return operator_units.decode(output.read(32769))
def observe() -> dict:
operator_units.guarded("broker-health")
preflight.protected(CTL)
@@ -202,8 +136,7 @@ def observe() -> dict:
or (info.st_uid, info.st_gid, stat.S_IMODE(info.st_mode)) != (1204, 1204, 0o400)
):
raise DeliveryError("ordinary private broker cookie required")
data = {name: command(name) for name in QUERIES}
return validate_snapshot(data)
return validate_snapshot(broker_snapshot.command())
def main() -> None:
+96
View File
@@ -0,0 +1,96 @@
"""One fixed read-only RabbitMQ 4.3.6 metadata RPC; no caller expressions."""
import subprocess
import tempfile
from pathlib import Path
from . import dependency_policy, operator_units
from .policy import DeliveryError, fields
CTL = Path("/usr/lib/rabbitmq/sbin/rabbitmqctl")
# Fixed getters only: no caller text, mutation, message reads or password hashes.
SNAPSHOT_EXPRESSION = r"""
rpc:call(node(), erlang, apply, [fun() ->
Count = rabbit_amqqueue:count(<<"kf">>),
true = (Count =:= 0 orelse Count =:= 1),
Normalize = fun
(name, {resource, <<"kf">>, queue, Value}) -> Value;
(arguments, Value) -> [[K, atom_to_binary(T, utf8), V] || {K,T,V} <- Value];
(type, rabbit_classic_queue) -> <<"classic">>;
(K, Value) when K =:= policy; K =:= operator_policy ->
case Value of undefined -> <<>>; none -> <<>>; '' -> <<>>; _ -> Value end;
(effective_policy_definition, Value) when is_list(Value) -> maps:from_list(Value);
(tags, Value) -> [case T of
A when is_atom(A) -> atom_to_binary(A,utf8); _ -> T end || T <- Value];
(_, Value) when is_atom(Value), Value =/= true, Value =/= false ->
atom_to_binary(Value,utf8);
(_, Value) -> Value
end,
ToMap = fun(Items) -> maps:from_list(
[{atom_to_binary(K,utf8),Normalize(K,V)} || {K,V} <- Items]) end,
Queues = [ToMap(Row) || Row <- rabbit_amqqueue:info_all(<<"kf">>,
[name,durable,auto_delete,exclusive,type,state,arguments,messages_ready,
messages_unacknowledged,message_bytes,consumers,policy,operator_policy,
effective_policy_definition])],
Limits = [#{<<"vhost">> => V,
<<"limits">> => iolist_to_binary(rabbit_json:encode(maps:from_list(L)))}
|| {V,L} <- rabbit_vhost_limit:list()],
Users = [ToMap(Row) || Row <- rabbit_auth_backend_internal:list_users()],
Permissions = [ToMap(Row) || Row <-
rabbit_auth_backend_internal:list_vhost_permissions(<<"kf">>)],
iolist_to_binary(rabbit_json:encode(#{<<"queues">> => Queues, <<"vhost_limits">> => Limits,
<<"users">> => Users, <<"permissions">> => Permissions}))
end, []], 5000).
""".strip()
def command() -> dict:
env = {
"PATH": "/usr/bin:/bin",
"LC_ALL": "C.UTF-8",
"HOME": dependency_policy.STORAGE["rabbitmq"]["path"],
"ERL_EPMD_ADDRESS": "127.0.0.1",
"RABBITMQ_NODENAME": "kf@localhost",
"RABBITMQ_CTL_ERL_ARGS": (
"+S 1:1 +A 2 +P 1024 +Q 1024 -kernel inet_dist_use_interface {127,0,0,1}"
),
}
with tempfile.TemporaryFile() as output:
try:
result = subprocess.run(
[
str(CTL),
"-q",
"-n",
"kf@localhost",
"--timeout",
"5",
"eval",
SNAPSHOT_EXPRESSION,
"--formatter=json",
],
stdout=output,
stderr=subprocess.DEVNULL,
timeout=30,
env=env,
)
except subprocess.TimeoutExpired as error:
raise DeliveryError("broker snapshot query exceeded deadline") from error
if result.returncode:
raise DeliveryError("broker snapshot query failed")
if output.tell() > 32768:
raise DeliveryError("broker snapshot query output exceeds bound")
output.seek(0)
value = operator_units.decode(output.read(32769))
fields(value, {"result", "node", "value"}, "broker snapshot envelope")
if (
value["result"] != "ok"
or value["node"] != "kf@localhost"
or not isinstance(value["value"], str)
):
raise DeliveryError("broker snapshot result or node differs")
return fields(
operator_units.decode(value["value"].encode()),
{"queues", "vhost_limits", "users", "permissions"},
"broker snapshot",
)
+1
View File
@@ -66,6 +66,7 @@ def binding() -> tuple[dict, dict, Path]:
"availability.py",
"node_health.py",
"broker_health.py",
"broker_snapshot.py",
"factory_health.py",
"import_health.py",
"operator_units.py",
+4 -4
View File
@@ -11,10 +11,10 @@ from .policy import DeliveryError, invalid_constant, unique_object
PROFILES = {
"node-health": ("root", 0, 128, 16, 50, 300),
"broker-health": ("rabbitmq", 1204, 256, 64, 50, 45),
"factory-health": ("kf-worker", 1202, 32, 4, 10, 30),
"import-health": ("kf-oj", 1200, 128, 16, 25, 60),
"outbox-health": ("kf-oj", 1200, 128, 16, 25, 60),
"broker-health": ("rabbitmq", 1204, 256, 64, 100, 45),
"factory-health": ("kf-worker", 1202, 32, 4, 100, 30),
"import-health": ("kf-oj", 1200, 128, 16, 100, 60),
"outbox-health": ("kf-oj", 1200, 128, 16, 100, 60),
"node-maintenance": ("root", 0, 256, 32, 50, 900),
"node-observation": ("root", 0, 128, 16, 25, 360),
}