fix(probe): use conservative global task headroom for admission
This commit is contained in:
parent
fcb0ea7f82
commit
7adf77a701
5 changed files with 400 additions and 33 deletions
|
|
@ -5,14 +5,18 @@ records the single 2026-10-17 namespace failure: EXPERIMENTAL /
|
|||
EGL_FX_PROBE_BLOCKED_BEFORE_HELPER / EGL_NOT_RUN / ALLOCATOR_NOT_ATTEMPTED.
|
||||
The historical READY_FOR_APPROVAL verdict preceded that consumed attempt.
|
||||
The [task-limit protocol](design/STEP20C_EGL_FX_TASK_LIMIT_PROTOCOL.md)
|
||||
records probe-local cgroup admission as EXPERIMENTAL / IMPLEMENTED_OFFLINE /
|
||||
OFFLINE_REVIEW_ACCEPTED, with inert validation only. Qualification against the real
|
||||
manager and new EGL runtime remain NOT_RUN. Separate approval is required for
|
||||
benign qualification, then for any new EGL attempt; no retry is authorized.
|
||||
The first benign nominal invocation refused in real-UID accounting before
|
||||
ATTEMPT or scope creation. The narrowly scoped UID count correction and its
|
||||
offline/read-only validation are recorded in that protocol; native admission
|
||||
and parent-death cleanup remain unqualified.
|
||||
records probe-local cgroup admission as EXPERIMENTAL / IMPLEMENTED_OFFLINE.
|
||||
Admission now checks inherited NPROC against a trusted host-global task upper
|
||||
bound before ATTEMPT and immediately before release; the strict real-UID census
|
||||
remains an optional historical diagnostic. The earlier ENOENT and process-set
|
||||
change refusals are preserved. The current delta has 99 passing inert tests,
|
||||
14 unchanged observer tests and one successful read-only preflight; two fresh
|
||||
sealed LABs pass passive CLI planning without ATTEMPT, scope or payload.
|
||||
Independent delta review is ACCEPTED; final audit is PASS / ACCEPTED, with no
|
||||
blocking finding. Disposition: CGROUP_GLOBAL_HEADROOM_READY_FOR_BENIGN_APPROVAL,
|
||||
source-only. Real manager admission, parent-death cleanup and new EGL runtime
|
||||
remain NOT_RUN. Separate approval is required for benign qualification, then
|
||||
for any new EGL attempt; no retry is authorized.
|
||||
|
||||
[Gate 2 headless boot](design/STEP20C_HEADLESS_BOOT_GATE2.md) records the single
|
||||
V5 native failure: EXPERIMENTAL / PRISTINE_ALLOCATOR_BLOCKED / CORE_NOT_RUN,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
# STEP20C — EGL/FX task-limit protocol design — 2026-10-17
|
||||
|
||||
Status: **EXPERIMENTAL / IMPLEMENTED_OFFLINE / OFFLINE_REVIEW_ACCEPTED**.
|
||||
Status: **EXPERIMENTAL / IMPLEMENTED_OFFLINE**. Historical offline review is
|
||||
accepted; independent review of the current global-headroom delta is accepted.
|
||||
The final audit of this delta is **PASS / ACCEPTED**, with no blocking finding.
|
||||
New runtime: **NOT_RUN**. No scope, Bubblewrap, EGL/FX helper or compositor is
|
||||
launched by this mission. The source-only runner now implements the gate;
|
||||
qualification against the real manager remains separately approval-gated.
|
||||
|
|
@ -77,6 +79,75 @@ manifest/fixture hashes and historical preservation. Disposition:
|
|||
**CGROUP_UID_COUNT_FIX_READY_FOR_BENIGN_APPROVAL**, source-only. Publication of
|
||||
the correction does not authorize the three future native qualification commands.
|
||||
|
||||
## Conservative global headroom fast path — 2026-10-17
|
||||
|
||||
The admission path replaces the fragile all-PID census with the system task
|
||||
upper bound in field four (`runnable/total`) of Linux `proc_loadavg(5)`.
|
||||
Every individual real UID's thread count is bounded by that host total at the
|
||||
sample. The minimum finite inherited NPROC soft/hard limit must be **strictly
|
||||
greater than total + 256**; infinity is retained, never replaced or raised.
|
||||
Both read-only preflight (before ATTEMPT) and admission immediately before
|
||||
release perform this check. Counts may change between checks: no PID-set or
|
||||
loadavg equality is required, but each observation must independently leave
|
||||
the full reserve. This is no atomic reservation against future task creation;
|
||||
the kernel still enforces NPROC and the owned cgroup must still prove exact
|
||||
finite `pids.max=256`. Mixed-UID helpers do not invalidate the upper bound.
|
||||
|
||||
Trust qualification is deliberately Linux-specific and fail-closed: the exact
|
||||
`/proc` path must be a unique root procfs mount, with no overmount affecting
|
||||
loadavg. Initial self PID/user namespace identities (`4026531836`/`4026531837`),
|
||||
the full initial UID map and a singleton self `NSpid` equal to the supervisor PID
|
||||
are required. PID1 namespace links may be ptrace-protected and are not needed. These
|
||||
recognized Linux identities are a conservative qualification restriction, not
|
||||
a portable namespace discovery guarantee. The `/proc` root and `loadavg` must
|
||||
be root-owned; process metadata such as `self/status` may legitimately be
|
||||
user-owned. Writable files, symlinks, device/inode drift, malformed or oversized
|
||||
data, unavailable context or any ambiguous source refuse admission. Reads use bounded no-follow FDs and
|
||||
qualify the context before and after the sample. Trusted kernel/root mount
|
||||
administration remains an assumption; hostile root is outside this protocol.
|
||||
|
||||
`uid_threads()` remains the strict optional historical diagnostic, outside
|
||||
admission. The earlier ENOENT and `process creation/end` refusals above remain
|
||||
historical facts. `ProcFS.identity()`, bootstrap/manager ownership, pidfds,
|
||||
barrier, cleanup and all cgroup controls retain their contracts. Offline tests
|
||||
cannot establish `CGROUP_NATIVE_PASS`; real qualification remains **NOT_RUN**.
|
||||
|
||||
### Current global-headroom validation — 2026-10-17
|
||||
|
||||
The inert Python suite passes **99 tests** (86 historical plus 13 new cases).
|
||||
The unchanged private parent-death observer independently passes its **14
|
||||
deterministic tests**. Neither suite launches a real scope or payload.
|
||||
|
||||
Exactly one real read-only preflight ran through Desktop Commander in the
|
||||
ArchASP app.slice context at **2026-10-17T06:57:21.960482+00:00**
|
||||
(08:57:21 CEST). It passed with inherited NPROC soft/hard **60706/60706**,
|
||||
one global task sample of **1439**, reserve **256** and observed remaining
|
||||
margin **59011**. These values describe that sample only; capacity is not
|
||||
reserved. Only `systemctl show` child commands were allowed. No ATTEMPT,
|
||||
scope or payload was created; native qualification remains **NOT_RUN**.
|
||||
|
||||
Two new private 0700 LABs have distinct nonces and exact sealed source copies,
|
||||
with **47 file identities / 39 ELF records each**. Their isolated passive CLI
|
||||
plans pass through Desktop Commander without `launcher_error`, writes,
|
||||
ATTEMPT, socket or result creation. The historical UID-correction LAB inventories
|
||||
remain unchanged. Private paths, nonces, process identifiers and raw evidence
|
||||
are retained outside public documentation.
|
||||
|
||||
Independent delta review is **ACCEPTED**, with no blocking finding; it
|
||||
independently reproduces **99/99** tests and **72 targeted tests**, with AST
|
||||
invariants and Python style checks passing. The final audit is **PASS / ACCEPTED**,
|
||||
with no blocking finding: it independently reproduces **99/99 + 14/14** tests,
|
||||
confirms 13 sensitive symbols/classes unchanged by AST, both LAB inventories
|
||||
and fixture hashes, identical historical inventories and passive CLI exit 0 /
|
||||
NOT_RUN. Audited source hashes matched the reviewed-file receipt before this
|
||||
factual documentation closeout.
|
||||
|
||||
Disposition: **CGROUP_GLOBAL_HEADROOM_READY_FOR_BENIGN_APPROVAL**, source-only.
|
||||
Native qualification remains **NOT_RUN**; no `CGROUP_NATIVE_PASS` follows.
|
||||
Fresh conversational approval is required before nominal or parent-death
|
||||
execution; EGL needs another fresh LAB, review and approval. STEP20C remains
|
||||
NEXT and production multi-output remains NOT_YET_SUPPORTED.
|
||||
|
||||
## Evidence, guarantees and admission
|
||||
|
||||
| Route / evidence | Risk | Bound actually supplied | Admission condition / disposition |
|
||||
|
|
@ -91,9 +162,10 @@ the correction does not authorize the three future native qualification commands
|
|||
| Closed mount/environment/device/FD policy | Dlopen drift, inherited socket or control-plane leak | Fixed exposure and sealed inputs, not total resource containment | Preserve pins, ABI, manifest closure and exact allowlists; no new host device/mount |
|
||||
|
||||
Linux [RLIMIT_NPROC documentation](https://man7.org/linux/man-pages/man2/getrlimit.2.html)
|
||||
explains the real-UID thread limit and EAGAIN at creation. A procfs scan is a
|
||||
refusal diagnostic, not an atomic reservation: disappearing entries, unreadable
|
||||
status, UID changes or inconsistent thread counts must be reported explicitly.
|
||||
explains the real-UID thread limit and EAGAIN at creation. The retained optional
|
||||
procfs scan is a strict diagnostic, not an admission prerequisite or atomic
|
||||
reservation: disappearing entries, unreadable status, UID changes or inconsistent
|
||||
thread counts must be reported explicitly.
|
||||
The [kernel cgroup v2 contract](https://docs.kernel.org/admin-guide/cgroup-v2.html#pid)
|
||||
counts TIDs hierarchically and rejects task creation over the limit. Migration
|
||||
can exceed pids.max, so a verified empty private group and controlled admission
|
||||
|
|
@ -132,9 +204,9 @@ Independent code review and native qualification are separate gates:
|
|||
collecting agent. No cgroup mount, directory FD or user bus reaches payload.
|
||||
4. After cgroup admission, omit only the explicit NPROC=256 override, retain
|
||||
the inherited UID soft/hard limits and all other prlimit settings. Record
|
||||
inherited NPROC, real UID, diagnostic thread-count provenance and effective
|
||||
cgroup properties; any known lower-than-UID-load NPROC refuses explicitly.
|
||||
Count uncertainty/drift cannot be accepted as a proof of headroom. No fallback
|
||||
inherited NPROC, real UID, trusted global task total, full reserve and effective
|
||||
cgroup properties; minimum finite NPROC <= total + 256 refuses explicitly.
|
||||
Missing or ambiguous host samples cannot prove headroom. No fallback
|
||||
to a larger NPROC, unlimited task count, unconfined launch or new device.
|
||||
5. Reserve the new single attempt durably with O_EXCL before creating its
|
||||
scope/child or otherwise mutating native execution state. A collision,
|
||||
|
|
|
|||
|
|
@ -17,10 +17,15 @@ nouvelle autorisation conversationnelle avec leur propre LAB et manifeste.
|
|||
Le premier essai bénin nominal du 17 octobre 2026 a refusé avant ATTEMPT,
|
||||
création de scope ou payload : `UID process divergent` dans le diagnostic
|
||||
`ProcFS.uid_threads()`. Les deux LABs bénins historiques restent non consommés
|
||||
et ne doivent pas être réutilisés. Le comptage corrigé inclut les threads de
|
||||
et ne doivent pas être réutilisés. Le diagnostic historique corrigé inclut les threads de
|
||||
tous les processus dont l'UID réel correspond, même avec des UID effectif,
|
||||
sauvé ou filesystem différents. Il refuse toute incohérence observée ; ce
|
||||
snapshot ne réserve aucune capacité. `ProcFS.identity()` conserve ses contrôles
|
||||
sauvé ou filesystem différents. Il reste disponible hors chemin d'admission.
|
||||
L'admission lit désormais la borne système `runnable/total` de `/proc/loadavg`
|
||||
après qualification conservative du procfs hôte Linux et de ses namespaces
|
||||
initiaux ; les contextes ambigus refusent. Le minimum soft/hard NPROC fini doit
|
||||
être strictement supérieur au total plus256, avant ATTEMPT et avant release.
|
||||
Les valeurs peuvent fluctuer sans égalité entre snapshots si chaque marge reste
|
||||
suffisante. Aucun comptage ne réserve atomiquement de capacité. `ProcFS.identity()` conserve ses contrôles
|
||||
stricts pour le superviseur, le manager et le bootstrap. Les régressions restent
|
||||
hors ligne ; cette correction n'établit aucun PASS cgroup natif.
|
||||
|
||||
|
|
@ -135,8 +140,8 @@ le plan : aucun processus, fichier ATTEMPT, socket ni appel manager. Le helper
|
|||
Python est chargé par chemin absolu, après vérification de son hash, même avec
|
||||
`python3 -I`. Avec approbation, le préflight refuse un vrai launcher en session
|
||||
scope, un manager non délégué, app.slice inactif, un montage non cgroup2/domain,
|
||||
un ancêtre non writable, pids absent, un headroom insuffisant, un diagnostic
|
||||
UID incertain ou NPROC hérité sous la charge UID. Aucun snapshot /proc ne
|
||||
un ancêtre non writable, pids absent, un headroom insuffisant, une source
|
||||
loadavg non fiable ou NPROC hérité <= total système +256. Aucun snapshot /proc ne
|
||||
constitue une réservation. Aucun processus n'est migré par le superviseur.
|
||||
|
||||
Après ATTEMPT durable O_EXCL, systemd-run crée un seul scope et lance le
|
||||
|
|
|
|||
|
|
@ -151,6 +151,91 @@ class ProcFS:
|
|||
except (OSError, ValueError, KeyError, IndexError) as error:
|
||||
raise Refusal(f"proc diagnostic illisible/disparu: {error}") from error
|
||||
|
||||
def bounded_text(self, relative, limit, root_owned=False):
|
||||
"""Lire sans suivre le fichier final ; refuser troncature et remplacement."""
|
||||
path = self.root / relative
|
||||
descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC)
|
||||
try:
|
||||
before = os.fstat(descriptor)
|
||||
require(stat.S_ISREG(before.st_mode) and not before.st_mode & 0o022,
|
||||
"source proc modifiable/non régulière")
|
||||
require(not root_owned or before.st_uid == 0,
|
||||
"source proc non root")
|
||||
data = os.read(descriptor, limit + 1)
|
||||
require(len(data) <= limit and os.read(descriptor, 1) == b"",
|
||||
"source proc trop longue")
|
||||
after = path.stat(follow_symlinks=False)
|
||||
require((before.st_dev, before.st_ino) == (after.st_dev, after.st_ino),
|
||||
"source proc remplacée")
|
||||
return data.decode("ascii")
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
def host_context(self):
|
||||
"""Qualifier seulement le procfs hôte Linux reconnu, sinon refuser."""
|
||||
require(self.root == Path("/proc") and self.root.resolve() == self.root,
|
||||
"source proc hors chemin hôte")
|
||||
root = self.root.stat()
|
||||
require(root.st_uid == 0 and not root.st_mode & 0o022,
|
||||
"racine proc non fiable")
|
||||
mounts = self.bounded_text("self/mountinfo", LIMIT)
|
||||
matches = []
|
||||
for line in mounts.splitlines():
|
||||
left, right = line.split(" - ", 1)
|
||||
fields, source = left.split(), right.split()
|
||||
require(len(fields) >= 6 and len(source) == 3,
|
||||
"mountinfo proc invalide")
|
||||
target = fields[4]
|
||||
if target == "/proc":
|
||||
require(fields[3] == "/" and source[0] == "proc"
|
||||
and source[1] == "proc", "montage proc hôte requis")
|
||||
matches.append((fields[0], fields[2]))
|
||||
else:
|
||||
require(not any(target == name or target.startswith(name + "/")
|
||||
for name in ("/proc/loadavg", "/proc/self", "/proc/1")),
|
||||
"surmonture source proc refusée")
|
||||
require(len(matches) == 1, "montage proc ambigu/absent")
|
||||
require(matches[0][1] == f"{os.major(root.st_dev)}:{os.minor(root.st_dev)}",
|
||||
"device proc divergent")
|
||||
# WHY CONTRACT INVARIANT: recognizing Linux initial self namespace
|
||||
# inodes avoids confusing two equally nested namespaces with the host.
|
||||
# PID1 namespace links may be ptrace-protected for ordinary users; the
|
||||
# singleton self NSpid additionally binds this procfs PID view to us.
|
||||
namespaces = []
|
||||
for kind, initial in (("pid", 4026531836), ("user", 4026531837)):
|
||||
link = os.readlink(self.root / "self" / "ns" / kind)
|
||||
require(link == f"{kind}:[{initial}]", "namespace proc non initial")
|
||||
namespaces.append(link)
|
||||
uid_map = self.bounded_text("self/uid_map", 256).split()
|
||||
require(uid_map == ["0", "0", "4294967295"], "UID map non initiale")
|
||||
status = self.bounded_text("self/status", 65536).splitlines()
|
||||
nspids = [line.split()[1:] for line in status if line.startswith("NSpid:")]
|
||||
require(nspids == [[str(os.getpid())]], "vue PID proc non hôte")
|
||||
loadavg = (self.root / "loadavg").stat(follow_symlinks=False)
|
||||
require(loadavg.st_dev == root.st_dev, "device loadavg divergent")
|
||||
return (root.st_dev, root.st_ino, tuple(matches), tuple(namespaces),
|
||||
loadavg.st_ino)
|
||||
|
||||
def global_tasks(self):
|
||||
"""Borne système ponctuelle ; aucun parcours PID ni réservation."""
|
||||
try:
|
||||
before = self.host_context()
|
||||
text = self.bounded_text("loadavg", 256, root_owned=True)
|
||||
# WHY CONTRACT INVARIANT: bounded ASCII full-match rejects partial,
|
||||
# extra and malformed fields rather than accepting an undercount.
|
||||
match = re.fullmatch(
|
||||
r"[0-9]{1,10}\.[0-9]{2} [0-9]{1,10}\.[0-9]{2} "
|
||||
r"[0-9]{1,10}\.[0-9]{2} ([0-9]{1,10})/([0-9]{1,10}) "
|
||||
r"[0-9]{1,10}\n", text)
|
||||
require(match is not None, "loadavg format invalide")
|
||||
runnable, total = map(int, match.groups())
|
||||
require(0 <= runnable <= total and total > 0,
|
||||
"loadavg compte incohérent")
|
||||
require(self.host_context() == before, "contexte proc divergent")
|
||||
return total
|
||||
except (OSError, UnicodeError, ValueError, IndexError) as error:
|
||||
raise Refusal(f"source loadavg non fiable/illisible: {error}") from error
|
||||
|
||||
def nproc(self):
|
||||
return resource.getrlimit(resource.RLIMIT_NPROC)
|
||||
|
||||
|
|
@ -334,6 +419,23 @@ def capacity(cgroups, app):
|
|||
return records
|
||||
|
||||
|
||||
def nproc_headroom(proc):
|
||||
"""Vérifier la réserve entière contre la borne système, sans muter NPROC."""
|
||||
limits = proc.nproc()
|
||||
require(len(limits) == 2 and all(
|
||||
isinstance(value, int) and (value == resource.RLIM_INFINITY or value >= 0)
|
||||
for value in limits), "RLIMIT_NPROC invalide")
|
||||
total = proc.global_tasks()
|
||||
require(isinstance(total, int) and total > 0, "charge système invalide")
|
||||
finite = [value for value in limits if value != resource.RLIM_INFINITY]
|
||||
# WHY CONTRACT INVARIANT: the system total bounds any real UID, including
|
||||
# mixed credentials. Strict > reserves TASKS at each sample, never atomically.
|
||||
require(not finite or min(finite) > total + TASKS,
|
||||
f"inherited RLIMIT_NPROC insuffisant : {limits}, total={total}, "
|
||||
f"réserve={TASKS}")
|
||||
return {"nproc": limits, "global_tasks": total, "required_headroom": TASKS}
|
||||
|
||||
|
||||
def preflight(proc, cgroups, manager, uid, pid, deadline):
|
||||
"""Toutes les observations précèdent ATTEMPT et la première mutation."""
|
||||
require(uid != 0 and uid == os.geteuid(), "UID réel/effectif invalide")
|
||||
|
|
@ -360,13 +462,10 @@ def preflight(proc, cgroups, manager, uid, pid, deadline):
|
|||
controllers, enabled, writable = cgroups.available(app)
|
||||
require("pids" in controllers and "pids" in enabled, "contrôleur pids absent")
|
||||
require(writable, "ancêtre commun non writable")
|
||||
limits = proc.nproc()
|
||||
load = proc.uid_threads(uid)
|
||||
require(all(value == resource.RLIM_INFINITY or value >= load for value in limits),
|
||||
f"inherited RLIMIT_NPROC insuffisant : {limits}, charge UID={load}")
|
||||
headroom = nproc_headroom(proc)
|
||||
return {"launcher": launcher, "manager": owner, "manager_process": manager_process,
|
||||
"mount": mount, "app": app, "uid": uid, "nproc": limits,
|
||||
"uid_threads_diagnostic": load, "ancestors": capacity(cgroups, app)}
|
||||
"mount": mount, "app": app, "uid": uid, **headroom,
|
||||
"ancestors": capacity(cgroups, app)}
|
||||
|
||||
|
||||
def same_lifetime(first, second):
|
||||
|
|
@ -507,12 +606,11 @@ def admit(spec, evidence, pid, proc, cgroups, manager, deadline):
|
|||
observed_manager = proc.identity(evidence["manager_process"]["pid"])
|
||||
require(observed_manager == evidence["manager_process"],
|
||||
"manager PID/startticks divergent")
|
||||
load = proc.uid_threads(evidence["uid"])
|
||||
require(proc.nproc() == evidence["nproc"] and all(
|
||||
value == resource.RLIM_INFINITY or value >= load for value in proc.nproc()),
|
||||
"NPROC/charge UID divergent avant release")
|
||||
headroom = nproc_headroom(proc)
|
||||
require(headroom["nproc"] == evidence["nproc"],
|
||||
"NPROC hérité divergent avant release")
|
||||
return {"properties": observed, "process": identity, "cgroup": group,
|
||||
"kernel": record, "pidfd": pidfd}
|
||||
"kernel": record, "pidfd": pidfd, "headroom": headroom}
|
||||
except BaseException:
|
||||
os.close(pidfd)
|
||||
raise
|
||||
|
|
@ -679,7 +777,8 @@ def run_scope(lab, manifest, reserve, journal, proc=None, cgroups=None,
|
|||
# PRECHECK is not journaled until ATTEMPT: failed preflight stays read-only.
|
||||
reserve(evidence)
|
||||
phase("PRECHECK", nproc=evidence["nproc"],
|
||||
uid_threads=evidence["uid_threads_diagnostic"])
|
||||
global_tasks=evidence["global_tasks"],
|
||||
required_headroom=evidence["required_headroom"])
|
||||
barrier = barrier_factory(lab / "gate.sock", spec["nonce"], os.getuid(),
|
||||
os.getpid(), started + 5)
|
||||
identity = evidence["launcher"]
|
||||
|
|
|
|||
|
|
@ -63,6 +63,9 @@ class FakeProc:
|
|||
return dict(self.identities[pid])
|
||||
|
||||
def uid_threads(self, uid):
|
||||
raise AssertionError("diagnostic PID interdit dans admission")
|
||||
|
||||
def global_tasks(self):
|
||||
return self.load
|
||||
|
||||
def nproc(self):
|
||||
|
|
@ -347,6 +350,159 @@ class UIDThreadTests(unittest.TestCase):
|
|||
self.proc.uid_threads(1000)
|
||||
|
||||
|
||||
class GlobalHeadroomTests(unittest.TestCase):
|
||||
"""Vrai parseur/qualificateur, entrées simulées ; aucun proc hôte lu."""
|
||||
|
||||
SAMPLE = "0.00 1.23 12.34 2/1415 1234\n"
|
||||
|
||||
def setUp(self):
|
||||
self.proc = gate.ProcFS()
|
||||
self.context = patch.object(self.proc, "host_context", return_value=(1, 2))
|
||||
self.context.start()
|
||||
self.addCleanup(self.context.stop)
|
||||
self.reader = patch.object(self.proc, "bounded_text", return_value=self.SAMPLE)
|
||||
self.read = self.reader.start()
|
||||
self.addCleanup(self.reader.stop)
|
||||
|
||||
def test_actual_controller_observed_example_and_resource_injection(self):
|
||||
with patch.object(resource, "getrlimit", return_value=(60706, 60706)) as limits:
|
||||
self.assertEqual(gate.nproc_headroom(self.proc)["global_tasks"], 1415)
|
||||
limits.assert_called_once_with(resource.RLIMIT_NPROC)
|
||||
|
||||
def test_malformed_truncated_extra_or_inconsistent_loadavg_refused(self):
|
||||
invalid = ("", "0.00 1.23 12.34", self.SAMPLE.rstrip(),
|
||||
self.SAMPLE + "extra\n", self.SAMPLE.replace("2/1415", "x/1415"),
|
||||
self.SAMPLE.replace("2/1415", "-1/1415"),
|
||||
self.SAMPLE.replace("2/1415", "0/0"),
|
||||
self.SAMPLE.replace("2/1415", "1416/1415"),
|
||||
self.SAMPLE.replace("2/1415", "2/" + "9" * 30))
|
||||
for text in invalid:
|
||||
with self.subTest(text=text):
|
||||
self.read.return_value = text
|
||||
with self.assertRaises(gate.Refusal):
|
||||
self.proc.global_tasks()
|
||||
|
||||
def test_zero_runnable_is_valid(self):
|
||||
self.read.return_value = self.SAMPLE.replace("2/1415", "0/1415")
|
||||
self.assertEqual(self.proc.global_tasks(), 1415)
|
||||
|
||||
def test_missing_unreadable_nonascii_or_changed_context_refuses(self):
|
||||
for error in (FileNotFoundError("missing"), PermissionError("denied"),
|
||||
UnicodeError("non ascii")):
|
||||
with self.subTest(error=error), patch.object(
|
||||
self.proc, "bounded_text", side_effect=error):
|
||||
with self.assertRaises(gate.Refusal):
|
||||
self.proc.global_tasks()
|
||||
with patch.object(self.proc, "host_context", side_effect=[(1,), (2,)]):
|
||||
with self.assertRaisesRegex(gate.Refusal, "contexte"):
|
||||
self.proc.global_tasks()
|
||||
|
||||
def test_finite_minimum_requires_strict_full_reserve(self):
|
||||
for limits in ((1671, 60706), (60706, 1671), (1415, 1415), (0, 0),
|
||||
(resource.RLIM_INFINITY, 1671), (1671, resource.RLIM_INFINITY)):
|
||||
with self.subTest(limits=limits), patch.object(
|
||||
self.proc, "nproc", return_value=limits):
|
||||
with self.assertRaisesRegex(gate.Refusal, "RLIMIT_NPROC"):
|
||||
gate.nproc_headroom(self.proc)
|
||||
for limits in ((1672, 60706), (resource.RLIM_INFINITY, 1672),
|
||||
(resource.RLIM_INFINITY, resource.RLIM_INFINITY)):
|
||||
with self.subTest(limits=limits), patch.object(
|
||||
self.proc, "nproc", return_value=limits):
|
||||
self.assertEqual(gate.nproc_headroom(self.proc)["nproc"], limits)
|
||||
|
||||
def test_bounded_fd_reader_rejects_symlink_and_oversize(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
proc = gate.ProcFS(root)
|
||||
(root / "data").write_text("x" * 257)
|
||||
with self.assertRaises(gate.Refusal):
|
||||
proc.bounded_text("data", 256)
|
||||
(root / "link").symlink_to(root / "data")
|
||||
with self.assertRaises(OSError):
|
||||
proc.bounded_text("link", 256)
|
||||
|
||||
def test_fd_reader_rejects_writable_foreign_or_replaced_source(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
path = root / "data"
|
||||
path.write_text(self.SAMPLE)
|
||||
proc = gate.ProcFS(root)
|
||||
path.chmod(0o666)
|
||||
with self.assertRaisesRegex(gate.Refusal, "modifiable"):
|
||||
proc.bounded_text("data", 256)
|
||||
path.chmod(0o444)
|
||||
info = path.stat()
|
||||
foreign = list(info)
|
||||
foreign[4] = 1001
|
||||
with patch.object(gate.os, "fstat", return_value=os.stat_result(foreign)):
|
||||
with self.assertRaisesRegex(gate.Refusal, "non root"):
|
||||
proc.bounded_text("data", 256, root_owned=True)
|
||||
replaced = list(info)
|
||||
replaced[1] += 1
|
||||
with patch.object(Path, "stat", return_value=os.stat_result(replaced)):
|
||||
with self.assertRaisesRegex(gate.Refusal, "remplacée"):
|
||||
proc.bounded_text("data", 256)
|
||||
|
||||
def test_invalid_inherited_limits_refuse(self):
|
||||
for limits in ((-2, 4096), ("4096", 4096), (4096,)):
|
||||
with self.subTest(limits=limits), patch.object(
|
||||
self.proc, "nproc", return_value=limits):
|
||||
with self.assertRaisesRegex(gate.Refusal, "RLIMIT_NPROC invalide"):
|
||||
gate.nproc_headroom(self.proc)
|
||||
|
||||
def test_actual_host_qualification_refuses_untrusted_contexts(self):
|
||||
# Stop parser-level mocks: exercise the actual context qualification.
|
||||
self.context.stop()
|
||||
device = os.makedev(0, 5)
|
||||
info = os.stat_result((0o40555, 10, device, 1, 0, 0, 0, 0, 0, 0))
|
||||
mount = "1 0 0:5 / /proc rw - proc proc rw\n"
|
||||
|
||||
def text(name, *_args, **_kwargs):
|
||||
if name == "self/mountinfo":
|
||||
return mount
|
||||
if name == "self/status":
|
||||
return f"Name: fixture\nNSpid: {PID}\n"
|
||||
return "0 0 4294967295\n"
|
||||
|
||||
def namespace(path):
|
||||
return ("pid:[4026531836]" if path.name == "pid"
|
||||
else "user:[4026531837]")
|
||||
|
||||
with (patch.object(Path, "stat", return_value=info),
|
||||
patch.object(Path, "resolve", lambda path: path),
|
||||
patch.object(self.proc, "bounded_text", side_effect=text),
|
||||
patch.object(gate.os, "readlink", side_effect=namespace)):
|
||||
self.proc.host_context()
|
||||
for bad in (mount.replace(" - proc proc", " - fuse.lxcfs lxcfs"),
|
||||
mount + mount,
|
||||
mount.replace(" / /proc ", " /sub /proc "),
|
||||
mount + "2 1 0:5 / /proc/loadavg rw - proc proc rw\n"):
|
||||
with self.subTest(mount=bad):
|
||||
original, mount = mount, bad
|
||||
with self.assertRaises(gate.Refusal):
|
||||
self.proc.host_context()
|
||||
mount = original
|
||||
with patch.object(gate.os, "readlink", return_value="pid:[123]"):
|
||||
with self.assertRaisesRegex(gate.Refusal, "namespace"):
|
||||
self.proc.host_context()
|
||||
with patch.object(gate.os, "readlink", side_effect=PermissionError("self")):
|
||||
with self.assertRaises(PermissionError):
|
||||
self.proc.host_context()
|
||||
for nspid in ("", f"NSpid: {PID} 1\n", f"NSpid: {PID + 1}\n",
|
||||
f"NSpid: {PID}\nNSpid: {PID}\n"):
|
||||
with self.subTest(nspid=nspid), patch.object(
|
||||
self.proc, "bounded_text", side_effect=lambda name, *_a:
|
||||
nspid if name == "self/status" else text(name)):
|
||||
with self.assertRaisesRegex(gate.Refusal, "vue PID"):
|
||||
self.proc.host_context()
|
||||
with patch.object(self.proc, "bounded_text", side_effect=lambda name, *_a:
|
||||
mount if name == "self/mountinfo" else "0 1000 1\n"):
|
||||
with self.assertRaisesRegex(gate.Refusal, "UID map"):
|
||||
self.proc.host_context()
|
||||
with self.assertRaisesRegex(gate.Refusal, "chemin hôte"):
|
||||
gate.ProcFS(Path("/tmp/proc")).host_context()
|
||||
|
||||
|
||||
class AdmissionTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.sentinel = patch.object(
|
||||
|
|
@ -400,6 +556,37 @@ class AdmissionTests(unittest.TestCase):
|
|||
"PAYLOAD_STARTED", "CLEANED"])
|
||||
self.assertEqual(self.proc.limits, (4096, 4096))
|
||||
|
||||
def test_global_load_fluctuation_does_not_require_pid_census(self):
|
||||
with patch.object(self.proc, "global_tasks", side_effect=[1415, 1500]):
|
||||
result, _ = self.execute()
|
||||
self.assertEqual(result["cause"], "exit")
|
||||
self.assertEqual(self.barrier.released, 1)
|
||||
self.assertEqual(self.reservations[0]["global_tasks"], 1415)
|
||||
|
||||
def test_rising_global_load_refuses_release(self):
|
||||
with patch.object(self.proc, "global_tasks", side_effect=[1415, 3840]):
|
||||
result, _ = self.execute()
|
||||
self.assertIn("RLIMIT_NPROC", result["error"])
|
||||
self.assertEqual(self.barrier.released, 0)
|
||||
self.assertEqual(len(self.reservations), 1)
|
||||
|
||||
def test_failed_capacity_preflight_has_no_attempt_or_native_spawn(self):
|
||||
self.proc.load = 4096
|
||||
result, _ = self.execute()
|
||||
self.assertIn("RLIMIT_NPROC", result["error"])
|
||||
self.assertEqual(list(self.lab.iterdir()), [])
|
||||
self.assertEqual(self.reservations, [])
|
||||
self.assertEqual(self.journal, [])
|
||||
self.assertEqual(self.manager.created, 0)
|
||||
|
||||
def test_changed_inherited_nproc_before_release_refuses(self):
|
||||
evidence = self.preflight()
|
||||
self.cgroups.present = True
|
||||
self.proc.limits = (5000, 5000)
|
||||
with self.assertRaisesRegex(gate.Refusal, "hérité divergent"):
|
||||
gate.admit(SPEC, evidence, 43, self.proc, self.cgroups, self.manager,
|
||||
time.monotonic() + 1)
|
||||
|
||||
def test_session_scope_refused_before_reservation(self):
|
||||
self.proc.identities[PID]["cgroup"] = (
|
||||
f"/user.slice/user-{UID}.slice/session-6.scope")
|
||||
|
|
@ -545,7 +732,7 @@ class AdmissionTests(unittest.TestCase):
|
|||
|
||||
def test_failures_in_preflight_reservation_admission_and_drain(self):
|
||||
for error in (OSError("read failure"), KeyboardInterrupt()):
|
||||
with patch.object(self.proc, "uid_threads", side_effect=error):
|
||||
with patch.object(self.proc, "global_tasks", side_effect=error):
|
||||
result, _ = self.execute()
|
||||
self.assertNotEqual(result["cause"], "exit")
|
||||
self.assertEqual(self.manager.created, 0)
|
||||
|
|
|
|||
Loading…
Reference in a new issue