同步移动端工程、设备控制改进与发布资料
This commit is contained in:
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
@@ -15,7 +16,14 @@ PRIVATE_CREDENTIAL = PurePosixPath("测试相关资料/核桃派的用户名和
|
||||
EXAMPLE_CREDENTIAL = PurePosixPath(
|
||||
"测试相关资料/核桃派的用户名和密码和ip/用户名密码ip.example.txt"
|
||||
)
|
||||
PRIVATE_MOBILE_REGISTRATION = PurePosixPath(
|
||||
"移动端相关内容/安卓app/如何安卓测试/测试设备登记/测试设备.local.json"
|
||||
)
|
||||
EXAMPLE_MOBILE_REGISTRATION = PRIVATE_MOBILE_REGISTRATION.with_name("测试设备.example.json")
|
||||
BINARY_EXTENSIONS = {
|
||||
".apk",
|
||||
".aab",
|
||||
".jar",
|
||||
".deb",
|
||||
".dll",
|
||||
".docx",
|
||||
@@ -120,7 +128,7 @@ def _host_text_patterns() -> list[re.Pattern[str]]:
|
||||
def _private_value_markers() -> tuple[str, ...]:
|
||||
path = WORKSPACE_ROOT.joinpath(*PRIVATE_CREDENTIAL.parts)
|
||||
if not path.is_file():
|
||||
return ()
|
||||
return _mobile_value_markers()
|
||||
fields: dict[str, str] = {}
|
||||
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw_line.strip()
|
||||
@@ -132,13 +140,36 @@ def _private_value_markers() -> tuple[str, ...]:
|
||||
key, value = (part.strip() for part in line.split(separator, 1))
|
||||
fields[key] = value
|
||||
public_values = {fields.get(key, "") for key in PUBLIC_IMAGE_KEYS}
|
||||
return tuple(
|
||||
return _mobile_value_markers() + tuple(
|
||||
value
|
||||
for key in CURRENT_DEVICE_KEYS
|
||||
if len(value := fields.get(key, "")) >= 4 and value not in public_values
|
||||
)
|
||||
|
||||
|
||||
def _mobile_value_markers() -> tuple[str, ...]:
|
||||
path = WORKSPACE_ROOT.joinpath(*PRIVATE_MOBILE_REGISTRATION.parts)
|
||||
if not path.is_file():
|
||||
return ()
|
||||
try:
|
||||
document = json.loads(path.read_text(encoding="utf-8"))
|
||||
devices = document["devices"]
|
||||
if not isinstance(devices, list):
|
||||
raise ValueError
|
||||
values = []
|
||||
for device in devices:
|
||||
if not isinstance(device, dict):
|
||||
raise ValueError
|
||||
serial = device.get("serial", "")
|
||||
if not isinstance(serial, str):
|
||||
raise ValueError
|
||||
if serial and "<" not in serial and len(serial) >= 4:
|
||||
values.append(serial)
|
||||
return tuple(values)
|
||||
except (OSError, UnicodeError, ValueError, KeyError, TypeError):
|
||||
raise HygieneError("移动端真实登记损坏,无法安全提取私有标识;未输出内容。") from None
|
||||
|
||||
|
||||
def _binary_markers(private_values: tuple[str, ...] = ()) -> list[bytes]:
|
||||
markers: list[bytes] = []
|
||||
host_values = {
|
||||
@@ -178,7 +209,7 @@ def _text_issues(
|
||||
if any(pattern.search(text) for pattern in _host_text_patterns()):
|
||||
issues.append("包含开发电脑专属路径、用户名或主机名")
|
||||
if any(value in text for value in private_values):
|
||||
issues.append("包含当前设备私有凭据值")
|
||||
issues.append("包含当前设备私有凭据或测试手机标识")
|
||||
|
||||
key_header = re.compile("-----BEGIN " + r"(?:OPENSSH |RSA |EC |DSA )?PRIVATE KEY-----")
|
||||
if key_header.search(text):
|
||||
@@ -217,9 +248,12 @@ def audit_paths(paths: list[PurePosixPath], *, scan_binary: bool) -> list[tuple[
|
||||
findings: list[tuple[str, str]] = []
|
||||
private_values = _private_value_markers()
|
||||
for relative in paths:
|
||||
if relative == PRIVATE_CREDENTIAL:
|
||||
if relative in (PRIVATE_CREDENTIAL, PRIVATE_MOBILE_REGISTRATION):
|
||||
findings.append((relative.as_posix(), "真实凭据进入 Git 候选集合"))
|
||||
continue
|
||||
if relative.suffix.lower() in {".jks", ".keystore"}:
|
||||
findings.append((relative.as_posix(), "签名密钥文件进入 Git 候选集合"))
|
||||
continue
|
||||
path = WORKSPACE_ROOT.joinpath(*relative.parts)
|
||||
if not path.is_file():
|
||||
continue
|
||||
@@ -243,6 +277,15 @@ def _check_repository_contract(paths: list[PurePosixPath], staged: bool) -> list
|
||||
ignored = _git("check-ignore", "--quiet", "--", PRIVATE_CREDENTIAL.as_posix(), check=False)
|
||||
if ignored.returncode != 0:
|
||||
findings.append((PRIVATE_CREDENTIAL.as_posix(), "真实凭据未被 .gitignore 精确排除"))
|
||||
mobile_ignored = _git("check-ignore", "--quiet", "--", PRIVATE_MOBILE_REGISTRATION.as_posix(), check=False)
|
||||
if mobile_ignored.returncode != 0:
|
||||
findings.append((PRIVATE_MOBILE_REGISTRATION.as_posix(), "真实手机登记未被 .gitignore 排除"))
|
||||
if staged:
|
||||
mobile_example = _git("cat-file", "-e", f":{EXAMPLE_MOBILE_REGISTRATION.as_posix()}", check=False)
|
||||
if mobile_example.returncode != 0:
|
||||
findings.append((EXAMPLE_MOBILE_REGISTRATION.as_posix(), "手机空白登记示例不在 Git 索引"))
|
||||
elif EXAMPLE_MOBILE_REGISTRATION not in paths:
|
||||
findings.append((EXAMPLE_MOBILE_REGISTRATION.as_posix(), "手机空白登记示例不在 Git 候选集合"))
|
||||
|
||||
if staged:
|
||||
example_in_index = _git("cat-file", "-e", f":{EXAMPLE_CREDENTIAL.as_posix()}", check=False)
|
||||
|
||||
@@ -13,8 +13,6 @@ from typing import Callable, Sequence
|
||||
EXPECTED_PINCTRL = "allwinner,sun50i-h616-pinctrl"
|
||||
DISABLED_SERVICES = (
|
||||
"lightdm.service",
|
||||
"bluetooth.service",
|
||||
"aw859-bluetooth.service",
|
||||
"gpioc-server.service",
|
||||
"map_device.service",
|
||||
)
|
||||
|
||||
@@ -96,10 +96,9 @@ make -C "$STAGING/app/display/native" clean all test
|
||||
systemctl set-default multi-user.target
|
||||
systemctl disable --now \
|
||||
lightdm.service \
|
||||
bluetooth.service \
|
||||
aw859-bluetooth.service \
|
||||
gpioc-server.service \
|
||||
map_device.service
|
||||
DEFER_SERVICE_START="$DEFER_SERVICE_START" /bin/sh "$STAGING/scripts/install_mobile_bluetooth.sh"
|
||||
if [ "$DEFER_SERVICE_START" = "1" ]; then
|
||||
"$STAGING/.venv/bin/python" "$STAGING/scripts/dedicated_host.py" check --service
|
||||
else
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
#!/bin/sh
|
||||
# Enable the existing WalnutPi vendor controller, without changing WiFi rfkill.
|
||||
set -eu
|
||||
test "$(id -u)" = 0 || { echo 'root is required' >&2; exit 1; }
|
||||
test -x /usr/bin/hciattach
|
||||
test -x /usr/sbin/rfkill
|
||||
test -f /lib/systemd/system/aw859-bluetooth.service
|
||||
install -d -m 0755 /etc/systemd/system/aw859-bluetooth.service.d
|
||||
cat > /etc/systemd/system/aw859-bluetooth.service.d/50-matrix-mobile.conf <<'EOF'
|
||||
[Service]
|
||||
ExecStartPre=
|
||||
ExecStartPre=/usr/sbin/modprobe hci_uart
|
||||
ExecStartPre=/usr/sbin/rfkill unblock bluetooth
|
||||
ExecStart=
|
||||
ExecStart=/usr/bin/hciattach -n -s 1500000 /dev/ttyBT0 sprd
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
EOF
|
||||
install -d -m 0755 /etc/systemd/system/bluetooth.service.d
|
||||
cat > /etc/systemd/system/bluetooth.service.d/50-matrix-mobile.conf <<'EOF'
|
||||
[Unit]
|
||||
After=aw859-bluetooth.service
|
||||
Wants=aw859-bluetooth.service
|
||||
EOF
|
||||
python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
import re
|
||||
p = Path('/etc/bluetooth/main.conf')
|
||||
text = p.read_text(encoding='utf-8')
|
||||
backup = p.with_name('main.conf.matrix-mobile-original')
|
||||
if not backup.exists():
|
||||
with backup.open('x', encoding='utf-8') as stream:
|
||||
stream.write(text)
|
||||
for key, value in [('ControllerMode', 'le'), ('AlwaysPairable', 'false')]:
|
||||
pattern = r'(?m)^\s*' + key + r'\s*=.*$'
|
||||
if re.search(pattern, text):
|
||||
text = re.sub(pattern, key + '=' + value, text)
|
||||
else:
|
||||
text = text.replace('[General]', '[General]\n' + key + '=' + value, 1)
|
||||
p.write_text(text, encoding='utf-8')
|
||||
PY
|
||||
systemctl unmask bluetooth.service aw859-bluetooth.service
|
||||
systemctl daemon-reload
|
||||
systemctl enable bluetooth.service aw859-bluetooth.service
|
||||
if [ "${DEFER_SERVICE_START:-0}" != 1 ]; then
|
||||
# Bluetooth failure must not prevent deploying or running the display service.
|
||||
systemctl start aw859-bluetooth.service bluetooth.service || echo 'Bluetooth unavailable; inspect mobile status' >&2
|
||||
fi
|
||||
@@ -32,6 +32,8 @@ RUNTIME_GUARD_BODY = b'# Managed by the OTA component transaction\n[Service]\nRu
|
||||
SERVICE = 'matrix-screen-controller.service'
|
||||
WORKER = 'matrix-screen-controller-ota.service'
|
||||
FRP = 'matrix-screen-frpc.service'
|
||||
MOBILE_SERVICES = ('aw859-bluetooth.service', 'bluetooth.service')
|
||||
MOBILE_FILES = ('bluetooth-conf', 'bluetooth-original', 'bluetooth-dropin', 'aw859-dropin')
|
||||
JOURNAL = Path('ota/component-transaction')
|
||||
FILES = {
|
||||
'frpc': Path('/usr/local/bin/frpc'),
|
||||
@@ -39,6 +41,10 @@ FILES = {
|
||||
'frpc-dropin': Path('/etc/systemd/system/matrix-screen-frpc.service.d/user.conf'),
|
||||
'app-unit': Path('/etc/systemd/system/matrix-screen-controller.service'),
|
||||
'ota-unit': Path('/etc/systemd/system/matrix-screen-controller-ota.service'),
|
||||
'bluetooth-conf': Path('/etc/bluetooth/main.conf'),
|
||||
'bluetooth-original': Path('/etc/bluetooth/main.conf.matrix-mobile-original'),
|
||||
'bluetooth-dropin': Path('/etc/systemd/system/bluetooth.service.d/50-matrix-mobile.conf'),
|
||||
'aw859-dropin': Path('/etc/systemd/system/aw859-bluetooth.service.d/50-matrix-mobile.conf'),
|
||||
}
|
||||
|
||||
|
||||
@@ -229,15 +235,18 @@ def begin(source: Path, candidate: Path):
|
||||
if RUNTIME_GUARD.exists():
|
||||
raise RuntimeError('存在未清理的 OTA 运行目录保护,请先恢复')
|
||||
bundle = source / 'system-dependencies/frpc'
|
||||
mobile_installer = source / 'scripts/install_mobile_bluetooth.sh'
|
||||
if not bundle.is_dir():
|
||||
check_installed(source, version)
|
||||
return
|
||||
if not mobile_installer.is_file():
|
||||
return
|
||||
# Verify the pinned binary, not just a self-reported checksum file.
|
||||
from app.ota.policy import required_components
|
||||
expected = required_components(source, SoftwareVersion.parse(version))['frpc']
|
||||
if hashlib.sha256((bundle / 'frpc').read_bytes()).hexdigest() != expected['sha256']:
|
||||
raise RuntimeError('frpc payload differs from the registered dependency')
|
||||
user = account()
|
||||
if bundle.is_dir():
|
||||
expected = required_components(source, SoftwareVersion.parse(version))['frpc']
|
||||
if hashlib.sha256((bundle / 'frpc').read_bytes()).hexdigest() != expected['sha256']:
|
||||
raise RuntimeError('frpc payload differs from the registered dependency')
|
||||
user = account() if bundle.is_dir() else None
|
||||
for root in (DATA, candidate):
|
||||
if (root / JOURNAL).exists():
|
||||
raise RuntimeError('存在未完成组件事务,请先恢复')
|
||||
@@ -249,6 +258,13 @@ def begin(source: Path, candidate: Path):
|
||||
'permissions': permission_snapshot(DATA), 'files': {},
|
||||
'active': run(['systemctl', 'is-active', '--quiet', FRP], check=False).returncode == 0,
|
||||
'enabled': run(['systemctl', 'is-enabled', '--quiet', FRP], check=False).returncode == 0}
|
||||
if mobile_installer.is_file():
|
||||
record['mobile_services'] = {
|
||||
unit: {
|
||||
'enabled_state': run(['systemctl', 'is-enabled', unit], check=False).stdout.decode().strip(),
|
||||
'active': run(['systemctl', 'is-active', '--quiet', unit], check=False).returncode == 0,
|
||||
} for unit in MOBILE_SERVICES
|
||||
}
|
||||
try:
|
||||
for name, path in FILES.items():
|
||||
record['files'][name] = metadata(path) if path.exists() else None
|
||||
@@ -273,11 +289,14 @@ def begin(source: Path, candidate: Path):
|
||||
'/usr/bin/python3', str(HELPER), 'watch'])
|
||||
protect_runtime()
|
||||
env = os.environ.copy()
|
||||
env.update(FRPC_BUNDLE=str(bundle), FRPC_RUN_USER=user)
|
||||
run(['/bin/sh', str(source / 'scripts/install_frpc_system.sh')], env=env)
|
||||
if bundle.is_dir():
|
||||
env.update(FRPC_BUNDLE=str(bundle), FRPC_RUN_USER=user)
|
||||
run(['/bin/sh', str(source / 'scripts/install_frpc_system.sh')], env=env)
|
||||
if mobile_installer.is_file():
|
||||
run(['/bin/sh', str(mobile_installer)], env=env)
|
||||
mirror_permissions(candidate)
|
||||
check_installed(source, version)
|
||||
print('FRP component transaction prepared; waiting for OTA health result')
|
||||
print('System component transaction prepared; waiting for OTA health result')
|
||||
except BaseException:
|
||||
# The watcher handles subsequent worker failure. Restore immediately too,
|
||||
# so a failed migration never leaves new system files while rolling back.
|
||||
@@ -289,6 +308,10 @@ def begin(source: Path, candidate: Path):
|
||||
|
||||
|
||||
def restore_components(journal: Path, record: dict):
|
||||
mobile_services = record.get('mobile_services', {})
|
||||
for unit in reversed(MOBILE_SERVICES):
|
||||
if unit in mobile_services:
|
||||
run(['systemctl', 'stop', unit], check=False)
|
||||
run(['systemctl', 'stop', FRP], check=False)
|
||||
for name in ('frpc', 'frpc-unit', 'frpc-dropin'):
|
||||
path = FILES[name]
|
||||
@@ -299,6 +322,15 @@ def restore_components(journal: Path, record: dict):
|
||||
atomic(path, (journal / name).read_bytes(), item['mode'])
|
||||
apply_metadata(path, item)
|
||||
restore_permissions(DATA, record['permissions'])
|
||||
if mobile_services:
|
||||
for name in MOBILE_FILES:
|
||||
path = FILES[name]
|
||||
item = record['files'][name]
|
||||
if item is None:
|
||||
path.unlink(missing_ok=True)
|
||||
else:
|
||||
atomic(path, (journal / name).read_bytes(), item['mode'])
|
||||
apply_metadata(path, item)
|
||||
run(['systemctl', 'daemon-reload'])
|
||||
# A previously absent unit cannot be disabled; avoid treating absence as error.
|
||||
if record['files']['frpc-unit'] is not None:
|
||||
@@ -309,6 +341,24 @@ def restore_components(journal: Path, record: dict):
|
||||
link.unlink(missing_ok=True)
|
||||
if record['active']:
|
||||
run(['systemctl', 'start', FRP])
|
||||
for unit in MOBILE_SERVICES:
|
||||
previous = mobile_services.get(unit)
|
||||
if previous is None:
|
||||
continue
|
||||
run(['systemctl', 'unmask', unit])
|
||||
state = previous['enabled_state']
|
||||
run(['systemctl', 'disable', unit], check=False)
|
||||
if state == 'enabled':
|
||||
run(['systemctl', 'enable', unit])
|
||||
elif state == 'enabled-runtime':
|
||||
run(['systemctl', 'enable', '--runtime', unit])
|
||||
elif state in ('masked', 'masked-runtime'):
|
||||
args = ['systemctl', 'mask']
|
||||
if state == 'masked-runtime':
|
||||
args.append('--runtime')
|
||||
run(args + [unit])
|
||||
if previous['active']:
|
||||
run(['systemctl', 'start', unit])
|
||||
|
||||
|
||||
def locate_journal():
|
||||
|
||||
@@ -14,11 +14,13 @@ import sys
|
||||
import time
|
||||
import traceback
|
||||
from typing import Any
|
||||
import urllib.request
|
||||
|
||||
from app.ota.diagnostics import DiagnosticLog, clear_failure_log, persist_failure_log, sha256_file
|
||||
from app.ota.package import extract_payload, inspect_package
|
||||
from app.ota.state import read_json, utc_now, write_json, write_last_result
|
||||
from app.ota.versioning import SoftwareVersion
|
||||
from app.system.kernel_recovery import CANDIDATE_RELEASE, KernelRecovery
|
||||
|
||||
TARGET = Path("/opt/matrix-screen-controller")
|
||||
RELEASES = Path("/opt/matrix-screen-controller.releases")
|
||||
@@ -34,6 +36,32 @@ class UpdateFailed(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def verify_kernel_capability_retained(
|
||||
before: dict[str, Any],
|
||||
after: dict[str, Any],
|
||||
performance_before: dict[str, Any],
|
||||
performance_after: dict[str, Any],
|
||||
) -> None:
|
||||
previously_healthy = (
|
||||
before.get("release") == CANDIDATE_RELEASE
|
||||
and before.get("available") is True
|
||||
and before.get("marker") is True
|
||||
)
|
||||
if not previously_healthy:
|
||||
return
|
||||
if (after["release"] != CANDIDATE_RELEASE or not after["available"] or not after["marker"]
|
||||
or set(after["policies"]) != set(before["policies"])):
|
||||
raise UpdateFailed("OTA lost the validated candidate kernel or cpufreq policy")
|
||||
requested = performance_before.get("requested")
|
||||
if (performance_after.get("available") is not True or performance_after.get("requested") is not requested
|
||||
or (requested is True and performance_after.get("effective") is not True)):
|
||||
raise UpdateFailed("OTA changed performance mode availability or effective state")
|
||||
if requested is False and performance_after.get("current_governors") != {
|
||||
name: value["current"] for name, value in before["policies"].items()
|
||||
}:
|
||||
raise UpdateFailed("OTA did not restore the original CPU governors")
|
||||
|
||||
|
||||
def snapshot(root: Path, *, ignored: set[str] | None = None) -> dict[str, tuple[int, str]]:
|
||||
records: dict[str, tuple[int, str]] = {}
|
||||
ignored = ignored or set()
|
||||
@@ -81,6 +109,9 @@ class Transaction:
|
||||
f"job_id={self.job_id} current_version={self.current_version} "
|
||||
f"target_version={self.target_version} packaged_at={request.get('packaged_at') or ''}"
|
||||
)
|
||||
self.kernel_recovery = KernelRecovery(DATA_ROOT)
|
||||
self.kernel_before: dict[str, Any] | None = None
|
||||
self.performance_before: dict[str, Any] | None = None
|
||||
self.job = {
|
||||
"id": self.job_id,
|
||||
"target_version": str(self.target_version),
|
||||
@@ -118,6 +149,13 @@ class Transaction:
|
||||
)
|
||||
|
||||
def prepare(self) -> None:
|
||||
self.kernel_before = self.kernel_recovery.capability()
|
||||
with urllib.request.urlopen("http://127.0.0.1:8080/api/status", timeout=8) as response:
|
||||
self.performance_before = json.loads(response.read().decode("utf-8"))["system"]["performance_mode"]
|
||||
self.diagnostics.write(
|
||||
f"kernel_before={self.kernel_before['release']} "
|
||||
f"cpufreq_available={self.kernel_before['available']} marker={self.kernel_before['marker']}"
|
||||
)
|
||||
if any(path.exists() for path in (self.work_root, self.release, self.data_candidate, self.data_backup)):
|
||||
raise UpdateFailed("OTA transaction paths already exist")
|
||||
self.work_root.mkdir(parents=True, mode=0o700)
|
||||
@@ -273,6 +311,15 @@ class Transaction:
|
||||
raise UpdateFailed("new service reports the wrong hardware mapping")
|
||||
if driver.get("oe_timing_backend") != "h618-pwm4" or driver.get("oe_pulse_faults") != 0:
|
||||
raise UpdateFailed("new service did not pass the PWM4 OE health check")
|
||||
before = self.kernel_before or {}
|
||||
if (before.get("available") and before.get("marker")
|
||||
and before.get("release") == CANDIDATE_RELEASE):
|
||||
verify_kernel_capability_retained(
|
||||
before, self.kernel_recovery.capability(), self.performance_before or {},
|
||||
(status.get("system") or {}).get("performance_mode") or {},
|
||||
)
|
||||
else:
|
||||
self.diagnostics.write("kernel_preexisting_degraded=true; application update may proceed")
|
||||
self.diagnostics.write(
|
||||
"health_summary="
|
||||
f"version:{status.get('service', {}).get('software_version')} "
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exercise the independent recovery task after a real systemd service stop."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
def run(*args: str) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(args, check=True, capture_output=True, text=True, timeout=30)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if os.geteuid() != 0 or not Path("/run/systemd/system").is_dir():
|
||||
raise RuntimeError("real systemd lifecycle test requires root on a systemd host")
|
||||
token = uuid4().hex[:12]
|
||||
directory_name = f"matrix-kernel-recovery-test-{token}"
|
||||
main_unit = f"{directory_name}-main"
|
||||
task_unit = f"{directory_name}-task"
|
||||
runtime = Path("/run") / directory_name
|
||||
if runtime.exists():
|
||||
raise RuntimeError("isolated runtime directory already exists")
|
||||
try:
|
||||
run("systemd-run", "--quiet", f"--unit={main_unit}",
|
||||
f"--property=RuntimeDirectory={directory_name}",
|
||||
"--property=RuntimeDirectoryPreserve=yes", "/bin/sleep", "120")
|
||||
for _ in range(30):
|
||||
if runtime.is_dir():
|
||||
break
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
raise RuntimeError("temporary service did not create its runtime directory")
|
||||
if run("systemctl", "show", main_unit, "--property=RuntimeDirectoryPreserve", "--value").stdout.strip() != "yes":
|
||||
raise RuntimeError("temporary service did not enable runtime preservation")
|
||||
run("systemctl", "stop", main_unit)
|
||||
if not runtime.is_dir():
|
||||
raise RuntimeError("systemd removed the runtime directory after service stop")
|
||||
proof = runtime / "independent-task.txt"
|
||||
run("systemd-run", "--quiet", "--wait", "--collect", f"--unit={task_unit}",
|
||||
"/usr/bin/python3", "-c",
|
||||
"from pathlib import Path; import sys; Path(sys.argv[1]).write_text('ok', encoding='ascii')",
|
||||
str(proof))
|
||||
if proof.read_text(encoding="ascii") != "ok":
|
||||
raise RuntimeError("independent task did not survive the main service stop")
|
||||
print("real systemd recovery lifecycle passed")
|
||||
return 0
|
||||
finally:
|
||||
subprocess.run(["systemctl", "stop", main_unit], check=False, capture_output=True, timeout=15)
|
||||
if runtime.is_dir() and runtime.parent == Path("/run") and runtime.name == directory_name:
|
||||
shutil.rmtree(runtime)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user