525 lines
21 KiB
Python
525 lines
21 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import threading
|
|
import time
|
|
import zipfile
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from cognitive_agent.application.firmware_workbench import (
|
|
FINAL_RELEASE_MESSAGE,
|
|
FirmwareWorkbenchError,
|
|
FirmwareWorkbenchService,
|
|
_atomic_write_json,
|
|
)
|
|
from cognitive_agent.infrastructure.can.gateway import CanGatewayError
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
|
|
|
|
def wait_for_terminal(service: FirmwareWorkbenchService, task_id: str) -> dict[str, object]:
|
|
deadline = time.monotonic() + 5
|
|
while time.monotonic() < deadline:
|
|
task = service.task(task_id)
|
|
if task["status"] not in {"waiting", "running", "pending_confirmation"}:
|
|
return task
|
|
time.sleep(0.02)
|
|
raise AssertionError("firmware pipeline did not reach a terminal state")
|
|
|
|
|
|
def make_image(tmp_path: Path) -> Path:
|
|
image = tmp_path / "Seat_Controller_demo.hex"
|
|
image.write_bytes(b":020000040000FA\n" + b":100000000102030405060708090A0B0C0D0E0F1068\n" * 80)
|
|
return image
|
|
|
|
|
|
def test_pipeline_publishes_a_downloadable_ota_package(
|
|
tmp_path: Path, monkeypatch
|
|
) -> None:
|
|
monkeypatch.setenv(
|
|
"FIRMWARE_WORKBENCH_OTA_PUBLIC_BASE_URL",
|
|
"http://192.168.43.130:8000/api/firmware-workbench/releases",
|
|
)
|
|
image = make_image(tmp_path)
|
|
|
|
def build_runner(
|
|
_task_id: str, log: object, _cancel: threading.Event
|
|
) -> Path:
|
|
log("info", "IAR build: 0 errors, 0 warnings")
|
|
return image
|
|
|
|
service = FirmwareWorkbenchService(
|
|
ROOT,
|
|
artifact_root=tmp_path / "workbench",
|
|
ota_root=tmp_path / "ota",
|
|
build_runner=build_runner,
|
|
)
|
|
instruction = "增加支持座椅位置参数控制的能力,增加座椅通风功能,增加座椅加热功能"
|
|
|
|
first = service.start_task(instruction)
|
|
task = wait_for_terminal(service, str(first["task"]["id"]))
|
|
assert task["status"] == "success"
|
|
assert task["message"] == FINAL_RELEASE_MESSAGE
|
|
assert [stage["status"] for stage in task["stages"]] == ["success"] * 8
|
|
assert len(task["test_results"]) == 7
|
|
|
|
snapshot = service.snapshot()
|
|
assert snapshot["baselines"] == [
|
|
{
|
|
"id": "time_only",
|
|
"name": "时间控制基线",
|
|
"capabilities": ["时间控制"],
|
|
}
|
|
]
|
|
assert len(snapshot["artifacts"]) == 2
|
|
assert len(snapshot["releases"]) == 1
|
|
artifact = next(item for item in snapshot["artifacts"] if item["task_id"] == task["id"])
|
|
baseline = next(item for item in snapshot["artifacts"] if item.get("is_baseline"))
|
|
release = snapshot["releases"][0]
|
|
assert baseline["capabilities"] == ["时间控制"]
|
|
assert baseline["protected"] is True
|
|
assert artifact["capabilities"] == ["时间控制", "位置控制", "座椅通风", "座椅加热"]
|
|
assert artifact["status"] == "published"
|
|
assert release["download_url"] == (
|
|
f"http://192.168.43.130:8000/api/firmware-workbench/releases/"
|
|
f"{release['id']}/download"
|
|
)
|
|
assert service.latest_release()["download_url"] == release["download_url"]
|
|
latest_manifest = json.loads((tmp_path / "ota" / "latest.json").read_text("utf-8"))
|
|
assert latest_manifest["download_url"] == release["download_url"]
|
|
package, filename = service.resolve_release_download(str(release["id"]))
|
|
assert filename == artifact["firmware_filename"]
|
|
assert hashlib.sha256(package.read_bytes()).hexdigest() == artifact["sha256"]
|
|
with zipfile.ZipFile(package) as archive:
|
|
assert {
|
|
"Seat_Controller_demo.hex",
|
|
"manifest.json",
|
|
"requirements.json",
|
|
"validation.json",
|
|
"feature_profile.json",
|
|
} <= set(archive.namelist())
|
|
assert (tmp_path / "ota" / "latest.json").is_file()
|
|
assert service.publish_artifact(str(artifact["id"]))["id"] == release["id"]
|
|
service.close()
|
|
|
|
monkeypatch.setenv(
|
|
"FIRMWARE_WORKBENCH_OTA_PUBLIC_BASE_URL",
|
|
"http://192.168.43.131:8000/api/firmware-workbench/releases",
|
|
)
|
|
migrated = FirmwareWorkbenchService(
|
|
ROOT,
|
|
artifact_root=tmp_path / "workbench",
|
|
ota_root=tmp_path / "ota",
|
|
build_runner=build_runner,
|
|
)
|
|
migrated_release = migrated.latest_release()
|
|
assert migrated_release["download_url"].startswith("http://192.168.43.131:8000/")
|
|
migrated_latest = json.loads((tmp_path / "ota" / "latest.json").read_text("utf-8"))
|
|
assert migrated_latest["download_url"] == migrated_release["download_url"]
|
|
migrated.close()
|
|
|
|
|
|
def test_repeated_instruction_always_creates_and_executes_a_new_task(tmp_path: Path) -> None:
|
|
image = make_image(tmp_path)
|
|
|
|
def build_runner(
|
|
_task_id: str, log: object, _cancel: threading.Event
|
|
) -> Path:
|
|
log("info", "IAR build: 0 errors, 0 warnings")
|
|
return image
|
|
|
|
service = FirmwareWorkbenchService(
|
|
ROOT,
|
|
artifact_root=tmp_path / "workbench",
|
|
ota_root=tmp_path / "ota",
|
|
build_runner=build_runner,
|
|
)
|
|
first = service.start_task("增加座椅加热功能。")
|
|
first_task = wait_for_terminal(service, str(first["task"]["id"]))
|
|
second = service.start_task("增加座椅加热功能。")
|
|
|
|
assert first["task"]["id"] != second["task"]["id"]
|
|
assert "duplicate" not in first
|
|
assert "duplicate" not in second
|
|
second_task = wait_for_terminal(service, str(second["task"]["id"]))
|
|
assert first_task["status"] == "success"
|
|
assert second_task["status"] == "success"
|
|
assert first_task["target_capabilities"] == ["时间控制", "座椅加热"]
|
|
assert second_task["target_capabilities"] == ["时间控制", "座椅加热"]
|
|
service.close()
|
|
|
|
|
|
def test_non_baseline_artifact_can_be_deleted_but_baseline_is_protected(tmp_path: Path) -> None:
|
|
image = make_image(tmp_path)
|
|
|
|
def build_runner(
|
|
_task_id: str, log: object, _cancel: threading.Event
|
|
) -> Path:
|
|
log("info", "IAR build: 0 errors, 0 warnings")
|
|
return image
|
|
|
|
service = FirmwareWorkbenchService(
|
|
ROOT,
|
|
artifact_root=tmp_path / "workbench",
|
|
ota_root=tmp_path / "ota",
|
|
build_runner=build_runner,
|
|
)
|
|
started = service.start_task("增加座椅通风功能")
|
|
task = wait_for_terminal(service, str(started["task"]["id"]))
|
|
package_path, _ = service.resolve_artifact_download(str(task["artifact_id"]))
|
|
release_path, _ = service.resolve_release_download(str(task["release_id"]))
|
|
|
|
result = service.delete_artifact(str(task["artifact_id"]))
|
|
assert result["deleted"] is True
|
|
assert not package_path.exists()
|
|
assert not release_path.exists()
|
|
snapshot = service.snapshot()
|
|
assert [artifact["id"] for artifact in snapshot["artifacts"]] == [
|
|
"fw-art-time-only-baseline"
|
|
]
|
|
assert snapshot["releases"] == []
|
|
|
|
try:
|
|
service.delete_artifact("fw-art-time-only-baseline")
|
|
except Exception as exc: # noqa: BLE001 - public error type is asserted below
|
|
assert getattr(exc, "code", None) == "baseline_protected"
|
|
else:
|
|
raise AssertionError("time-only baseline must not be deletable")
|
|
service.close()
|
|
|
|
|
|
def test_position_instruction_merges_with_time_only_baseline(tmp_path: Path) -> None:
|
|
image = make_image(tmp_path)
|
|
|
|
def build_runner(
|
|
_task_id: str, log: object, _cancel: threading.Event
|
|
) -> Path:
|
|
log("info", "IAR build: profile position=1 ventilation=0 heating=0")
|
|
return image
|
|
|
|
service = FirmwareWorkbenchService(
|
|
ROOT,
|
|
artifact_root=tmp_path / "workbench",
|
|
ota_root=tmp_path / "ota",
|
|
build_runner=build_runner,
|
|
)
|
|
started = service.start_task("增加支持座椅位置参数控制的能力。", "time_only")
|
|
task = wait_for_terminal(service, str(started["task"]["id"]))
|
|
|
|
assert task["status"] == "success"
|
|
assert task["baseline_capabilities"] == ["时间控制"]
|
|
assert task["requested_capabilities"] == ["位置控制"]
|
|
assert task["target_capabilities"] == ["时间控制", "位置控制"]
|
|
artifact = service.snapshot()["artifacts"][0]
|
|
assert artifact["capabilities"] == ["时间控制", "位置控制"]
|
|
assert artifact["capability_diff"]["added"] == ["位置控制"]
|
|
assert "vent" not in artifact["firmware_filename"]
|
|
assert "heat" not in artifact["firmware_filename"]
|
|
profile_path = tmp_path / "workbench" / "tasks" / task["id"] / "feature_profile.json"
|
|
assert json.loads(profile_path.read_text("utf-8"))["rocking_horse"] is False
|
|
service.close()
|
|
|
|
|
|
def test_running_pipeline_can_be_canceled_and_retried(tmp_path: Path) -> None:
|
|
image = make_image(tmp_path)
|
|
allow_build = threading.Event()
|
|
|
|
def build_runner(
|
|
_task_id: str, log: object, cancel: threading.Event
|
|
) -> Path:
|
|
log("info", "IAR build started")
|
|
while not allow_build.is_set():
|
|
if cancel.wait(0.01):
|
|
raise InterruptedError
|
|
return image
|
|
|
|
service = FirmwareWorkbenchService(
|
|
ROOT,
|
|
artifact_root=tmp_path / "workbench",
|
|
ota_root=tmp_path / "ota",
|
|
build_runner=build_runner,
|
|
)
|
|
started = service.start_task("增加座椅通风功能")
|
|
task_id = str(started["task"]["id"])
|
|
deadline = time.monotonic() + 3
|
|
while service.task(task_id)["current_stage"] != "build" and time.monotonic() < deadline:
|
|
time.sleep(0.01)
|
|
service.cancel_task(task_id)
|
|
canceled = wait_for_terminal(service, task_id)
|
|
assert canceled["status"] == "canceled"
|
|
assert any(stage["status"] == "canceled" for stage in canceled["stages"])
|
|
|
|
allow_build.set()
|
|
retried = service.retry_task(task_id)
|
|
assert retried["id"] == task_id
|
|
completed = wait_for_terminal(service, task_id)
|
|
assert completed["status"] == "success"
|
|
service.close()
|
|
|
|
|
|
@pytest.mark.parametrize("instruction", [
|
|
"增加动态木马功能",
|
|
"增加木马功能",
|
|
"Add rocking_horse",
|
|
"Add Rocking Horse",
|
|
"Add rocking-horse",
|
|
])
|
|
def test_rocking_horse_package_and_notification(tmp_path: Path, instruction: str) -> None:
|
|
image = make_image(tmp_path)
|
|
profile = {
|
|
"position_control": True, "ventilation": False,
|
|
"heating": False, "rocking_horse": True,
|
|
}
|
|
|
|
def build_runner(task_id: str, log: object, cancel: threading.Event) -> Path:
|
|
workspace = tmp_path / "workbench" / "tasks" / task_id
|
|
assert json.loads((workspace / "feature_profile.json").read_text("utf-8")) == profile
|
|
return image
|
|
|
|
service = FirmwareWorkbenchService(
|
|
ROOT, artifact_root=tmp_path / "workbench", ota_root=tmp_path / "ota",
|
|
build_runner=build_runner,
|
|
)
|
|
try:
|
|
started = service.start_task(instruction)
|
|
task = wait_for_terminal(service, started["task"]["id"])
|
|
assert task["status"] == "success", task.get("failure_reason")
|
|
assert [stage["status"] for stage in task["stages"]] == ["success"] * 8
|
|
assert task["target_capabilities"] == ["时间控制", "位置控制", "动态木马"]
|
|
assert any(item["name"] == "动态木马控制与停止保护" for item in task["test_results"])
|
|
artifact = service.artifact(task["artifact_id"])
|
|
assert artifact["capability_diff"]["added"] == ["位置控制", "动态木马"]
|
|
assert "rocking-horse" in artifact["firmware_filename"]
|
|
package, _ = service.resolve_release_download(task["release_id"])
|
|
with zipfile.ZipFile(package) as archive:
|
|
assert json.loads(archive.read("manifest.json"))["capabilities"] == task["target_capabilities"]
|
|
assert json.loads(archive.read("feature_profile.json")) == profile
|
|
assert json.loads(archive.read("requirements.json"))["feature_profile"] == profile
|
|
frames = []
|
|
|
|
def publish(frame):
|
|
frames.append(frame)
|
|
return {"status": "published"}
|
|
|
|
result = service.notify_artifact(artifact["id"], publish)
|
|
assert result["published_count"] == 3
|
|
assert [frame["sequence"] for frame in frames] == [1, 2, 3]
|
|
assert len({frame["message_id"] for frame in frames}) == 1
|
|
assert all(frame["firmware"]["artifact_name"] == artifact["firmware_filename"] for frame in frames)
|
|
assert all(frame["firmware"]["sha256"] == artifact["sha256"] for frame in frames)
|
|
assert all(frame["auto_upgrade"] is False for frame in frames)
|
|
|
|
def unavailable(frame):
|
|
raise CanGatewayError("broker unavailable")
|
|
|
|
with pytest.raises(FirmwareWorkbenchError, match="0/3") as error:
|
|
service.notify_artifact(artifact["id"], unavailable)
|
|
assert error.value.code == "notification_publish_failed"
|
|
assert service.artifact(artifact["id"])["status"] == "published"
|
|
finally:
|
|
service.close()
|
|
|
|
|
|
def test_rocking_horse_build_failure_does_not_publish(tmp_path: Path) -> None:
|
|
def fail_build(task_id: str, log: object, cancel: threading.Event) -> Path:
|
|
raise RuntimeError("IAR build failed")
|
|
|
|
service = FirmwareWorkbenchService(
|
|
ROOT, artifact_root=tmp_path / "workbench", ota_root=tmp_path / "ota",
|
|
build_runner=fail_build,
|
|
)
|
|
try:
|
|
started = service.start_task("增加动态木马功能")
|
|
task = wait_for_terminal(service, started["task"]["id"])
|
|
assert task["status"] == "failed"
|
|
assert task["failure_reason"] == "IAR build failed"
|
|
assert service.snapshot()["releases"] == []
|
|
assert all(item.get("is_baseline") for item in service.snapshot()["artifacts"])
|
|
finally:
|
|
service.close()
|
|
|
|
|
|
def test_atomic_state_write_retries_locks_without_touching_other_temporary_files(
|
|
tmp_path: Path, monkeypatch,
|
|
) -> None:
|
|
path = tmp_path / "state.json"
|
|
previous = '{"tasks": [{"id": "previous-task"}]}'
|
|
path.write_text(previous, encoding="utf-8")
|
|
legacy_temporary = tmp_path / "state.tmp"
|
|
legacy_temporary.write_text("another writer", encoding="utf-8")
|
|
original_replace = Path.replace
|
|
attempts = []
|
|
|
|
def blocked_replace(source, destination):
|
|
attempts.append(source)
|
|
assert path.read_text(encoding="utf-8") == previous
|
|
assert json.loads(source.read_text(encoding="utf-8")) == {"tasks": ["新任务"]}
|
|
if len(attempts) <= 2:
|
|
raise PermissionError(13, "file temporarily in use", str(destination))
|
|
return original_replace(source, destination)
|
|
|
|
monkeypatch.setattr(Path, "replace", blocked_replace)
|
|
_atomic_write_json(path, {"tasks": ["新任务"]})
|
|
assert len(attempts) == 3
|
|
assert attempts[0] != legacy_temporary
|
|
assert len(set(attempts)) == 1
|
|
assert legacy_temporary.read_text(encoding="utf-8") == "another writer"
|
|
assert json.loads(path.read_text(encoding="utf-8")) == {"tasks": ["新任务"]}
|
|
assert list(tmp_path.glob(".state.json.*.tmp")) == []
|
|
|
|
|
|
@pytest.mark.parametrize("error,expected_attempts", [
|
|
(PermissionError(13, "access denied"), 8),
|
|
(OSError(28, "disk full"), 1),
|
|
])
|
|
def test_atomic_write_failure_preserves_history_and_cleans_its_temporary_file(
|
|
tmp_path: Path, monkeypatch, error, expected_attempts,
|
|
) -> None:
|
|
path = tmp_path / "state.json"
|
|
previous = '{"tasks": [{"id": "preserved-task"}]}'
|
|
path.write_text(previous, encoding="utf-8")
|
|
attempts = []
|
|
|
|
def reject_replace(source, destination):
|
|
attempts.append(source)
|
|
raise error
|
|
|
|
monkeypatch.setattr(Path, "replace", reject_replace)
|
|
with pytest.raises(type(error)):
|
|
_atomic_write_json(path, {"tasks": []})
|
|
assert len(attempts) == expected_attempts
|
|
assert path.read_text(encoding="utf-8") == previous
|
|
assert list(tmp_path.glob(".state.json.*.tmp")) == []
|
|
|
|
|
|
def test_build_and_publish_survive_transient_state_and_manifest_locks(
|
|
tmp_path: Path, monkeypatch,
|
|
) -> None:
|
|
image = make_image(tmp_path)
|
|
building = threading.Event()
|
|
failures = {"state.json": 2, "latest.json": 2}
|
|
original_replace = Path.replace
|
|
|
|
def replace_with_locks(source, destination):
|
|
name = Path(destination).name
|
|
if building.is_set() and failures.get(name, 0):
|
|
failures[name] -= 1
|
|
raise PermissionError(13, "temporary Windows file lock", str(destination))
|
|
return original_replace(source, destination)
|
|
|
|
def build_runner(task_id, log, cancel):
|
|
building.set()
|
|
log("info", "IAR build: 0 errors, 0 warnings")
|
|
return image
|
|
|
|
service = FirmwareWorkbenchService(
|
|
ROOT, artifact_root=tmp_path / "workbench", ota_root=tmp_path / "ota",
|
|
build_runner=build_runner,
|
|
)
|
|
monkeypatch.setattr(Path, "replace", replace_with_locks)
|
|
try:
|
|
task = wait_for_terminal(service, service.start_task("增加动态木马功能")["task"]["id"])
|
|
assert task["status"] == "success", task.get("failure_reason")
|
|
assert [stage["status"] for stage in task["stages"]] == ["success"] * 8
|
|
assert failures == {"state.json": 0, "latest.json": 0}
|
|
stored = json.loads((tmp_path / "workbench/state.json").read_text(encoding="utf-8"))
|
|
assert stored["tasks"][0]["status"] == "success"
|
|
latest = json.loads((tmp_path / "ota/latest.json").read_text(encoding="utf-8"))
|
|
assert latest["release_id"] == task["release_id"]
|
|
finally:
|
|
service.close()
|
|
|
|
|
|
def test_recovery_save_failure_does_not_reinitialize_existing_history(tmp_path: Path, monkeypatch):
|
|
path = tmp_path / "workbench/state.json"
|
|
path.parent.mkdir()
|
|
original = {"tasks": [{"id": "fw-task-recovery", "status": "running", "stages": []}],
|
|
"artifacts": [], "releases": []}
|
|
path.write_text(json.dumps(original), encoding="utf-8")
|
|
save = FirmwareWorkbenchService._save_locked
|
|
attempts = []
|
|
|
|
def fail_first_save(service):
|
|
attempts.append(True)
|
|
if len(attempts) == 1:
|
|
raise PermissionError(13, "state is locked")
|
|
save(service)
|
|
|
|
monkeypatch.setattr(FirmwareWorkbenchService, "_save_locked", fail_first_save)
|
|
with pytest.raises(PermissionError):
|
|
FirmwareWorkbenchService(ROOT, artifact_root=path.parent, ota_root=tmp_path / "ota")
|
|
assert len(attempts) == 1
|
|
assert json.loads(path.read_text(encoding="utf-8")) == original
|
|
|
|
|
|
def test_unreadable_state_is_not_overwritten_by_baseline_registration(tmp_path: Path, monkeypatch):
|
|
path = tmp_path / "workbench/state.json"
|
|
path.parent.mkdir()
|
|
previous = '{"tasks": [{"id": "preserved-task"}], "artifacts": [], "releases": []}'
|
|
path.write_text(previous, encoding="utf-8")
|
|
read = Path.read_text
|
|
|
|
def deny_state_read(source, *args, **kwargs):
|
|
if source == path:
|
|
raise PermissionError(13, "state read denied")
|
|
return read(source, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(Path, "read_text", deny_state_read)
|
|
with pytest.raises(PermissionError):
|
|
FirmwareWorkbenchService(ROOT, artifact_root=path.parent, ota_root=tmp_path / "ota")
|
|
assert path.read_bytes() == previous.encode("utf-8")
|
|
|
|
|
|
@pytest.mark.skipif(os.name != "nt", reason="Requires a real Windows file-sharing lock")
|
|
def test_atomic_write_recovers_from_a_real_windows_reader_lock(tmp_path: Path, monkeypatch):
|
|
import ctypes
|
|
from ctypes import wintypes
|
|
|
|
kernel = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
kernel.CreateFileW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD,
|
|
wintypes.LPVOID, wintypes.DWORD, wintypes.DWORD, wintypes.HANDLE]
|
|
kernel.CreateFileW.restype = wintypes.HANDLE
|
|
kernel.CloseHandle.argtypes = [wintypes.HANDLE]
|
|
kernel.CloseHandle.restype = wintypes.BOOL
|
|
path = tmp_path / "state.json"
|
|
path.write_text('{"before": true}', encoding="utf-8")
|
|
# Permit read/write, but intentionally omit FILE_SHARE_DELETE to reproduce WinError 5/32.
|
|
handle = kernel.CreateFileW(str(path), 0x80000000, 0x00000003, None, 3, 0x80, None)
|
|
assert handle != ctypes.c_void_p(-1).value, ctypes.get_last_error()
|
|
blocked = threading.Event()
|
|
errors = []
|
|
observed_winerrors = []
|
|
replace = Path.replace
|
|
|
|
def observe_replace(source, destination):
|
|
try:
|
|
return replace(source, destination)
|
|
except PermissionError as exc:
|
|
observed_winerrors.append(exc.winerror)
|
|
blocked.set()
|
|
raise
|
|
|
|
def write():
|
|
try:
|
|
_atomic_write_json(path, {"after": True})
|
|
except Exception as exc:
|
|
errors.append(exc)
|
|
|
|
monkeypatch.setattr(Path, "replace", observe_replace)
|
|
worker = threading.Thread(target=write)
|
|
worker.start()
|
|
try:
|
|
assert blocked.wait(timeout=2), "Windows did not reproduce the sharing conflict"
|
|
assert json.loads(path.read_text(encoding="utf-8")) == {"before": True}
|
|
finally:
|
|
kernel.CloseHandle(handle)
|
|
worker.join(timeout=4)
|
|
assert not worker.is_alive()
|
|
assert not errors
|
|
assert observed_winerrors and set(observed_winerrors) <= {5, 32, 33}
|
|
assert json.loads(path.read_text(encoding="utf-8")) == {"after": True}
|