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.
|
EGL_FX_PROBE_BLOCKED_BEFORE_HELPER / EGL_NOT_RUN / ALLOCATOR_NOT_ATTEMPTED.
|
||||||
The historical READY_FOR_APPROVAL verdict preceded that consumed attempt.
|
The historical READY_FOR_APPROVAL verdict preceded that consumed attempt.
|
||||||
The [task-limit protocol](design/STEP20C_EGL_FX_TASK_LIMIT_PROTOCOL.md)
|
The [task-limit protocol](design/STEP20C_EGL_FX_TASK_LIMIT_PROTOCOL.md)
|
||||||
records probe-local cgroup admission as EXPERIMENTAL / IMPLEMENTED_OFFLINE /
|
records probe-local cgroup admission as EXPERIMENTAL / IMPLEMENTED_OFFLINE.
|
||||||
OFFLINE_REVIEW_ACCEPTED, with inert validation only. Qualification against the real
|
Admission now checks inherited NPROC against a trusted host-global task upper
|
||||||
manager and new EGL runtime remain NOT_RUN. Separate approval is required for
|
bound before ATTEMPT and immediately before release; the strict real-UID census
|
||||||
benign qualification, then for any new EGL attempt; no retry is authorized.
|
remains an optional historical diagnostic. The earlier ENOENT and process-set
|
||||||
The first benign nominal invocation refused in real-UID accounting before
|
change refusals are preserved. The current delta has 99 passing inert tests,
|
||||||
ATTEMPT or scope creation. The narrowly scoped UID count correction and its
|
14 unchanged observer tests and one successful read-only preflight; two fresh
|
||||||
offline/read-only validation are recorded in that protocol; native admission
|
sealed LABs pass passive CLI planning without ATTEMPT, scope or payload.
|
||||||
and parent-death cleanup remain unqualified.
|
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
|
[Gate 2 headless boot](design/STEP20C_HEADLESS_BOOT_GATE2.md) records the single
|
||||||
V5 native failure: EXPERIMENTAL / PRISTINE_ALLOCATOR_BLOCKED / CORE_NOT_RUN,
|
V5 native failure: EXPERIMENTAL / PRISTINE_ALLOCATOR_BLOCKED / CORE_NOT_RUN,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
# STEP20C — EGL/FX task-limit protocol design — 2026-10-17
|
# 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
|
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;
|
launched by this mission. The source-only runner now implements the gate;
|
||||||
qualification against the real manager remains separately approval-gated.
|
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
|
**CGROUP_UID_COUNT_FIX_READY_FOR_BENIGN_APPROVAL**, source-only. Publication of
|
||||||
the correction does not authorize the three future native qualification commands.
|
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
|
## Evidence, guarantees and admission
|
||||||
|
|
||||||
| Route / evidence | Risk | Bound actually supplied | Admission condition / disposition |
|
| 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 |
|
| 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)
|
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
|
explains the real-UID thread limit and EAGAIN at creation. The retained optional
|
||||||
refusal diagnostic, not an atomic reservation: disappearing entries, unreadable
|
procfs scan is a strict diagnostic, not an admission prerequisite or atomic
|
||||||
status, UID changes or inconsistent thread counts must be reported explicitly.
|
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)
|
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
|
counts TIDs hierarchically and rejects task creation over the limit. Migration
|
||||||
can exceed pids.max, so a verified empty private group and controlled admission
|
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.
|
collecting agent. No cgroup mount, directory FD or user bus reaches payload.
|
||||||
4. After cgroup admission, omit only the explicit NPROC=256 override, retain
|
4. After cgroup admission, omit only the explicit NPROC=256 override, retain
|
||||||
the inherited UID soft/hard limits and all other prlimit settings. Record
|
the inherited UID soft/hard limits and all other prlimit settings. Record
|
||||||
inherited NPROC, real UID, diagnostic thread-count provenance and effective
|
inherited NPROC, real UID, trusted global task total, full reserve and effective
|
||||||
cgroup properties; any known lower-than-UID-load NPROC refuses explicitly.
|
cgroup properties; minimum finite NPROC <= total + 256 refuses explicitly.
|
||||||
Count uncertainty/drift cannot be accepted as a proof of headroom. No fallback
|
Missing or ambiguous host samples cannot prove headroom. No fallback
|
||||||
to a larger NPROC, unlimited task count, unconfined launch or new device.
|
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
|
5. Reserve the new single attempt durably with O_EXCL before creating its
|
||||||
scope/child or otherwise mutating native execution state. A collision,
|
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,
|
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
|
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
|
`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,
|
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
|
sauvé ou filesystem différents. Il reste disponible hors chemin d'admission.
|
||||||
snapshot ne réserve aucune capacité. `ProcFS.identity()` conserve ses contrôles
|
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
|
stricts pour le superviseur, le manager et le bootstrap. Les régressions restent
|
||||||
hors ligne ; cette correction n'établit aucun PASS cgroup natif.
|
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
|
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
|
`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,
|
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
|
un ancêtre non writable, pids absent, un headroom insuffisant, une source
|
||||||
UID incertain ou NPROC hérité sous la charge UID. Aucun snapshot /proc ne
|
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.
|
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
|
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:
|
except (OSError, ValueError, KeyError, IndexError) as error:
|
||||||
raise Refusal(f"proc diagnostic illisible/disparu: {error}") from 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):
|
def nproc(self):
|
||||||
return resource.getrlimit(resource.RLIMIT_NPROC)
|
return resource.getrlimit(resource.RLIMIT_NPROC)
|
||||||
|
|
||||||
|
|
@ -334,6 +419,23 @@ def capacity(cgroups, app):
|
||||||
return records
|
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):
|
def preflight(proc, cgroups, manager, uid, pid, deadline):
|
||||||
"""Toutes les observations précèdent ATTEMPT et la première mutation."""
|
"""Toutes les observations précèdent ATTEMPT et la première mutation."""
|
||||||
require(uid != 0 and uid == os.geteuid(), "UID réel/effectif invalide")
|
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)
|
controllers, enabled, writable = cgroups.available(app)
|
||||||
require("pids" in controllers and "pids" in enabled, "contrôleur pids absent")
|
require("pids" in controllers and "pids" in enabled, "contrôleur pids absent")
|
||||||
require(writable, "ancêtre commun non writable")
|
require(writable, "ancêtre commun non writable")
|
||||||
limits = proc.nproc()
|
headroom = nproc_headroom(proc)
|
||||||
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}")
|
|
||||||
return {"launcher": launcher, "manager": owner, "manager_process": manager_process,
|
return {"launcher": launcher, "manager": owner, "manager_process": manager_process,
|
||||||
"mount": mount, "app": app, "uid": uid, "nproc": limits,
|
"mount": mount, "app": app, "uid": uid, **headroom,
|
||||||
"uid_threads_diagnostic": load, "ancestors": capacity(cgroups, app)}
|
"ancestors": capacity(cgroups, app)}
|
||||||
|
|
||||||
|
|
||||||
def same_lifetime(first, second):
|
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"])
|
observed_manager = proc.identity(evidence["manager_process"]["pid"])
|
||||||
require(observed_manager == evidence["manager_process"],
|
require(observed_manager == evidence["manager_process"],
|
||||||
"manager PID/startticks divergent")
|
"manager PID/startticks divergent")
|
||||||
load = proc.uid_threads(evidence["uid"])
|
headroom = nproc_headroom(proc)
|
||||||
require(proc.nproc() == evidence["nproc"] and all(
|
require(headroom["nproc"] == evidence["nproc"],
|
||||||
value == resource.RLIM_INFINITY or value >= load for value in proc.nproc()),
|
"NPROC hérité divergent avant release")
|
||||||
"NPROC/charge UID divergent avant release")
|
|
||||||
return {"properties": observed, "process": identity, "cgroup": group,
|
return {"properties": observed, "process": identity, "cgroup": group,
|
||||||
"kernel": record, "pidfd": pidfd}
|
"kernel": record, "pidfd": pidfd, "headroom": headroom}
|
||||||
except BaseException:
|
except BaseException:
|
||||||
os.close(pidfd)
|
os.close(pidfd)
|
||||||
raise
|
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.
|
# PRECHECK is not journaled until ATTEMPT: failed preflight stays read-only.
|
||||||
reserve(evidence)
|
reserve(evidence)
|
||||||
phase("PRECHECK", nproc=evidence["nproc"],
|
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(),
|
barrier = barrier_factory(lab / "gate.sock", spec["nonce"], os.getuid(),
|
||||||
os.getpid(), started + 5)
|
os.getpid(), started + 5)
|
||||||
identity = evidence["launcher"]
|
identity = evidence["launcher"]
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,9 @@ class FakeProc:
|
||||||
return dict(self.identities[pid])
|
return dict(self.identities[pid])
|
||||||
|
|
||||||
def uid_threads(self, uid):
|
def uid_threads(self, uid):
|
||||||
|
raise AssertionError("diagnostic PID interdit dans admission")
|
||||||
|
|
||||||
|
def global_tasks(self):
|
||||||
return self.load
|
return self.load
|
||||||
|
|
||||||
def nproc(self):
|
def nproc(self):
|
||||||
|
|
@ -347,6 +350,159 @@ class UIDThreadTests(unittest.TestCase):
|
||||||
self.proc.uid_threads(1000)
|
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):
|
class AdmissionTests(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.sentinel = patch.object(
|
self.sentinel = patch.object(
|
||||||
|
|
@ -400,6 +556,37 @@ class AdmissionTests(unittest.TestCase):
|
||||||
"PAYLOAD_STARTED", "CLEANED"])
|
"PAYLOAD_STARTED", "CLEANED"])
|
||||||
self.assertEqual(self.proc.limits, (4096, 4096))
|
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):
|
def test_session_scope_refused_before_reservation(self):
|
||||||
self.proc.identities[PID]["cgroup"] = (
|
self.proc.identities[PID]["cgroup"] = (
|
||||||
f"/user.slice/user-{UID}.slice/session-6.scope")
|
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):
|
def test_failures_in_preflight_reservation_admission_and_drain(self):
|
||||||
for error in (OSError("read failure"), KeyboardInterrupt()):
|
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()
|
result, _ = self.execute()
|
||||||
self.assertNotEqual(result["cause"], "exit")
|
self.assertNotEqual(result["cause"], "exit")
|
||||||
self.assertEqual(self.manager.created, 0)
|
self.assertEqual(self.manager.created, 0)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue