feat(node): ship managed environment startup and USB recovery
This commit is contained in:
@@ -62,6 +62,24 @@ and the boundary between Mission Core and vendor-specific integration code.
|
||||
- Synthetic or explicitly redacted fixtures may be committed under
|
||||
`tests/fixtures/`.
|
||||
|
||||
## Onboard environment ownership — owner requirement, 2026-09-24
|
||||
|
||||
- Every onboard OS change belongs to the shipped, versioned installer or the
|
||||
application's environment/device preparation workflow from the first test.
|
||||
The operator installs the build and configures it in the application; never
|
||||
require hand-written service files, USB rules, permission fixes or commands.
|
||||
- This applies to all Node features, including GUI/service autostart and USB
|
||||
recovery, not just individual camera drivers. Read-only SSH inspection and
|
||||
bounded artifact-owned build staging remain allowed.
|
||||
- Detect the distribution, architecture and required capabilities. Maintain
|
||||
explicit supported environment profiles; do not claim arbitrary Linux
|
||||
compatibility from a qualified Ubuntu build. Preserve foreign configuration,
|
||||
report unsupported features, make preparation repeatable, and ship rollback.
|
||||
- Do not add USB administration controls or port resets to inventory refresh.
|
||||
Automatic startup recovery may retry terminal enumeration failures only;
|
||||
preserve enumerated devices and active companion ports. Device identity and
|
||||
assignments must survive changes of USB port and tty number.
|
||||
|
||||
## Insta360 and clean-host installation — owner requirement, 2026-09-08
|
||||
|
||||
- The current X4 starting point is USB enumeration only. SDK installation,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
{
|
||||
"schema": "missioncore.node.environment/v1",
|
||||
"revision": "ubuntu-24.04-amd64/2",
|
||||
"revision": "ubuntu-24.04-amd64/3",
|
||||
"steps": [
|
||||
{"id":"platform","label":"Проверка системы","description":"Операционная система и архитектура БК","requires":[]},
|
||||
{"id":"packages","label":"Установка системных пакетов","description":"OpenSSH Server и зависимости окружения","requires":["platform"]},
|
||||
{"id":"node-service","label":"Настройка службы БК","description":"Автозапуск и доступ к системной инвентаризации","requires":["platform"]},
|
||||
{"id":"desktop-autostart","label":"Автозапуск приложения","description":"Открытие окна при входе в рабочий стол","requires":["node-service"]},
|
||||
{"id":"usb-startup","label":"Обнаружение устройств при загрузке","description":"Автоматическое восстановление незавершённого подключения USB","requires":["platform"]},
|
||||
{"id":"network-inventory","label":"Получение сетевых настроек","description":"Интерфейсы и назначенные адреса","requires":["node-service"]},
|
||||
{"id":"usb-inventory","label":"Получение USB-устройств","description":"Оборудование, обнаруженное операционной системой","requires":["node-service"]},
|
||||
{"id":"ssh-service","label":"Настройка SSH","description":"Запуск сервера и подключение реестра доверенных ключей","requires":["packages","node-service"]},
|
||||
|
||||
@@ -182,6 +182,7 @@ func (p *PresentationStore) save(value PresentationSettings) error {
|
||||
}
|
||||
|
||||
func (s *Server) presentationRoutes(mux *http.ServeMux) {
|
||||
s.boardLayoutRoutes(mux)
|
||||
p := s.Presentation
|
||||
mux.HandleFunc("GET /api/presentation/settings", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.authorized(w, r) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// An authenticated Node action may start only this fixed model job.
|
||||
polkit.addRule(function(action, subject) {
|
||||
var unit = action.lookup("unit");
|
||||
if (subject.user === "mission-core-node" && action.id === "org.freedesktop.systemd1.manage-units" && (unit === "mission-core-node-realsense-prepare.service" || unit === "mission-core-node-insta360-x4-profile.service") && action.lookup("verb") === "start") {
|
||||
if (subject.user === "mission-core-node" && action.id === "org.freedesktop.systemd1.manage-units" && (unit === "mission-core-node-vesc-prepare.service" || unit === "mission-core-node-realsense-prepare.service" || unit === "mission-core-node-insta360-x4-profile.service") && action.lookup("verb") === "start") {
|
||||
return polkit.Result.YES;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ import sys
|
||||
from build_deb import build, VERSION, BRAND_SHA256
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DG_COMMIT = "8c53f73ee521561a1cc7944a7f7cf113702f40b5"
|
||||
DG_COMMIT = "8dd9190573d6616024ef01b9b34bf90b72960f44"
|
||||
|
||||
|
||||
def guideline_sources():
|
||||
@@ -36,6 +36,7 @@ def provenance():
|
||||
"design_guideline_files": guideline_sources(),
|
||||
"shared_sensor_ui_files": {str(p.relative_to(ROOT.parents[1])): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted((ROOT.parents[1] / "packages/sensor-ui/src").rglob("*")) if p.is_file()},
|
||||
"shared_spatial_ui_files": {str(p.relative_to(ROOT.parents[1])): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted((ROOT.parents[1] / "packages/spatial-ui/src").rglob("*")) if p.is_file()},
|
||||
"vesc_plugin_files": {str(p.relative_to(ROOT.parents[1])): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted((ROOT.parents[1] / "plugins/vesc").rglob("*")) if p.is_file() and not any(x in p.relative_to(ROOT.parents[1]).parts for x in ("__pycache__", "build"))},
|
||||
"k1_frontend_files": {str(p.relative_to(ROOT.parents[1])): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted((ROOT.parents[1] / "plugins/xgrids-k1/frontend/src").rglob("*")) if p.is_file()},
|
||||
"toolchain": json.loads((ROOT / "toolchain.json").read_text()), "files": files}
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ import sys
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
BINARY_VERSION = "0.8.21"
|
||||
VERSION = "0.8.21-3"
|
||||
BINARY_VERSION = "0.8.45"
|
||||
VERSION = "0.8.45-1"
|
||||
sys.path.insert(0, str(ROOT.parents[1] / "scripts/packaging"))
|
||||
from debian import package
|
||||
|
||||
@@ -44,7 +44,7 @@ Architecture: amd64
|
||||
Maintainer: NODE.DC local build <noreply@example.invalid>
|
||||
Section: admin
|
||||
Priority: optional
|
||||
Depends: adduser, systemd (>= 255), python3 (>= 3.12), python3 (<< 3.13), python3-gi, gir1.2-gtk-3.0, gir1.2-webkit2-4.1, pkexec, polkitd, ca-certificates, hicolor-icon-theme, network-manager, iproute2, python3-psycopg2, postgresql-16 (>= 16.15), timescaledb-2-oss-postgresql-16 (= 2.29.2~ubuntu24.04-1615), udev, libc6 (>= 2.35), libstdc++6 (>= 12), libgcc-s1, zlib1g
|
||||
Depends: adduser, systemd (>= 255), python3 (>= 3.12), python3 (<< 3.13), python3-gi, gir1.2-gtk-3.0, gir1.2-webkit2-4.1, pkexec, polkitd, ca-certificates, hicolor-icon-theme, network-manager, iproute2, python3-psycopg2, postgresql-16 (>= 16.15), timescaledb-2-oss-postgresql-16 (= 2.29.2~ubuntu24.04-1615), udev, usbutils, libc6 (>= 2.39), libstdc++6 (>= 12), libgcc-s1, zlib1g
|
||||
Description: Mission Core onboard computer configuration
|
||||
Local graphical setup, host inventory, SSH access and persistent node identity.
|
||||
""".encode()
|
||||
@@ -66,6 +66,11 @@ Description: Mission Core onboard computer configuration
|
||||
("tailscale-release.json", "usr/share/mission-core-node/tailscale-release.json", 0o644),
|
||||
("configure-system", "usr/lib/mission-core-node/configure-system", 0o755),
|
||||
("environment_helper.py", "usr/lib/mission-core-node/environment_helper.py", 0o644),
|
||||
("desktop_startup.py", "usr/lib/mission-core-node/desktop_startup.py", 0o644),
|
||||
("mission-core-node-autostart.desktop", "usr/share/mission-core-node/mission-core-node-autostart.desktop", 0o644),
|
||||
("usb-startup.json", "usr/share/mission-core-node/usb-startup.json", 0o644),
|
||||
("usb_startup_recovery.py", "usr/lib/mission-core-node/usb_startup_recovery.py", 0o644),
|
||||
("mission-core-node-usb-startup.service", "usr/lib/systemd/system/mission-core-node-usb-startup.service", 0o644),
|
||||
("mission-core-node-environment.service", "usr/lib/systemd/system/mission-core-node-environment.service", 0o644),
|
||||
("60-environment.conf", "usr/share/mission-core-node/60-environment.conf", 0o644),
|
||||
("setup-monitor", "usr/lib/mission-core-node/setup-monitor", 0o755),
|
||||
@@ -111,6 +116,8 @@ Description: Mission Core onboard computer configuration
|
||||
files.append(("usr/lib/mission-core-node/sdk/missioncore_plugin_sdk/" + str(path.relative_to(sdk)), path.read_bytes(), 0o644))
|
||||
if (ROOT / "build/provenance.json").exists():
|
||||
files.append(("usr/share/doc/mission-core-node/provenance.json", (ROOT / "build/provenance.json").read_bytes(), 0o644))
|
||||
import runpy
|
||||
files.extend(runpy.run_path(str(ROOT.parents[1] / "plugins/vesc/packaging/payload.py"))["payload"]())
|
||||
archive = package(controls, files)
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_bytes(archive)
|
||||
|
||||
@@ -11,7 +11,7 @@ from pathlib import Path
|
||||
NODE = Path(__file__).resolve().parents[1]
|
||||
REPO = NODE.parents[1]
|
||||
DG = REPO.parent / "NODEDC_DESIGN_GUIDELINE"
|
||||
DG_COMMIT = "8c53f73ee521561a1cc7944a7f7cf113702f40b5"
|
||||
DG_COMMIT = "8dd9190573d6616024ef01b9b34bf90b72960f44"
|
||||
|
||||
|
||||
def files(root):
|
||||
@@ -73,6 +73,13 @@ def build(qualified, node_only=False):
|
||||
# board, runtime path override or operator compiler dependency is needed.
|
||||
virtual = str(REPO.name + "/apps/node-agent/build/model-packages/" + package_name)
|
||||
entries[virtual] = qualified
|
||||
native_root = REPO / "plugins/vesc"
|
||||
native = json.loads((native_root / "packaging/native-runtime.json").read_text())
|
||||
native_path = native_root / "build/native-runtime" / native["file"]
|
||||
native_bytes = native_path.read_bytes()
|
||||
if len(native_bytes) != native["bytes"] or hashlib.sha256(native_bytes).hexdigest() != native["sha256"]:
|
||||
raise ValueError("Qualified VESC Tool runtime changed")
|
||||
entries[REPO.name + "/plugins/vesc/build/native-runtime/" + native["file"]] = native_path
|
||||
metadata = {}
|
||||
for name, path in entries.items():
|
||||
data = path.read_bytes()
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Unprivileged XDG startup: wait for Node before opening its ordinary UI."""
|
||||
import http.client
|
||||
import os
|
||||
import time
|
||||
|
||||
|
||||
def wait_for_service(now=time.monotonic, sleep=time.sleep, connection=http.client.HTTPConnection):
|
||||
deadline = now() + 180
|
||||
while now() < deadline:
|
||||
client = connection('127.0.0.1', 8780, timeout=2)
|
||||
try:
|
||||
client.request('GET', '/')
|
||||
response = client.getresponse()
|
||||
if response.status == 200:
|
||||
return True
|
||||
except (OSError, http.client.HTTPException):
|
||||
pass
|
||||
finally:
|
||||
client.close()
|
||||
sleep(1)
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if os.geteuid() == 0:
|
||||
raise SystemExit('Run in the normal graphical user session')
|
||||
wait_for_service()
|
||||
# Gtk.Application retains one window per desktop session. Its ordinary
|
||||
# polkit authorization and error handling are unchanged.
|
||||
os.execv('/usr/bin/mission-core-node', ['/usr/bin/mission-core-node'])
|
||||
@@ -219,7 +219,33 @@ def tailscale_install():
|
||||
return "Tailscale установлен; системная служба запущена. Вход проверяется отдельно."
|
||||
|
||||
|
||||
OPERATIONS = {"platform": platform, "packages": packages, "node-service": node_service, "network-inventory": network_inventory, "usb-inventory": usb_inventory, "ssh-service": ssh_service, "tailscale-install": tailscale_install}
|
||||
def desktop_autostart():
|
||||
owned_config(Path('/usr/share/mission-core-node/mission-core-node-autostart.desktop'),
|
||||
Path('/etc/xdg/autostart/org.nodedc.MissionCoreNode.desktop'))
|
||||
return "Окно приложения открывается при входе в рабочий стол. Служба БК работает и без входа."
|
||||
|
||||
|
||||
def usb_startup():
|
||||
# Enabling the next boot is distinct from executing recovery now. This
|
||||
# workflow never resets devices in a running operator session.
|
||||
owned_config(Path('/usr/share/mission-core-node/usb-startup.json'),
|
||||
Path('/etc/mission-core-node/usb-startup.json'))
|
||||
command(['/usr/bin/systemctl', 'daemon-reload'])
|
||||
command(['/usr/bin/systemctl', 'enable', 'mission-core-node-usb-startup.service'])
|
||||
command(['/usr/bin/systemctl', 'is-enabled', 'mission-core-node-usb-startup.service'])
|
||||
report = json.loads(command(['/usr/bin/python3', '-I', '-B', '/usr/lib/mission-core-node/usb_startup_recovery.py', '--inspect'], timeout=75))
|
||||
if report.get('state') != 'inspected':
|
||||
raise SetupError("Не удалось проверить поддержку восстановления USB. Повторите настройку.")
|
||||
supported = sum(h.get('individual_power') is True for h in report.get('hubs', []))
|
||||
if not supported:
|
||||
return "Проверка при загрузке включена. Поддержка отдельного переключения USB-портов не подтверждена; они будут пропущены."
|
||||
return "Восстановление при загрузке включено для поддерживаемых USB-портов. Обнаруженные устройства сохраняют подключение."
|
||||
|
||||
|
||||
OPERATIONS = {"platform": platform, "packages": packages, "node-service": node_service,
|
||||
"desktop-autostart": desktop_autostart, "usb-startup": usb_startup,
|
||||
"network-inventory": network_inventory, "usb-inventory": usb_inventory,
|
||||
"ssh-service": ssh_service, "tailscale-install": tailscale_install}
|
||||
|
||||
|
||||
def run_steps(profile, operations, save):
|
||||
|
||||
@@ -5,7 +5,7 @@ mc_node_release_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
if [ ! -t 0 ]; then
|
||||
exec /usr/bin/gnome-terminal --wait --title="Mission Core Node" -- "$mc_node_release_dir/install"
|
||||
fi
|
||||
printf '%s\n' 'Mission Core Node' 'Обновление приложения и встроенных пакетов камер на этом Ubuntu-компьютере.' 'Подготовка X4 выполняется затем из списка устройств в Node или Core.' 'Пароль администратора вводится только в этом локальном окне Ubuntu.'
|
||||
printf '%s\n' 'Mission Core Node' 'Обновление приложения и встроенных драйверов на этом Ubuntu-компьютере.' 'Подготовка VESC и X4 выполняется затем из списка устройств в Node или Core.' 'Пароль администратора вводится только в этом локальном окне Ubuntu.'
|
||||
set +e
|
||||
/usr/bin/sudo /usr/bin/python3 -I "$mc_node_release_dir/install_release.py" 2>&1 | /usr/bin/tee -a "$mc_node_release_dir/install-output.log"
|
||||
mc_node_install_result=${PIPESTATUS[0]}
|
||||
|
||||
@@ -351,7 +351,7 @@ def main():
|
||||
if report["state"] != "complete":
|
||||
raise RuntimeError(report["error"])
|
||||
print(
|
||||
"Mission Core Node обновлён. X4 можно подготовить из списка устройств в Node или Core.",
|
||||
"Mission Core Node обновлён. VESC и X4 можно подготовить из списка устройств в Node или Core.",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -243,6 +243,10 @@ def main():
|
||||
sys.path.insert(0, str(node / "packaging"))
|
||||
from build_deb import BINARY_VERSION, VERSION, build
|
||||
|
||||
run("node-environment-tests", ["/usr/bin/python3", "-m", "unittest", "test_environment_helper", "test_usb_startup_recovery", "test_desktop_startup", "-v"], cwd=node / "packaging")
|
||||
run("owner-release-staging-tests", ["/usr/bin/python3", "plugins/insta360-x4/packaging/test_owner_release_entry.py"], cwd=repo)
|
||||
run("node-usb-unit-validation", ["/usr/bin/systemd-analyze", "verify", str(node / "packaging/mission-core-node-usb-startup.service")], cwd=node)
|
||||
run("vesc-reader-tests", ["/usr/bin/python3", "-m", "unittest", "discover", "-s", "plugins/vesc/tests", "-v"], cwd=repo)
|
||||
binary = node / "build/node-agent-linux-amd64"
|
||||
run(
|
||||
"node-binary",
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Name=Mission Core Node
|
||||
Comment=Настройка и диагностика бортового компьютера
|
||||
TryExec=/usr/bin/mission-core-node
|
||||
Exec=/usr/bin/python3 -I -B /usr/lib/mission-core-node/desktop_startup.py
|
||||
Icon=org.nodedc.MissionCoreNode
|
||||
Terminal=false
|
||||
StartupNotify=false
|
||||
@@ -0,0 +1,35 @@
|
||||
[Unit]
|
||||
Description=Mission Core bounded USB startup recovery
|
||||
Wants=systemd-udev-settle.service
|
||||
After=systemd-udev-settle.service
|
||||
Before=mission-core-node.service mission-core-vesc.service
|
||||
ConditionPathExists=/etc/mission-core-node/usb-startup.json
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
ExecStart=/usr/bin/python3 -I -B /usr/lib/mission-core-node/usb_startup_recovery.py
|
||||
ExecStopPost=/usr/bin/python3 -I -B /usr/lib/mission-core-node/usb_startup_recovery.py --restore
|
||||
TimeoutStartSec=130
|
||||
TimeoutStopSec=15
|
||||
RuntimeDirectory=mission-core-usb-startup
|
||||
RuntimeDirectoryMode=0755
|
||||
RuntimeDirectoryPreserve=yes
|
||||
UMask=0077
|
||||
NoNewPrivileges=yes
|
||||
PrivateNetwork=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectControlGroups=yes
|
||||
RestrictSUIDSGID=yes
|
||||
RestrictAddressFamilies=AF_UNIX AF_NETLINK
|
||||
CapabilityBoundingSet=
|
||||
ReadWritePaths=/sys/devices /run/mission-core-usb-startup
|
||||
DevicePolicy=closed
|
||||
DeviceAllow=char-usb_device rw
|
||||
TasksMax=16
|
||||
MemoryMax=64M
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -2,6 +2,7 @@
|
||||
set -eu
|
||||
case "$1" in
|
||||
configure)
|
||||
/usr/bin/python3 -I /usr/lib/mission-core-vesc/clear_runtime_cache.py
|
||||
if ! getent passwd mission-core-node >/dev/null; then
|
||||
adduser --system --group --home /var/lib/mission-core-node --no-create-home --disabled-login mission-core-node
|
||||
fi
|
||||
@@ -15,6 +16,9 @@ case "$1" in
|
||||
systemctl enable mission-core-node.service
|
||||
systemctl restart mission-core-node.service
|
||||
systemctl try-restart mission-core-realsense.service
|
||||
if [ -f /var/lib/mission-core-node-profiles/vesc/preparation.json ]; then
|
||||
systemctl start mission-core-node-vesc-prepare.service
|
||||
fi
|
||||
if [ -f /run/mission-core-node-k1-upgrade-active ]; then
|
||||
# A jointly upgraded plugin starts itself after its own configuration.
|
||||
# Restore it here only when that package is already configured.
|
||||
|
||||
@@ -14,10 +14,22 @@ if [ "$1" = install ] || [ "$1" = upgrade ]; then
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
mc_node_usb_job=$(systemctl show --property=ActiveState --value mission-core-node-usb-startup.service 2>/dev/null || true)
|
||||
case "$mc_node_usb_job" in
|
||||
activating|deactivating) echo "Mission Core Node: дождитесь завершения обнаружения USB при загрузке." >&2; exit 1 ;;
|
||||
esac
|
||||
if [ -f /run/mission-core-usb-startup/pending.json ]; then
|
||||
echo "Mission Core Node: восстановление USB ещё не завершило включение порта; пакет сохранён." >&2
|
||||
exit 1
|
||||
fi
|
||||
mc_node_x4_job=$(systemctl show --property=ActiveState --value mission-core-node-insta360-x4-profile.service 2>/dev/null || true)
|
||||
case "$mc_node_x4_job" in
|
||||
active|activating) echo "Mission Core Node: дождитесь завершения подготовки X4." >&2; exit 1 ;;
|
||||
esac
|
||||
mc_node_vesc_job=$(systemctl show --property=ActiveState --value mission-core-node-vesc-prepare.service 2>/dev/null || true)
|
||||
case "$mc_node_vesc_job" in
|
||||
active|activating) echo "Mission Core Node: дождитесь завершения подготовки VESC." >&2; exit 1 ;;
|
||||
esac
|
||||
mc_node_device_job=$(systemctl show --property=ActiveState --value mission-core-node-realsense-prepare.service 2>/dev/null || true)
|
||||
case "$mc_node_device_job" in
|
||||
active|activating) echo "Mission Core Node: дождитесь завершения подготовки устройства." >&2; exit 1 ;;
|
||||
@@ -38,6 +50,7 @@ if [ "$1" = install ] || [ "$1" = upgrade ]; then
|
||||
(umask 077; : > /run/mission-core-node-k1-upgrade-active)
|
||||
systemctl stop mission-core-k1.service
|
||||
fi
|
||||
systemctl stop mission-core-vesc.service 2>/dev/null || true
|
||||
systemctl stop mission-core-node-monitor.service 2>/dev/null || true
|
||||
fi
|
||||
. /etc/os-release
|
||||
|
||||
@@ -13,10 +13,22 @@ if [ -d /run/systemd/system ]; then
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
mc_node_usb_job=$(systemctl show --property=ActiveState --value mission-core-node-usb-startup.service 2>/dev/null || true)
|
||||
case "$mc_node_usb_job" in
|
||||
activating|deactivating) echo "Mission Core Node: дождитесь завершения обнаружения USB при загрузке." >&2; exit 1 ;;
|
||||
esac
|
||||
if [ -f /run/mission-core-usb-startup/pending.json ]; then
|
||||
echo "Mission Core Node: восстановление USB ещё не завершило включение порта; пакет сохранён." >&2
|
||||
exit 1
|
||||
fi
|
||||
mc_node_x4_job=$(systemctl show --property=ActiveState --value mission-core-node-insta360-x4-profile.service 2>/dev/null || true)
|
||||
case "$mc_node_x4_job" in
|
||||
active|activating) echo "Mission Core Node: дождитесь завершения подготовки X4." >&2; exit 1 ;;
|
||||
esac
|
||||
mc_node_vesc_job=$(systemctl show --property=ActiveState --value mission-core-node-vesc-prepare.service 2>/dev/null || true)
|
||||
case "$mc_node_vesc_job" in
|
||||
active|activating) echo "Mission Core Node: дождитесь завершения подготовки VESC." >&2; exit 1 ;;
|
||||
esac
|
||||
mc_node_device_job=$(systemctl show --property=ActiveState --value mission-core-node-realsense-prepare.service 2>/dev/null || true)
|
||||
case "$mc_node_device_job" in
|
||||
active|activating) echo "Mission Core Node: дождитесь завершения подготовки устройства." >&2; exit 1 ;;
|
||||
@@ -30,8 +42,39 @@ if [ -d /run/systemd/system ]; then
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
retire_startup_settings() {
|
||||
for mc_node_owned in /etc/xdg/autostart/org.nodedc.MissionCoreNode.desktop /etc/mission-core-node/usb-startup.json; do
|
||||
case "$mc_node_owned" in
|
||||
*.desktop) mc_node_template=/usr/share/mission-core-node/mission-core-node-autostart.desktop ;;
|
||||
*.json) mc_node_template=/usr/share/mission-core-node/usb-startup.json ;;
|
||||
esac
|
||||
if [ ! -L "$mc_node_owned" ] && cmp -s "$mc_node_template" "$mc_node_owned"; then
|
||||
rm "$mc_node_owned"
|
||||
fi
|
||||
done
|
||||
if [ -d /run/systemd/system ]; then
|
||||
systemctl disable --now mission-core-node-usb-startup.service
|
||||
fi
|
||||
}
|
||||
# A downgrade removes helpers introduced in 0.8.37. Retire their owned
|
||||
# configuration first; do not leave an enabled unit or XDG entry dangling.
|
||||
if [ "$1" = upgrade ] && [ -n "${2:-}" ] && dpkg --compare-versions "$2" lt 0.8.37-1; then
|
||||
retire_startup_settings
|
||||
fi
|
||||
case "$1" in
|
||||
upgrade|remove|deconfigure)
|
||||
if [ -d /run/systemd/system ] && [ -f /usr/lib/systemd/system/mission-core-vesc.service ]; then
|
||||
systemctl disable --now mission-core-vesc.service
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
case "$1" in
|
||||
remove|deconfigure)
|
||||
if cmp -s /usr/share/mission-core-node/profiles/vesc/70-mission-core-vesc.rules /etc/udev/rules.d/70-mission-core-vesc.rules; then
|
||||
rm /etc/udev/rules.d/70-mission-core-vesc.rules
|
||||
if [ -d /run/systemd/system ]; then udevadm control --reload-rules; fi
|
||||
fi
|
||||
retire_startup_settings
|
||||
mc_node_ssh_snippet=/etc/ssh/sshd_config.d/60-mission-core-node.conf
|
||||
if [ -e "$mc_node_ssh_snippet" ]; then
|
||||
if cmp -s /usr/share/mission-core-node/60-mission-core-node.conf "$mc_node_ssh_snippet"; then
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import unittest
|
||||
from unittest.mock import Mock
|
||||
import desktop_startup as startup
|
||||
|
||||
|
||||
class DesktopStartupTests(unittest.TestCase):
|
||||
def test_waits_for_service_without_credentials_and_closes_each_connection(self):
|
||||
clock = [0]
|
||||
def sleep(seconds): clock[0] += seconds
|
||||
client = Mock()
|
||||
client.getresponse.side_effect = [OSError('not started'), Mock(status=503), Mock(status=200)]
|
||||
factory = Mock(return_value=client)
|
||||
self.assertTrue(startup.wait_for_service(lambda: clock[0], sleep, factory))
|
||||
self.assertEqual(clock[0], 2)
|
||||
self.assertEqual(client.close.call_count, 3)
|
||||
self.assertEqual(client.request.call_args.args, ('GET', '/'))
|
||||
|
||||
def test_stopped_service_does_not_hold_startup_forever(self):
|
||||
clock = [0]
|
||||
def sleep(seconds): clock[0] += seconds
|
||||
client = Mock()
|
||||
client.request.side_effect = OSError('offline')
|
||||
self.assertFalse(startup.wait_for_service(lambda: clock[0], sleep, lambda *a, **kw: client))
|
||||
self.assertEqual(clock[0], 180)
|
||||
self.assertEqual(client.close.call_count, 180)
|
||||
@@ -15,6 +15,24 @@ PROFILE = json.loads((Path(__file__).parents[1] / "internal/node/environment-pro
|
||||
|
||||
|
||||
class EnvironmentWorkflowTests(unittest.TestCase):
|
||||
def test_profile_and_shipped_operations_match(self):
|
||||
self.assertEqual({s['id'] for s in PROFILE['steps']}, set(helper.OPERATIONS))
|
||||
|
||||
def test_usb_setup_enables_next_boot_without_resetting_live_devices(self):
|
||||
def command(argv, **_):
|
||||
return json.dumps({'state': 'inspected', 'hubs': [{'individual_power': True}]}) if '--inspect' in argv else ''
|
||||
with patch.object(helper, 'owned_config') as config, patch.object(helper, 'command', side_effect=command) as run:
|
||||
self.assertIn('включено', helper.usb_startup())
|
||||
config.assert_called_once()
|
||||
for call in run.call_args_list:
|
||||
self.assertFalse({'--now', 'start', 'restart'} & set(call.args[0]))
|
||||
self.assertTrue(any('--inspect' in call.args[0] for call in run.call_args_list))
|
||||
|
||||
def test_autostart_preserves_foreign_configuration_and_does_not_start_a_gui_as_root(self):
|
||||
with patch.object(helper, 'owned_config', side_effect=helper.SetupError('conflict')), patch.object(helper, 'command') as run:
|
||||
with self.assertRaises(helper.SetupError): helper.desktop_autostart()
|
||||
run.assert_not_called()
|
||||
|
||||
def test_failure_blocks_dependents_but_inventory_still_runs_and_retry_rechecks(self):
|
||||
saved = []
|
||||
operations = {item['id']: Mock(return_value='verified') for item in PROFILE['steps']}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
"""Synthetic sysfs/journal only: these tests never open a physical USB port."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
import usb_startup_recovery as recovery
|
||||
|
||||
|
||||
def event(second, message, transport='kernel'):
|
||||
return {'__MONOTONIC_TIMESTAMP': str(int(second * 1000000)),
|
||||
'_TRANSPORT': transport, 'MESSAGE': message}
|
||||
|
||||
|
||||
class JournalTests(unittest.TestCase):
|
||||
def test_only_terminal_boot_failures_with_no_later_connection_are_candidates(self):
|
||||
records = [event(20, 'usb usb1-port2: unable to enumerate USB device'),
|
||||
event(18, 'usb usb1-port3: unable to enumerate USB device'),
|
||||
event(23, 'usb 1-3: new full-speed USB device number 8 using xhci_hcd'),
|
||||
event(12, 'usb 1-1-port2: unable to enumerate USB device'),
|
||||
event(65, 'usb usb2-port1: unable to enumerate USB device'),
|
||||
event(15, 'usb usb2-port3: unable to enumerate USB device', 'stdout'),
|
||||
event(18, 'usb 1-4: device descriptor read/64, error -71')]
|
||||
self.assertEqual(recovery.failed_ports(list(reversed(records))), ['1-1-port2', 'usb1-port2'])
|
||||
|
||||
def test_success_or_user_disconnect_invalidates_old_failure(self):
|
||||
for message in ['New USB device found, idVendor=0000', 'USB disconnect, device number 8']:
|
||||
self.assertEqual(recovery.failed_ports([
|
||||
event(20, 'usb usb1-port2: unable to enumerate USB device'), event(21, 'usb 1-2: ' + message)]), [])
|
||||
|
||||
def test_invalid_port_names_cannot_be_paths(self):
|
||||
for name in ['../../etc/passwd', 'usb0-port0', 'usb1-port1/disable']:
|
||||
with self.assertRaises(ValueError): recovery.child_name(name)
|
||||
self.assertEqual(recovery.child_name('1-2.3-port4'), '1-2.3.4')
|
||||
|
||||
|
||||
class SysfsTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
base = Path(self.temp.name)
|
||||
self.root, self.physical = base/'bus', base/'devices'
|
||||
self.root.mkdir(); self.physical.mkdir()
|
||||
self.usb = recovery.USB(self.root, self.physical)
|
||||
self.ports = []
|
||||
for n in (1, 2):
|
||||
hub = self.physical/f'usb{n}'; hub.mkdir()
|
||||
for key, value in {'bDeviceClass':'09', 'busnum':str(n), 'devnum':'1'}.items():
|
||||
(hub/key).write_text(value)
|
||||
(self.root/hub.name).symlink_to(hub, target_is_directory=True)
|
||||
interface = hub/f'{n}-0:1.0'; interface.mkdir()
|
||||
(self.root/interface.name).symlink_to(interface, target_is_directory=True)
|
||||
port = interface/f'usb{n}-port2'; port.mkdir()
|
||||
for key, value in {'state':'not attached', 'disable':'0\n', 'over_current_count':'0', 'connect_type':'unknown'}.items():
|
||||
(port/key).write_text(value)
|
||||
self.ports.append(port)
|
||||
(self.ports[0]/'peer').symlink_to(self.ports[1], target_is_directory=True)
|
||||
(self.ports[1]/'peer').symlink_to(self.ports[0], target_is_directory=True)
|
||||
|
||||
def plan(self):
|
||||
with patch.object(recovery, 'run', return_value=' wHubCharacteristic 0x0009\n'):
|
||||
return self.usb.plan('usb1-port2')
|
||||
|
||||
def test_individual_power_pair_can_be_disabled_and_restored(self):
|
||||
entries = self.plan()
|
||||
self.assertEqual([e['name'] for e in entries], ['usb1-port2', 'usb2-port2'])
|
||||
for entry in entries: self.usb.write(entry, True)
|
||||
self.assertTrue(all((p/'disable').read_text() == '1\n' for p in self.ports))
|
||||
for entry in reversed(entries): self.usb.write(entry, False)
|
||||
self.assertTrue(all((p/'disable').read_text() == '0\n' for p in self.ports))
|
||||
|
||||
def test_connected_companion_blocks_both_ports_before_descriptor_query(self):
|
||||
(self.ports[1]/'device').symlink_to(self.physical/'camera')
|
||||
with patch.object(recovery, 'run') as run:
|
||||
with self.assertRaises(OSError): self.usb.plan('usb1-port2')
|
||||
run.assert_not_called()
|
||||
self.assertEqual((self.ports[0]/'disable').read_text(), '0\n')
|
||||
|
||||
def test_non_individual_power_or_missing_descriptor_cannot_reset(self):
|
||||
for output in ['wHubCharacteristics 0x0000', 'wHubCharacteristics 0x0002', '']:
|
||||
with self.subTest(output=output), patch.object(recovery, 'run', return_value=output):
|
||||
with self.assertRaises(OSError): self.usb.plan('usb1-port2')
|
||||
|
||||
def test_internal_disabled_overcurrent_and_mid_enumeration_ports_skipped(self):
|
||||
for attribute, value in [('connect_type', 'hardwired'), ('disable', '1'), ('over_current_count', '1'), ('state', 'powered')]:
|
||||
p = self.ports[0]/attribute; original = p.read_text(); p.write_text(value)
|
||||
with self.subTest(attribute=attribute), self.assertRaises(OSError): self.plan()
|
||||
p.write_text(original)
|
||||
|
||||
def test_attachment_after_planning_prevents_write(self):
|
||||
entries = self.plan()
|
||||
(self.ports[0]/'device').symlink_to(self.physical/'new-device')
|
||||
with self.assertRaises(OSError): self.usb.write(entries[0], True)
|
||||
self.assertEqual((self.ports[0]/'disable').read_text(), '0\n')
|
||||
|
||||
def test_hub_replacement_after_planning_prevents_write(self):
|
||||
entries = self.plan()
|
||||
(self.physical/'usb1'/'devnum').write_text('4')
|
||||
with self.assertRaises(OSError): self.usb.write(entries[0], True)
|
||||
|
||||
def test_first_hub_replaced_while_inspecting_companion_invalidates_plan(self):
|
||||
def inspect(name):
|
||||
if name == 'usb2-port2': (self.physical/'usb1'/'devnum').write_text('4')
|
||||
return True
|
||||
with patch.object(self.usb, 'individual_power', side_effect=inspect):
|
||||
with self.assertRaises(OSError): self.usb.plan('usb1-port2')
|
||||
|
||||
def test_enabled_port_is_not_written_during_cleanup(self):
|
||||
entries = self.plan()
|
||||
with patch.object(recovery.os, 'open', side_effect=AssertionError('No write expected')):
|
||||
self.usb.write(entries[0], False)
|
||||
|
||||
def test_foreign_companion_path_is_rejected(self):
|
||||
(self.ports[0]/'peer').unlink()
|
||||
(self.ports[0]/'peer').symlink_to(self.root)
|
||||
with self.assertRaises(OSError): self.usb.companions('usb1-port2')
|
||||
|
||||
|
||||
class RecoveryTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory(); self.addCleanup(self.temp.cleanup)
|
||||
self.state = Path(self.temp.name)
|
||||
self.clock = 30
|
||||
self.usb = Mock()
|
||||
self.entries = [{'name': 'usb1-port2', 'generation': {'address': 1}},
|
||||
{'name': 'usb2-port2', 'generation': {'address': 1}}]
|
||||
self.usb.plan.return_value = self.entries
|
||||
self.usb.empty.return_value = True
|
||||
self.usb.generation.return_value = {'address': 1}
|
||||
self.usb.outcome.return_value = {'idVendor': '0000', 'idProduct': '0001', 'product': 'Synthetic'}
|
||||
|
||||
def sleep(self, seconds): self.clock += seconds
|
||||
|
||||
def run_recovery(self, ports=None):
|
||||
return recovery.recover(self.usb, self.state, 'boot', ports or ['usb1-port2'], lambda: self.clock, self.sleep)
|
||||
|
||||
def test_pair_is_attempted_only_once_and_restored_before_enumeration_check(self):
|
||||
result = self.run_recovery(['usb1-port2', 'usb2-port2'])
|
||||
self.assertEqual(len(result), 1)
|
||||
self.assertEqual(result[0]['state'], 'enumerated')
|
||||
self.assertEqual([c.args[1] for c in self.usb.write.call_args_list], [True, True, False, False])
|
||||
self.assertFalse((self.state/'pending.json').exists())
|
||||
|
||||
def test_second_disable_failure_restores_both_and_keeps_failure_detail(self):
|
||||
def write(entry, disabled):
|
||||
if disabled and entry['name'] == 'usb2-port2': raise OSError('failed second write')
|
||||
self.usb.write.side_effect = write
|
||||
result = self.run_recovery()
|
||||
self.assertIn('failed second write', result[0]['reason'])
|
||||
self.assertEqual([c.args[1] for c in self.usb.write.call_args_list], [True, True, False, False])
|
||||
self.assertFalse((self.state/'pending.json').exists())
|
||||
|
||||
def test_termination_during_off_period_restores_ports(self):
|
||||
def interrupted(_): raise InterruptedError('stop')
|
||||
result = recovery.recover(self.usb, self.state, 'boot', ['usb1-port2'], lambda: self.clock, interrupted)
|
||||
self.assertIn('stop', result[0]['reason'])
|
||||
self.assertFalse((self.state/'pending.json').exists())
|
||||
self.assertEqual([c.args[1] for c in self.usb.write.call_args_list], [True, True, False, False])
|
||||
|
||||
def test_restore_failure_preserves_pending_and_stops_other_ports(self):
|
||||
def write(_, disabled):
|
||||
if not disabled: raise OSError('restore failed')
|
||||
self.usb.write.side_effect = write
|
||||
result = self.run_recovery(['usb1-port2', 'usb1-port3'])
|
||||
self.assertEqual(result[0]['state'], 'restore_failed')
|
||||
self.usb.plan.assert_called_once()
|
||||
self.assertTrue((self.state/'pending.json').exists())
|
||||
self.usb.write.side_effect = None
|
||||
recovery.restore(self.usb, self.state, 'boot')
|
||||
self.assertFalse((self.state/'pending.json').exists())
|
||||
|
||||
def test_slow_planning_cannot_start_a_late_reset(self):
|
||||
def plan(_): self.clock = 179; return self.entries
|
||||
self.usb.plan.side_effect = plan
|
||||
result = self.run_recovery()
|
||||
self.assertIn('deadline', result[0]['reason'])
|
||||
self.usb.write.assert_not_called()
|
||||
|
||||
def test_failed_enumeration_has_no_repeated_power_loop(self):
|
||||
self.usb.outcome.return_value = None
|
||||
result = self.run_recovery()
|
||||
self.assertEqual(result[0]['state'], 'retried')
|
||||
self.assertEqual(self.clock, 39)
|
||||
self.assertEqual(self.usb.write.call_count, 4)
|
||||
|
||||
def test_previous_boot_pending_state_cannot_address_current_ports(self):
|
||||
recovery.atomic(self.state/'pending.json', {'boot_id': 'old', 'ports': self.entries})
|
||||
with self.assertRaises(OSError): recovery.restore(self.usb, self.state, 'new')
|
||||
self.usb.write.assert_not_called()
|
||||
|
||||
def test_boot_wait_occurs_before_journal_snapshot_and_second_run_preserves_report(self):
|
||||
boot_file = self.state/'boot-id'; boot_file.write_text('boot')
|
||||
self.clock = 5
|
||||
def journal():
|
||||
self.assertGreaterEqual(self.clock, recovery.MIN_AGE)
|
||||
return []
|
||||
with patch.object(recovery, 'STATE', self.state), patch.object(recovery, 'BOOT_ID', boot_file), \
|
||||
patch.object(recovery, 'state_directory'), patch.object(recovery, 'enabled'), \
|
||||
patch.object(recovery.os, 'geteuid', return_value=0), patch.object(recovery.sys, 'argv', ['helper']), \
|
||||
patch.object(recovery.time, 'monotonic', side_effect=lambda: self.clock), \
|
||||
patch.object(recovery.time, 'sleep', side_effect=self.sleep), \
|
||||
patch.object(recovery, 'journal', side_effect=journal) as read, patch('builtins.print'):
|
||||
self.assertEqual(recovery.main(), 0)
|
||||
first = (self.state/'result.json').read_bytes()
|
||||
self.assertEqual(recovery.main(), 0)
|
||||
self.assertEqual((self.state/'result.json').read_bytes(), first)
|
||||
read.assert_called_once()
|
||||
|
||||
def test_late_service_start_cannot_read_or_reset_ports(self):
|
||||
boot_file = self.state/'boot-id'; boot_file.write_text('boot')
|
||||
with patch.object(recovery, 'STATE', self.state), patch.object(recovery, 'BOOT_ID', boot_file), \
|
||||
patch.object(recovery, 'state_directory'), patch.object(recovery.os, 'geteuid', return_value=0), \
|
||||
patch.object(recovery.sys, 'argv', ['helper']), patch.object(recovery.time, 'monotonic', return_value=200), \
|
||||
patch.object(recovery, 'journal') as read, patch.object(recovery, 'recover') as reset, patch('builtins.print'):
|
||||
self.assertEqual(recovery.main(), 0)
|
||||
read.assert_not_called(); reset.assert_not_called()
|
||||
self.assertEqual(json.loads((self.state/'result.json').read_text())['reason'], 'Outside startup window')
|
||||
|
||||
|
||||
if __name__ == '__main__': unittest.main()
|
||||
@@ -0,0 +1 @@
|
||||
{"schema":"missioncore.node.usb-startup-policy/v1","enabled":true,"mode":"terminal-enumeration-failures"}
|
||||
@@ -0,0 +1,332 @@
|
||||
"""One bounded boot-time retry of failed USB enumeration; no device commands.
|
||||
|
||||
Only root's fixed systemd job may apply. No request supplies a path or command.
|
||||
Healthy ports (including USB3 companions) and non-individual-power hubs are
|
||||
excluded. The application still identifies controllers by firmware UUID.
|
||||
"""
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import signal
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
STATE = Path('/run/mission-core-usb-startup')
|
||||
BOOT_ID = Path('/proc/sys/kernel/random/boot_id')
|
||||
POLICY = Path('/etc/mission-core-node/usb-startup.json')
|
||||
EXPECTED_POLICY = {'schema': 'missioncore.node.usb-startup-policy/v1', 'enabled': True,
|
||||
'mode': 'terminal-enumeration-failures'}
|
||||
BOOT_WINDOW = 180
|
||||
FAILURE_WINDOW = 60
|
||||
MIN_AGE = 30
|
||||
BUDGET = 90
|
||||
PORT = re.compile(r'(usb[1-9][0-9]*|[1-9][0-9]*-[1-9][0-9]*(?:\.[1-9][0-9]*)*)-port([1-9][0-9]*)')
|
||||
FAILURE = re.compile(r'usb ((?:usb[1-9][0-9]*|[1-9][0-9]*-[1-9][0-9]*(?:\.[1-9][0-9]*)*)-port[1-9][0-9]*): unable to enumerate USB device')
|
||||
|
||||
|
||||
def child_name(port):
|
||||
match = PORT.fullmatch(port)
|
||||
if not match:
|
||||
raise ValueError('Invalid USB port')
|
||||
hub, number = match.groups()
|
||||
return hub[3:] + '-' + number if hub.startswith('usb') else hub + '.' + number
|
||||
|
||||
|
||||
def failed_ports(records):
|
||||
"""Ignore failures superseded by a later connection/disconnection event."""
|
||||
failures, changes = {}, {}
|
||||
for entry in sorted(records, key=lambda r: int(r.get('__MONOTONIC_TIMESTAMP', 0))):
|
||||
if entry.get('_TRANSPORT') != 'kernel':
|
||||
continue
|
||||
stamp = int(entry.get('__MONOTONIC_TIMESTAMP', 0)) / 1000000
|
||||
message = entry.get('MESSAGE', '')
|
||||
if not isinstance(message, str):
|
||||
continue
|
||||
match = FAILURE.fullmatch(message)
|
||||
if match and 0 < stamp <= FAILURE_WINDOW:
|
||||
failures[match[1]] = stamp
|
||||
match = re.match(r'usb ([0-9]+-[0-9]+(?:\.[0-9]+)*): (?:new |New USB device found|USB disconnect)', message)
|
||||
if match:
|
||||
changes[match[1]] = stamp
|
||||
return [p for p, stamp in sorted(failures.items()) if changes.get(child_name(p), 0) <= stamp]
|
||||
|
||||
|
||||
def run(args):
|
||||
try:
|
||||
result = subprocess.run(args, capture_output=True, text=True, timeout=5,
|
||||
env={'PATH': '/usr/sbin:/usr/bin:/sbin:/bin', 'LC_ALL': 'C'})
|
||||
except subprocess.SubprocessError as error:
|
||||
raise OSError('USB inspection command timed out or failed') from error
|
||||
if result.returncode or len(result.stdout) > 2 * 1024**2:
|
||||
raise OSError('USB inspection command failed')
|
||||
return result.stdout
|
||||
|
||||
|
||||
def journal():
|
||||
raw = run(['/usr/bin/journalctl', '-k', '-b', '0', '--no-pager', '-o', 'json', '-n', '2000',
|
||||
'--grep=unable to enumerate USB device|new .* USB device|New USB device found|USB disconnect'])
|
||||
return [json.loads(line) for line in raw.splitlines()]
|
||||
|
||||
|
||||
def atomic(path, value):
|
||||
temporary = path.with_suffix('.tmp')
|
||||
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o600)
|
||||
with os.fdopen(fd, 'w') as stream:
|
||||
json.dump(value, stream, sort_keys=True)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, path)
|
||||
|
||||
|
||||
class USB:
|
||||
def __init__(self, root=Path('/sys/bus/usb/devices'), physical=Path('/sys/devices')):
|
||||
self.root, self.physical = root, physical.resolve()
|
||||
|
||||
def resolve(self, name):
|
||||
match = PORT.fullmatch(name)
|
||||
if not match:
|
||||
raise ValueError('Invalid port name')
|
||||
hub = match[1]
|
||||
interface = hub[3:] + '-0' if hub.startswith('usb') else hub
|
||||
paths = list(self.root.glob(interface + ':*/' + name))
|
||||
if len(paths) != 1:
|
||||
raise OSError('USB port topology unavailable')
|
||||
path = paths[0].resolve(strict=True)
|
||||
if not path.is_relative_to(self.physical) or path.name != name:
|
||||
raise OSError('USB port outside sysfs devices')
|
||||
return path
|
||||
|
||||
def generation(self, name):
|
||||
hub = self.root / PORT.fullmatch(name)[1]
|
||||
if (hub / 'bDeviceClass').read_text().strip() != '09':
|
||||
raise OSError('Parent is not a USB hub')
|
||||
return {'bus': int((hub / 'busnum').read_text()), 'address': int((hub / 'devnum').read_text()),
|
||||
'path': str(hub.resolve(strict=True)), 'inode': hub.stat().st_ino}
|
||||
|
||||
def empty(self, name):
|
||||
p = self.resolve(name)
|
||||
return (not os.path.lexists(p / 'device') and
|
||||
(p / 'state').read_text().strip() == 'not attached' and
|
||||
(p / 'disable').read_text().strip() == '0' and
|
||||
(p / 'over_current_count').read_text().strip() == '0' and
|
||||
(p / 'connect_type').read_text().strip() in ('hotplug', 'unknown'))
|
||||
|
||||
def companions(self, name):
|
||||
p = self.resolve(name)
|
||||
names = [name]
|
||||
if os.path.lexists(p / 'peer'):
|
||||
peer = (p / 'peer').resolve(strict=True)
|
||||
if not peer.is_relative_to(self.physical) or self.resolve(peer.name) != peer:
|
||||
raise OSError('USB companion topology changed')
|
||||
if (peer / 'peer').resolve(strict=True) != p:
|
||||
raise OSError('USB companion is not reciprocal')
|
||||
names.append(peer.name)
|
||||
return names
|
||||
|
||||
def individual_power(self, name):
|
||||
before = self.generation(name)
|
||||
raw = run(['/usr/bin/lsusb', '-v', '-s', f"{before['bus']:03}:{before['address']:03}"])
|
||||
characteristics = re.findall(r'^\s*wHubCharacteristic[s]?\s+0x([0-9a-fA-F]+)\s*$', raw, re.M)
|
||||
return (before == self.generation(name) and len(characteristics) == 1 and
|
||||
int(characteristics[0], 16) & 3 == 1)
|
||||
|
||||
def plan(self, name):
|
||||
names = self.companions(name)
|
||||
entries = [{'name': n, 'generation': self.generation(n)} for n in names]
|
||||
# Test every companion before querying hub descriptors or opening any port.
|
||||
if not all(self.empty(n) for n in names):
|
||||
raise OSError('Port or USB companion already attached, disabled, internal or over-current')
|
||||
if not all(self.individual_power(n) for n in names):
|
||||
raise OSError('Individual USB port power switching is not confirmed')
|
||||
if any(self.generation(e['name']) != e['generation'] for e in entries):
|
||||
raise OSError('USB hub changed during companion inspection')
|
||||
return entries
|
||||
|
||||
def write(self, entry, disabled):
|
||||
name = entry['name']
|
||||
if self.generation(name) != entry['generation']:
|
||||
raise OSError('USB hub generation changed')
|
||||
if disabled and not self.empty(name):
|
||||
raise OSError('USB port became occupied')
|
||||
p = self.resolve(name) / 'disable'
|
||||
# Pending state is durable before the first write. An interrupted or
|
||||
# rejected first write must not cause a redundant enable on a port
|
||||
# which has since successfully enumerated.
|
||||
if not disabled and p.read_text().strip() == '0':
|
||||
return
|
||||
fd = os.open(p, os.O_WRONLY | os.O_NOFOLLOW)
|
||||
try:
|
||||
if self.generation(name) != entry['generation'] or (disabled and not self.empty(name)):
|
||||
raise OSError('USB topology changed before write')
|
||||
if os.write(fd, b'1\n' if disabled else b'0\n') != 2:
|
||||
raise OSError('Incomplete USB port write')
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
def outcome(self, name):
|
||||
p = self.resolve(name)
|
||||
if not (p / 'device').exists():
|
||||
return None
|
||||
child = (p / 'device').resolve(strict=True)
|
||||
if not child.is_relative_to(self.physical):
|
||||
raise OSError('Unexpected USB child')
|
||||
return {key: (child / key).read_text().strip() for key in ('idVendor', 'idProduct', 'product')}
|
||||
|
||||
|
||||
def restore(usb, state, boot):
|
||||
p = state / 'pending.json'
|
||||
if not p.exists():
|
||||
return
|
||||
pending = json.loads(p.read_text())
|
||||
if pending['boot_id'] != boot:
|
||||
raise OSError('Pending recovery belongs to another boot')
|
||||
errors = []
|
||||
for entry in reversed(pending['ports']):
|
||||
try:
|
||||
usb.write(entry, False)
|
||||
except OSError as error:
|
||||
errors.append(str(error))
|
||||
if errors:
|
||||
raise OSError('; '.join(errors))
|
||||
p.unlink()
|
||||
|
||||
|
||||
def recover(usb, state, boot, candidates, now=time.monotonic, sleep=time.sleep):
|
||||
deadline = min(now() + BUDGET, BOOT_WINDOW)
|
||||
results, processed = [], set()
|
||||
for name in candidates[:32]:
|
||||
if name in processed:
|
||||
continue
|
||||
item = {'port': name, 'state': 'skipped'}
|
||||
results.append(item)
|
||||
if now() + 12 >= deadline:
|
||||
item['reason'] = 'Boot recovery deadline reached'
|
||||
break
|
||||
try:
|
||||
entries = usb.plan(name)
|
||||
if now() + 12 >= deadline:
|
||||
raise OSError('Boot recovery deadline reached during planning')
|
||||
# Recheck all companions after descriptor reads and before any write.
|
||||
if not all(usb.empty(e['name']) and usb.generation(e['name']) == e['generation'] for e in entries):
|
||||
raise OSError('USB port changed during planning')
|
||||
processed.update(e['name'] for e in entries)
|
||||
item['ports'] = [e['name'] for e in entries]
|
||||
atomic(state / 'pending.json', {'boot_id': boot, 'ports': entries})
|
||||
try:
|
||||
for entry in entries:
|
||||
usb.write(entry, True)
|
||||
sleep(1)
|
||||
finally:
|
||||
restore(usb, state, boot)
|
||||
item['state'] = 'retried'
|
||||
until = min(now() + 8, deadline)
|
||||
while now() < until:
|
||||
result = usb.outcome(name)
|
||||
if result:
|
||||
item.update(state='enumerated', descriptor=result)
|
||||
break
|
||||
sleep(0.25)
|
||||
except (OSError, ValueError) as error:
|
||||
item['reason'] = str(error)
|
||||
if (state / 'pending.json').exists():
|
||||
item['state'] = 'restore_failed'
|
||||
break
|
||||
return results
|
||||
|
||||
|
||||
def state_directory():
|
||||
STATE.mkdir(mode=0o755, exist_ok=True)
|
||||
info = STATE.lstat()
|
||||
if not stat.S_ISDIR(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022:
|
||||
raise OSError('Untrusted recovery state directory')
|
||||
|
||||
|
||||
def enabled():
|
||||
info = POLICY.lstat()
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022 or info.st_size > 1024:
|
||||
raise OSError('Untrusted startup recovery policy')
|
||||
if json.loads(POLICY.read_text()) != EXPECTED_POLICY:
|
||||
raise OSError('Startup recovery policy is not supported')
|
||||
|
||||
|
||||
def inspect(usb):
|
||||
hubs = []
|
||||
deadline = time.monotonic() + 60
|
||||
for p in sorted(usb.root.glob('*')):
|
||||
if time.monotonic() >= deadline or len(hubs) >= 16:
|
||||
break
|
||||
if not re.fullmatch(r'usb[1-9][0-9]*|[1-9][0-9]*-[1-9][0-9]*(?:\.[1-9][0-9]*)*', p.name):
|
||||
continue
|
||||
try:
|
||||
if (p / 'bDeviceClass').read_text().strip() != '09':
|
||||
continue
|
||||
hubs.append({'hub': p.name, 'individual_power': usb.individual_power(p.name + '-port1')})
|
||||
except OSError as error:
|
||||
hubs.append({'hub': p.name, 'error': str(error)})
|
||||
return hubs
|
||||
|
||||
|
||||
def main():
|
||||
if os.geteuid() != 0 or sys.argv[1:] not in ([], ['--inspect'], ['--restore']):
|
||||
raise ValueError('Fixed root-owned startup job only')
|
||||
mode = sys.argv[1:] or ['apply']
|
||||
state_directory()
|
||||
lock = os.open(STATE / 'lock', os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
boot = BOOT_ID.read_text().strip()
|
||||
usb = USB()
|
||||
if mode == ['--restore']:
|
||||
try:
|
||||
restore(usb, STATE, boot)
|
||||
finally:
|
||||
os.close(lock)
|
||||
return 0
|
||||
if mode == ['apply'] and (STATE / 'attempted').exists():
|
||||
# Preserve the first result, including restore failures, across a later
|
||||
# daemon/package restart. Recovery is never retriggered by refresh.
|
||||
try:
|
||||
restore(usb, STATE, boot)
|
||||
finally:
|
||||
os.close(lock)
|
||||
return 0
|
||||
report = {'schema': 'missioncore.node.usb-startup-recovery/v1', 'boot_id': boot,
|
||||
'observed_at_unix': time.time(), 'boot_seconds': time.monotonic(),
|
||||
'mode': mode[0], 'state': 'skipped', 'results': []}
|
||||
destination = STATE / ('inspection.json' if mode == ['--inspect'] else 'result.json')
|
||||
try:
|
||||
if mode == ['--inspect']:
|
||||
report['hubs'] = inspect(usb)
|
||||
report['state'] = 'inspected'
|
||||
elif time.monotonic() >= BOOT_WINDOW:
|
||||
report['reason'] = 'Outside startup window'
|
||||
else:
|
||||
enabled()
|
||||
atomic(STATE / 'attempted', {'boot_id': boot})
|
||||
restore(usb, STATE, boot)
|
||||
# udev-settle can return before the hub driver's delayed retries
|
||||
# give up. Wait before taking the first snapshot, including when
|
||||
# the journal currently contains no terminal failure yet.
|
||||
time.sleep(max(0, MIN_AGE - time.monotonic()))
|
||||
candidates = failed_ports(journal())
|
||||
if candidates:
|
||||
report['results'] = recover(usb, STATE, boot, candidates)
|
||||
report['state'] = 'complete'
|
||||
except (OSError, ValueError, subprocess.SubprocessError) as error:
|
||||
report.update(state='error', reason=str(error))
|
||||
finally:
|
||||
atomic(destination, report)
|
||||
destination.chmod(0o644)
|
||||
os.close(lock)
|
||||
print(json.dumps(report, sort_keys=True))
|
||||
return 1 if report['state'] == 'error' or any(r['state'] == 'restore_failed' for r in report['results']) else 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
def interrupted(*_):
|
||||
raise InterruptedError('Startup recovery interrupted')
|
||||
signal.signal(signal.SIGTERM, interrupted)
|
||||
sys.exit(main())
|
||||
@@ -38,7 +38,7 @@ function App() {
|
||||
function openView(id: ViewId) { if(environment.running) return; setAdding(false); setRoot(views.find(item => item.id === id)!.root); workspace.openView(id); }
|
||||
function selectRoot(id: RootId) { const first = roots.find(item => item.id === id)!.first; if (first) openView(first); }
|
||||
const content = !value ? null : workspace.activeView === "environment" ? <EnvironmentView environment={environment} failure={failure} success={node.success} /> : workspace.activeView === "overview" ? <NodeOverview value={value} refresh={refresh} failure={failure} openView={openView} />
|
||||
: workspace.activeView === "sensors" ? <NodeSensors />
|
||||
: workspace.activeView === "sensors" ? <NodeSensors value={value} openSettings={()=>openView("environment")} />
|
||||
: workspace.activeView === "network" ? <NetworkView value={value} />
|
||||
: workspace.activeView === "usb" ? <USBView value={value} />
|
||||
: workspace.activeView === "core" ? <CoreConnectionView failure={failure} />
|
||||
|
||||
@@ -7,6 +7,7 @@ import platform
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
@@ -24,11 +25,44 @@ PROFILES = {
|
||||
}
|
||||
|
||||
|
||||
def sync_directory(path):
|
||||
directory = os.open(path, os.O_RDONLY | os.O_DIRECTORY)
|
||||
try:
|
||||
os.fsync(directory)
|
||||
finally:
|
||||
os.close(directory)
|
||||
|
||||
|
||||
def private(path):
|
||||
path.mkdir(mode=0o700, exist_ok=True)
|
||||
info = path.lstat()
|
||||
if path.is_symlink() or info.st_uid != os.geteuid() or info.st_mode & 0o077:
|
||||
raise RuntimeError("Release directory is not private and owned")
|
||||
sync_directory(path.parent)
|
||||
|
||||
|
||||
def stage_file(path, data, mode):
|
||||
"""Publish complete, durable installer files before opening the sudo UI."""
|
||||
if path.is_symlink():
|
||||
raise ValueError("Unexpected release symlink")
|
||||
if path.exists():
|
||||
with path.open("rb") as stream:
|
||||
if stream.read() != data:
|
||||
raise ValueError("Existing release was modified")
|
||||
os.fsync(stream.fileno())
|
||||
else:
|
||||
descriptor, temporary = tempfile.mkstemp(prefix="." + path.name + ".", dir=path.parent)
|
||||
try:
|
||||
with os.fdopen(descriptor, "wb") as stream:
|
||||
os.fchmod(stream.fileno(), mode)
|
||||
stream.write(data)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
# Do not overwrite another launcher or a modified staged file.
|
||||
os.link(temporary, path)
|
||||
finally:
|
||||
os.unlink(temporary)
|
||||
sync_directory(path.parent)
|
||||
|
||||
|
||||
def main():
|
||||
@@ -74,16 +108,7 @@ def main():
|
||||
raise ValueError("Release payload changed")
|
||||
files["release.json"] = raw
|
||||
for name, data in files.items():
|
||||
path = folder / name
|
||||
if path.is_symlink():
|
||||
raise ValueError("Unexpected release symlink")
|
||||
if path.exists():
|
||||
if path.read_bytes() != data:
|
||||
raise ValueError("Existing release was modified")
|
||||
else:
|
||||
with path.open("xb") as stream:
|
||||
stream.write(data)
|
||||
path.chmod(0o700 if name == "install" else 0o600)
|
||||
stage_file(folder / name, data, 0o700 if name == "install" else 0o600)
|
||||
print(json.dumps({"release_id": identifier, "directory": str(folder)}), flush=True)
|
||||
if sys.argv[1] == "--plan":
|
||||
result = subprocess.run(
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Installer staging survives interrupted writes without admitting corrupt files."""
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"owner_release_entry", Path(__file__).with_name("owner_release_entry.py")
|
||||
)
|
||||
entry = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(entry)
|
||||
|
||||
|
||||
class ReleaseStagingTests(unittest.TestCase):
|
||||
def test_sync_failure_never_publishes_partial_payload(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
target = Path(directory) / "package.deb"
|
||||
with patch.object(entry.os, "fsync", side_effect=OSError("disk failure")):
|
||||
with self.assertRaises(OSError):
|
||||
entry.stage_file(target, b"complete package", 0o600)
|
||||
self.assertEqual(list(Path(directory).iterdir()), [])
|
||||
entry.stage_file(target, b"complete package", 0o600)
|
||||
self.assertEqual(target.read_bytes(), b"complete package")
|
||||
self.assertEqual(target.stat().st_mode & 0o777, 0o600)
|
||||
|
||||
def test_valid_staging_can_be_repeated_without_replacing_inode(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
target = Path(directory) / "install"
|
||||
entry.stage_file(target, b"installer", 0o700)
|
||||
inode = target.stat().st_ino
|
||||
entry.stage_file(target, b"installer", 0o700)
|
||||
self.assertEqual(target.stat().st_ino, inode)
|
||||
self.assertEqual(target.stat().st_mode & 0o777, 0o700)
|
||||
|
||||
def test_truncated_existing_file_is_preserved_and_rejected(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
target = Path(directory) / "package.deb"
|
||||
target.write_bytes(b"partial")
|
||||
with self.assertRaisesRegex(ValueError, "modified"):
|
||||
entry.stage_file(target, b"complete package", 0o600)
|
||||
self.assertEqual(target.read_bytes(), b"partial")
|
||||
|
||||
def test_symlink_never_changes_its_target(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
real = Path(directory) / "real"
|
||||
real.write_bytes(b"keep")
|
||||
target = Path(directory) / "package.deb"
|
||||
target.symlink_to(real)
|
||||
with self.assertRaisesRegex(ValueError, "symlink"):
|
||||
entry.stage_file(target, b"replacement", 0o600)
|
||||
self.assertEqual(real.read_bytes(), b"keep")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user