Skip to content

Commit 1f801c5

Browse files
committed
fix(core): durcir l auto-install systeme en mode headless non-interactif
1 parent 4e549ea commit 1f801c5

3 files changed

Lines changed: 231 additions & 1 deletion

File tree

Core/sys_deps.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,10 @@
3232

3333
import platform
3434
import shutil
35+
import subprocess
3536
import webbrowser
37+
import os
38+
import time
3639
from collections.abc import Callable
3740
from typing import Optional, Union
3841

@@ -1083,6 +1086,10 @@ def install_system_packages(packages: list[str], gui=None) -> bool:
10831086
Returns True if successful, False otherwise.
10841087
"""
10851088
try:
1089+
# Mode headless (CLI/CI): ne jamais instancier de widgets Qt.
1090+
if gui is None:
1091+
return _install_system_packages_headless(packages)
1092+
10861093
manager = SysDependencyManager(gui)
10871094
system = platform.system().lower()
10881095

@@ -1120,3 +1127,104 @@ def install_system_packages(packages: list[str], gui=None) -> bool:
11201127
return False
11211128
except Exception:
11221129
return False
1130+
1131+
1132+
def _install_system_packages_headless(packages: list[str]) -> bool:
1133+
"""Install system packages without Qt UI for CLI/CI contexts."""
1134+
try:
1135+
pkgs = [str(p).strip() for p in (packages or []) if str(p).strip()]
1136+
if not pkgs:
1137+
return True
1138+
1139+
system = platform.system().lower()
1140+
if system != "linux":
1141+
# Keep behavior explicit for now: only Linux is supported in CI path.
1142+
return False
1143+
1144+
pm = None
1145+
for candidate in ("apt", "dnf", "yum", "pacman", "zypper"):
1146+
if shutil.which(candidate):
1147+
pm = candidate
1148+
break
1149+
if not pm:
1150+
return False
1151+
1152+
def _with_privilege(args: list[str]) -> list[str]:
1153+
try:
1154+
if hasattr(os, "geteuid") and os.geteuid() == 0:
1155+
return list(args)
1156+
except Exception:
1157+
pass
1158+
# CI/CD mode: strictly non-interactive sudo.
1159+
return ["sudo", "-n"] + list(args)
1160+
1161+
steps: list[list[str]]
1162+
run_env = os.environ.copy()
1163+
if pm == "apt":
1164+
# apt-get is more stable than apt for non-interactive automation.
1165+
run_env["DEBIAN_FRONTEND"] = "noninteractive"
1166+
steps = [
1167+
_with_privilege(["apt-get", "-o", "Acquire::Retries=3", "update"]),
1168+
_with_privilege(
1169+
[
1170+
"apt-get",
1171+
"-o",
1172+
"Dpkg::Options::=--force-confdef",
1173+
"-o",
1174+
"Dpkg::Options::=--force-confnew",
1175+
"-o",
1176+
"Acquire::Retries=3",
1177+
"install",
1178+
"-y",
1179+
"--no-install-recommends",
1180+
*pkgs,
1181+
]
1182+
),
1183+
]
1184+
elif pm == "dnf":
1185+
steps = [_with_privilege(["dnf", "install", "-y", *pkgs])]
1186+
elif pm == "yum":
1187+
steps = [_with_privilege(["yum", "install", "-y", *pkgs])]
1188+
elif pm == "pacman":
1189+
steps = [
1190+
_with_privilege(["pacman", "-Sy", "--noconfirm"]),
1191+
_with_privilege(["pacman", "-S", "--noconfirm", "--needed", *pkgs]),
1192+
]
1193+
else:
1194+
steps = [
1195+
_with_privilege(
1196+
[
1197+
"zypper",
1198+
"--non-interactive",
1199+
"--gpg-auto-import-keys",
1200+
"--no-gpg-checks",
1201+
"install",
1202+
"-y",
1203+
*pkgs,
1204+
]
1205+
)
1206+
]
1207+
1208+
for cmd in steps:
1209+
ok = False
1210+
# Retry transient failures (network mirrors, temporary locks, etc.).
1211+
for attempt in range(3):
1212+
proc = subprocess.run(
1213+
cmd,
1214+
stdout=subprocess.PIPE,
1215+
stderr=subprocess.PIPE,
1216+
text=True,
1217+
timeout=1800,
1218+
check=False,
1219+
env=run_env,
1220+
)
1221+
if proc.returncode == 0:
1222+
ok = True
1223+
break
1224+
if attempt < 2:
1225+
time.sleep(2)
1226+
if not ok:
1227+
return False
1228+
return True
1229+
except Exception:
1230+
return False

OnlyMod/EngineOnlyMod/app.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -776,7 +776,9 @@ def _ensure_engine_tools_headless(self, engine) -> tuple[bool, str]:
776776
return (
777777
False,
778778
"Missing system tools and automatic installation failed: "
779-
+ ", ".join(missing_system),
779+
+ ", ".join(missing_system)
780+
+ ". In CI/headless mode, automatic install is non-interactive for security. "
781+
+ "Run the script as root or preinstall required tools.",
780782
)
781783
except Exception as exc:
782784
return (

tests/test_sys_deps_headless.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
# Copyright 2026 Ague Samuel Amen
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
"""Headless system dependency installation tests."""
17+
18+
from __future__ import annotations
19+
20+
21+
def test_install_system_packages_headless_linux_uses_noninteractive_sudo(
22+
monkeypatch,
23+
) -> None:
24+
"""Headless mode should run package install commands without Qt widgets."""
25+
import Core.sys_deps as sys_deps
26+
27+
executed: list[list[str]] = []
28+
29+
class _Result:
30+
def __init__(self, returncode: int = 0) -> None:
31+
self.returncode = returncode
32+
self.stdout = ""
33+
self.stderr = ""
34+
35+
monkeypatch.setattr("platform.system", lambda: "Linux")
36+
monkeypatch.setattr(sys_deps.shutil, "which", lambda name: "/usr/bin/apt" if name == "apt" else None)
37+
monkeypatch.setattr(sys_deps.os, "geteuid", lambda: 1000, raising=False)
38+
39+
def _fake_run(cmd, **_kwargs):
40+
executed.append(list(cmd))
41+
return _Result(0)
42+
43+
monkeypatch.setattr(sys_deps.subprocess, "run", _fake_run)
44+
45+
assert sys_deps.install_system_packages(["cloc"], gui=None) is True
46+
assert executed == [
47+
["sudo", "-n", "apt-get", "-o", "Acquire::Retries=3", "update"],
48+
[
49+
"sudo",
50+
"-n",
51+
"apt-get",
52+
"-o",
53+
"Dpkg::Options::=--force-confdef",
54+
"-o",
55+
"Dpkg::Options::=--force-confnew",
56+
"-o",
57+
"Acquire::Retries=3",
58+
"install",
59+
"-y",
60+
"--no-install-recommends",
61+
"cloc",
62+
],
63+
]
64+
65+
66+
def test_install_system_packages_headless_does_not_use_qt_manager(monkeypatch) -> None:
67+
"""Headless mode must bypass the Qt-based SysDependencyManager path."""
68+
import Core.sys_deps as sys_deps
69+
70+
monkeypatch.setattr("platform.system", lambda: "Linux")
71+
monkeypatch.setattr(sys_deps.shutil, "which", lambda name: "/usr/bin/dnf" if name == "dnf" else None)
72+
monkeypatch.setattr(sys_deps.os, "geteuid", lambda: 0, raising=False)
73+
monkeypatch.setattr(
74+
sys_deps,
75+
"SysDependencyManager",
76+
lambda _gui: (_ for _ in ()).throw(AssertionError("Qt manager should not be used in headless mode")),
77+
)
78+
79+
class _Result:
80+
def __init__(self, returncode: int = 0) -> None:
81+
self.returncode = returncode
82+
self.stdout = ""
83+
self.stderr = ""
84+
85+
monkeypatch.setattr(sys_deps.subprocess, "run", lambda *_a, **_k: _Result(0))
86+
87+
assert sys_deps.install_system_packages(["cloc"], gui=None) is True
88+
89+
90+
def test_install_system_packages_headless_retries_transient_failures(monkeypatch) -> None:
91+
"""Headless install should retry command steps before failing."""
92+
import Core.sys_deps as sys_deps
93+
94+
attempts = {"count": 0}
95+
96+
class _Result:
97+
def __init__(self, returncode: int) -> None:
98+
self.returncode = returncode
99+
self.stdout = ""
100+
self.stderr = ""
101+
102+
monkeypatch.setattr("platform.system", lambda: "Linux")
103+
monkeypatch.setattr(
104+
sys_deps.shutil,
105+
"which",
106+
lambda name: "/usr/bin/dnf" if name == "dnf" else None,
107+
)
108+
monkeypatch.setattr(sys_deps.os, "geteuid", lambda: 0, raising=False)
109+
monkeypatch.setattr(sys_deps.time, "sleep", lambda *_a, **_k: None)
110+
111+
def _fake_run(_cmd, **_kwargs):
112+
attempts["count"] += 1
113+
if attempts["count"] == 1:
114+
return _Result(1)
115+
return _Result(0)
116+
117+
monkeypatch.setattr(sys_deps.subprocess, "run", _fake_run)
118+
119+
assert sys_deps.install_system_packages(["cloc"], gui=None) is True
120+
assert attempts["count"] == 2

0 commit comments

Comments
 (0)