feat(node): ship managed environment startup and USB recovery

This commit is contained in:
DCCONSTRUCTIONS
2026-09-25 16:37:56 +03:00
parent 45fb14b206
commit 53818230f9
25 changed files with 900 additions and 21 deletions
+18
View File
@@ -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;
}
});
+2 -1
View File
@@ -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}
+10 -3
View File
@@ -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
+4
View File
@@ -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.
+13
View File
@@ -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
+43
View File
@@ -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())
+1 -1
View File
@@ -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()