初始化奇妙小屏幕控制器项目
This commit is contained in:
@@ -0,0 +1,409 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts.image_config import (
|
||||
CONFIG_FILE_BYTES,
|
||||
ImageConfigError,
|
||||
create_config_file,
|
||||
read_config_file,
|
||||
update_config_file,
|
||||
)
|
||||
from scripts.export_release import next_patch
|
||||
from scripts import export_release
|
||||
from scripts.promote_image_release import PASS_FIELDS, _validate_report
|
||||
from scripts.build_sd_image import build_bundle, image_metadata
|
||||
from scripts.boot_space import DEFAULT_SAFETY_BYTES, calculate_budget
|
||||
from scripts.kernel_artifact import KernelArtifactInfo
|
||||
from app.ota.versioning import SoftwareVersion
|
||||
|
||||
|
||||
SOURCE_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def config() -> dict:
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"product": "matrix-screen-controller-walnutpi",
|
||||
"software_version": "1.0.2",
|
||||
"account": {"username": "example", "password": "example-password"},
|
||||
"wifi": {"ssid": "example-network", "password": "example-wifi-password"},
|
||||
"ipv4": {"mode": "dhcp", "address": "", "prefix": 0, "gateway": "", "dns": []},
|
||||
}
|
||||
|
||||
|
||||
def test_release_staging_changes_version_without_changing_feature_timestamp(tmp_path):
|
||||
source = tmp_path / "source"
|
||||
source.mkdir()
|
||||
(source / "VERSION").write_text("1.0.3\n", encoding="utf-8")
|
||||
feature_timestamp = b"2026-09-04T12:40+08:00\n"
|
||||
(source / "FEATURE_UPDATED_AT").write_bytes(feature_timestamp)
|
||||
destination = tmp_path / "staged"
|
||||
|
||||
export_release._copy_source(source, destination, SoftwareVersion.parse("1.0.4"))
|
||||
|
||||
assert (destination / "VERSION").read_text(encoding="utf-8") == "1.0.4\n"
|
||||
assert (destination / "FEATURE_UPDATED_AT").read_bytes() == feature_timestamp
|
||||
assert (source / "VERSION").read_text(encoding="utf-8") == "1.0.3\n"
|
||||
assert (source / "FEATURE_UPDATED_AT").read_bytes() == feature_timestamp
|
||||
|
||||
|
||||
def test_image_readme_exposes_only_the_intended_defaults():
|
||||
value = config()
|
||||
manifest = {
|
||||
"created_at": "2026-09-08T12:00:00+08:00",
|
||||
"artifact": "matrix-screen-controller-1.1.1.img",
|
||||
"image_sha256": "a" * 64,
|
||||
}
|
||||
body = export_release._image_readme(SoftwareVersion.parse("1.1.1"), manifest, "image", value)
|
||||
assert value["account"]["username"] in body
|
||||
assert value["account"]["password"] in body
|
||||
assert value["wifi"]["ssid"] in body
|
||||
assert value["wifi"]["password"] in body
|
||||
assert "公开的默认凭据" in body and "DHCP" in body
|
||||
assert "前置系列基线" not in body and "系统设置上传 OTA" not in body
|
||||
|
||||
|
||||
def test_source_readme_does_not_embed_a_development_device_address():
|
||||
body = (SOURCE_ROOT / "README.md").read_text(encoding="utf-8")
|
||||
assert "192.168.198.94" not in body
|
||||
assert "<设备通过 DHCP 获得的 IPv4>" in body
|
||||
|
||||
|
||||
def test_image_validation_report_is_strict_and_digest_bound(tmp_path):
|
||||
report = {
|
||||
"schema_version": 1,
|
||||
"artifact_type": "image",
|
||||
"image_sha256": "b" * 64,
|
||||
"software_version": "1.1.1",
|
||||
"status": "success",
|
||||
"validated_at": "2026-09-08T12:00:00+08:00",
|
||||
**{name: True for name in PASS_FIELDS},
|
||||
}
|
||||
path = tmp_path / "report.json"
|
||||
path.write_text(json.dumps(report), encoding="utf-8")
|
||||
assert _validate_report(path, "b" * 64, SoftwareVersion.parse("1.1.1")) == report
|
||||
report["unexpected_secret"] = "must be rejected"
|
||||
path.write_text(json.dumps(report), encoding="utf-8")
|
||||
with pytest.raises(ValueError, match="fields"):
|
||||
_validate_report(path, "b" * 64, SoftwareVersion.parse("1.1.1"))
|
||||
|
||||
|
||||
def test_image_candidate_does_not_publish_or_consume_version(tmp_path, monkeypatch):
|
||||
source = tmp_path / "核桃派软件源代码"
|
||||
source.mkdir()
|
||||
(source / "VERSION").write_text("1.1.0\n", encoding="utf-8")
|
||||
(source / "FEATURE_UPDATED_AT").write_text("2026-09-08T10:19+08:00\n", encoding="utf-8")
|
||||
(source / "UPGRADE_POLICY.json").write_text('{"schema_version":1,"checkpoints":[]}', encoding="utf-8")
|
||||
history = tmp_path / "发布记录.json"
|
||||
history.write_text('{"schema_version":1,"releases":[]}', encoding="utf-8")
|
||||
config_path = tmp_path / "image-config.json"
|
||||
config_path.write_text(json.dumps(config()), encoding="utf-8")
|
||||
candidate = tmp_path / "candidate"
|
||||
|
||||
def fake_build(*args):
|
||||
output, bundle = args[-3], args[-2]
|
||||
output.write_bytes(b"candidate-image")
|
||||
bundle.write_bytes(b"bundle")
|
||||
return {
|
||||
"format_version": 3,
|
||||
"software_version": "1.1.1",
|
||||
"created_at": "2026-09-08T12:00:00+08:00",
|
||||
"image_bytes": output.stat().st_size,
|
||||
"image_sha256": hashlib.sha256(output.read_bytes()).hexdigest(),
|
||||
}
|
||||
|
||||
monkeypatch.setattr(export_release, "SOURCE_ROOT", source)
|
||||
monkeypatch.setattr(export_release, "build_image", fake_build)
|
||||
monkeypatch.setattr(export_release, "_require_image_host", lambda: None)
|
||||
monkeypatch.setattr(export_release.subprocess, "run", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(
|
||||
sys,
|
||||
"argv",
|
||||
["export", "image", "--version", "1.1.1", "--config", str(config_path),
|
||||
"--candidate-output", str(candidate), "--notes", "candidate"],
|
||||
)
|
||||
assert export_release.main() == 0
|
||||
assert (source / "VERSION").read_text(encoding="utf-8") == "1.1.0\n"
|
||||
assert history.read_text(encoding="utf-8") == '{"schema_version":1,"releases":[]}'
|
||||
assert not (tmp_path / "发布更新相关" / "导出包" / "1.1.1").exists()
|
||||
assert {path.name for path in candidate.iterdir()} == {
|
||||
"README.md", "manifest.json", "matrix-screen-controller-1.1.1.img",
|
||||
"matrix-screen-controller-1.1.1.img.sha256",
|
||||
}
|
||||
|
||||
|
||||
def test_dual_slot_config_round_trip_and_fallback():
|
||||
initial = create_config_file(config())
|
||||
assert len(initial) == CONFIG_FILE_BYTES
|
||||
document, generation, slot = read_config_file(initial)
|
||||
assert document["account"]["username"] == "example"
|
||||
assert (generation, slot) == (1, 0)
|
||||
changed = config()
|
||||
changed["wifi"]["ssid"] = "second-network"
|
||||
updated = update_config_file(initial, changed)
|
||||
document, generation, slot = read_config_file(updated)
|
||||
assert document["wifi"]["ssid"] == "second-network"
|
||||
assert (generation, slot) == (2, 1)
|
||||
damaged = bytearray(updated)
|
||||
damaged[34_900] ^= 0xFF
|
||||
document, generation, slot = read_config_file(bytes(damaged))
|
||||
assert document["wifi"]["ssid"] == "example-network"
|
||||
assert (generation, slot) == (1, 0)
|
||||
|
||||
|
||||
def test_config_validation_rejects_bad_network_and_secrets():
|
||||
value = config()
|
||||
value["wifi"]["password"] = "short"
|
||||
with pytest.raises(ImageConfigError):
|
||||
create_config_file(value)
|
||||
value = config()
|
||||
value["ipv4"] = {
|
||||
"mode": "static",
|
||||
"address": "192.168.1.20",
|
||||
"prefix": 24,
|
||||
"gateway": "192.168.2.1",
|
||||
"dns": ["192.168.1.1"],
|
||||
}
|
||||
with pytest.raises(ImageConfigError):
|
||||
create_config_file(value)
|
||||
|
||||
|
||||
def test_release_sequence_is_shared():
|
||||
assert str(next_patch(SoftwareVersion.parse("1.0.1"))) == "1.0.2"
|
||||
assert str(next_patch(SoftwareVersion.parse("1.0.2"))) == "1.0.3"
|
||||
|
||||
|
||||
def test_image_metadata_v3_has_exact_payload_and_boot_identity():
|
||||
kernel = KernelArtifactInfo(
|
||||
artifact="axp313a-kernel-6.1.31-matrix-axp313a1.tar.gz",
|
||||
artifact_bytes=10,
|
||||
artifact_sha256="a" * 64,
|
||||
kernel_release="6.1.31-matrix-axp313a1",
|
||||
source_commit="30ff3fd5cf45417622b447a12e7a947402ffe34d",
|
||||
unpacked_file_bytes=20,
|
||||
regular_file_count=9,
|
||||
module_file_count=1,
|
||||
)
|
||||
assert image_metadata("1.0.3", 30, "b" * 64, kernel, 40) == {
|
||||
"format_version": 3,
|
||||
"product": "matrix-screen-controller-walnutpi",
|
||||
"software_version": "1.0.3",
|
||||
"bundle_path": "/opt/matrix-image-bootstrap/app/MSCBOOT.TGZ",
|
||||
"bundle_bytes": 30,
|
||||
"bundle_sha256": "b" * 64,
|
||||
"kernel_release": "6.1.31-matrix-axp313a1",
|
||||
"kernel_artifact": "axp313a-kernel-6.1.31-matrix-axp313a1.tar.gz",
|
||||
"kernel_artifact_bytes": 10,
|
||||
"kernel_artifact_sha256": "a" * 64,
|
||||
"kernel_unpacked_file_bytes": 20,
|
||||
"boot_required_bytes": 40,
|
||||
"boot_safety_bytes": DEFAULT_SAFETY_BYTES,
|
||||
}
|
||||
|
||||
|
||||
def test_fat_bundle_excludes_kernel_binary_and_source_dependencies(tmp_path: Path):
|
||||
source = tmp_path / "source"
|
||||
wheelhouse = tmp_path / "wheelhouse"
|
||||
debian = tmp_path / "debian"
|
||||
kernel = tmp_path / "offline/aarch64-kernel"
|
||||
kernel_source = tmp_path / "offline/kernel-source"
|
||||
for directory in (source, wheelhouse, debian, kernel, kernel_source):
|
||||
directory.mkdir(parents=True)
|
||||
(source / "app.py").write_text("app\n", encoding="utf-8")
|
||||
(source / "FEATURE_UPDATED_AT").write_text("2026-09-04T12:40+08:00\n", encoding="utf-8")
|
||||
(wheelhouse / "wheel.whl").write_bytes(b"wheel")
|
||||
(debian / "package.deb").write_bytes(b"deb")
|
||||
(kernel / "kernel.tar.gz").write_bytes(b"kernel")
|
||||
(kernel_source / "source.tar.gz").write_bytes(b"source")
|
||||
output = tmp_path / "bundle.tgz"
|
||||
build_bundle(source, wheelhouse, debian, output)
|
||||
with tarfile.open(output, "r:gz") as archive:
|
||||
names = {member.name for member in archive.getmembers()}
|
||||
assert "project/核桃派软件源代码/app.py" in names
|
||||
assert "project/核桃派软件源代码/FEATURE_UPDATED_AT" in names
|
||||
assert "project/离线依赖/其他依赖/aarch64-py311/wheel.whl" in names
|
||||
assert "project/离线依赖/其他依赖/debian12-aarch64/package.deb" in names
|
||||
assert not any("aarch64-kernel" in name or "kernel-source" in name for name in names)
|
||||
|
||||
|
||||
def test_firstboot_defers_controller_start_to_avoid_systemd_ordering_deadlock():
|
||||
firstboot = (SOURCE_ROOT / "scripts" / "image_firstboot.sh").read_text(encoding="utf-8")
|
||||
deploy = (SOURCE_ROOT / "scripts" / "deploy_walnutpi.sh").read_text(encoding="utf-8")
|
||||
assert "DEFER_SERVICE_START=1" in firstboot
|
||||
assert 'if [ "$DEFER_SERVICE_START" = "1" ]; then' in deploy
|
||||
assert deploy.count('dedicated_host.py" check --service') == 2
|
||||
assert "systemctl enable matrix-screen-controller.service" in deploy
|
||||
assert "service start is deferred until reboot" in deploy
|
||||
|
||||
|
||||
def test_firstboot_does_not_block_offline_installation_on_wifi_connectivity():
|
||||
firstboot = (SOURCE_ROOT / "scripts" / "image_firstboot.sh").read_text(encoding="utf-8")
|
||||
|
||||
network_config = "scripts.provision_device network --config"
|
||||
deferred_deploy = "DEFER_SERVICE_START=1"
|
||||
offline_success = (
|
||||
"SUCCESS: provisioning completed while Wi-Fi is not connected; "
|
||||
"the installed controller will keep trying after reboot."
|
||||
)
|
||||
|
||||
assert firstboot.index(network_config) < firstboot.index(deferred_deploy)
|
||||
assert "systemctl restart NetworkManager.service" in firstboot
|
||||
assert "nmcli --wait" not in firstboot
|
||||
assert "connection up matrix-screen" not in firstboot
|
||||
assert "for _attempt in" not in firstboot
|
||||
assert "ip route show default | grep -q ." in firstboot
|
||||
assert offline_success in firstboot
|
||||
assert firstboot.index("rm -f -- \"$CONFIG\"") < firstboot.index(offline_success)
|
||||
|
||||
|
||||
def test_image_payload_v3_stages_both_payloads_outside_fat():
|
||||
builder = (SOURCE_ROOT / "scripts/build_sd_image.py").read_text(encoding="utf-8")
|
||||
exporter = (SOURCE_ROOT / "scripts/export_release.py").read_text(encoding="utf-8")
|
||||
installer = (SOURCE_ROOT / "scripts/install_image_bootstrap.sh").read_text(encoding="utf-8")
|
||||
verifier = (SOURCE_ROOT / "scripts/verify_image_bootstrap.sh").read_text(encoding="utf-8")
|
||||
firstboot = (SOURCE_ROOT / "scripts/image_firstboot.sh").read_text(encoding="utf-8")
|
||||
|
||||
assert 'IMAGE_FORMAT_VERSION = 3' in builder
|
||||
assert 'BUNDLE_IMAGE_PATH = "/opt/matrix-image-bootstrap/app/MSCBOOT.TGZ"' in builder
|
||||
assert '"kernel_artifact_sha256"' in builder
|
||||
assert '"kernel_unpacked_file_bytes"' in builder
|
||||
assert "build_bundle(source, wheelhouse, debian, bootstrap_bundle, system_dependencies)" in builder
|
||||
assert "aarch64-kernel" in exporter and "kernel-source" in exporter
|
||||
assert "image_bootstrap_payload.py\" stage" in installer
|
||||
assert "image_bootstrap_payload.py\" verify" in verifier
|
||||
assert '"format_version": 3' in firstboot
|
||||
assert "[ ! -e \"$BOOT_MOUNT/MSCBOOT.TGZ\" ]" in verifier
|
||||
assert "scripts/kernel_artifact.py --kernel" in firstboot
|
||||
assert "install_axp313a_kernel.sh --artifact" in firstboot
|
||||
assert "kernel_unpacked_bytes * 2 + 268435456" in firstboot
|
||||
assert "rm -rf -- /opt/matrix-image-bootstrap" in firstboot
|
||||
|
||||
|
||||
def test_in_place_image_payload_refresh_is_retired():
|
||||
refresher = (SOURCE_ROOT / "scripts/refresh_sd_image_payload.py").read_text(encoding="utf-8")
|
||||
assert "in-place image payload refresh is retired" in refresher
|
||||
assert "export_release.py image --repair-current" in refresher
|
||||
assert 'write_file("MSCBOOT.TGZ"' not in refresher
|
||||
|
||||
|
||||
def test_export_help_works_without_site_packages():
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-S", str(SOURCE_ROOT / "scripts/export_release.py"), "--help"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "--repair-current" in result.stdout
|
||||
assert "PIL" not in result.stderr and "fontTools" not in result.stderr
|
||||
|
||||
|
||||
def test_boot_budget_counts_candidate_rollback_temporary_and_safety_files():
|
||||
candidate = {
|
||||
"boot/Image-matrix-axp313a1": 9_000,
|
||||
"boot/sun50i-h616-walnutpi-1b-matrix-axp313a1.dtb": 2_000,
|
||||
"boot/sun50i-h616-walnutpi-1b-emmc-matrix-axp313a1.dtb": 2_000,
|
||||
"boot/System.map-6.1.31-matrix-axp313a1": 3_000,
|
||||
"boot/config-6.1.31-matrix-axp313a1": 1_000,
|
||||
}
|
||||
boot_cmd = b'''# DO NOT EDIT THIS FILE\nsetenv docker_optimizations "on"\nsetenv bootargs "root=/dev/mmcblk1p2"\nif test "${docker_optimizations}" = "on"; then setenv bootargs "${bootargs} cgroup_enable=memory swapaccount=1"; fi\nload ${devtype} ${devnum} ${fdt_addr_r} ${prefix}${fdtfile_emmc}.dtb\nload ${devtype} ${devnum} ${fdt_addr_r} ${prefix}${fdtfile}.dtb\nload ${devtype} ${devnum} ${fdt_addr_r} ${prefix}${fdtfile}.dtb\nload ${devtype} ${devnum} ${kernel_addr_r} ${prefix}Image\n'''
|
||||
budget = calculate_budget(
|
||||
candidate_sizes=candidate,
|
||||
boot_cmd=boot_cmd,
|
||||
boot_scr=b"scr",
|
||||
cluster_bytes=4096,
|
||||
available_bytes=10**9,
|
||||
)
|
||||
assert budget.required_bytes > sum(candidate.values())
|
||||
assert budget.safety_bytes == 32 * 1024 * 1024
|
||||
assert budget.total_bytes == budget.required_bytes + budget.safety_bytes
|
||||
|
||||
|
||||
def test_repair_commit_replaces_directory_and_record_without_duplicate(tmp_path: Path):
|
||||
final = tmp_path / "1.0.3"
|
||||
staged = tmp_path / ".1.0.3.building"
|
||||
final.mkdir()
|
||||
staged.mkdir()
|
||||
(final / "old").write_text("old", encoding="utf-8")
|
||||
(staged / "new").write_text("new", encoding="utf-8")
|
||||
history_path = tmp_path / "发布记录.json"
|
||||
history = {"schema_version": 1, "releases": [{"version": "1.0.3", "artifact_sha256": "new"}]}
|
||||
history_path.write_text('{"schema_version":1,"releases":[]}', encoding="utf-8")
|
||||
leftover = export_release._commit_repair(final, staged, history_path, history)
|
||||
assert leftover is None
|
||||
assert (final / "new").read_text(encoding="utf-8") == "new"
|
||||
assert not (final / "old").exists()
|
||||
assert json.loads(history_path.read_text(encoding="utf-8")) == history
|
||||
assert not list(tmp_path.glob("*.invalid-backup"))
|
||||
|
||||
|
||||
def test_repair_commit_restores_old_directory_when_record_write_fails(tmp_path: Path, monkeypatch):
|
||||
final = tmp_path / "1.0.3"
|
||||
staged = tmp_path / ".1.0.3.building"
|
||||
final.mkdir()
|
||||
staged.mkdir()
|
||||
(final / "old").write_text("old", encoding="utf-8")
|
||||
(staged / "new").write_text("new", encoding="utf-8")
|
||||
history_path = tmp_path / "发布记录.json"
|
||||
original_history = b'{"schema_version":1,"releases":[]}\n'
|
||||
history_path.write_bytes(original_history)
|
||||
|
||||
def fail_record(_path, _document):
|
||||
raise OSError("simulated record failure")
|
||||
|
||||
monkeypatch.setattr(export_release, "_atomic_json", fail_record)
|
||||
with pytest.raises(OSError, match="simulated record failure"):
|
||||
export_release._commit_repair(
|
||||
final,
|
||||
staged,
|
||||
history_path,
|
||||
{"schema_version": 1, "releases": [{"version": "1.0.3"}]},
|
||||
)
|
||||
assert (final / "old").read_text(encoding="utf-8") == "old"
|
||||
assert not (final / "new").exists()
|
||||
assert history_path.read_bytes() == original_history
|
||||
|
||||
|
||||
def test_firstboot_requires_managed_password_ssh_after_identity_reset():
|
||||
firstboot = (SOURCE_ROOT / "scripts" / "image_firstboot.sh").read_text(encoding="utf-8")
|
||||
policy = (SOURCE_ROOT / "systemd" / "10-walnutpi-screen-hardening.conf").read_text(encoding="utf-8")
|
||||
assert policy.splitlines() == [
|
||||
"PermitRootLogin no",
|
||||
"PasswordAuthentication yes",
|
||||
"KbdInteractiveAuthentication no",
|
||||
"PermitEmptyPasswords no",
|
||||
]
|
||||
identity = "python3 -m scripts.provision_device identity --config"
|
||||
ssh = "python3 -m scripts.provision_device ssh --config"
|
||||
assert firstboot.index(identity) < firstboot.index(ssh) < firstboot.index("systemctl reboot")
|
||||
assert "systemctl enable --now ssh.service" not in firstboot
|
||||
|
||||
|
||||
def test_firstboot_cannot_time_out_or_reload_away_its_running_unit():
|
||||
firstboot = (SOURCE_ROOT / "scripts" / "image_firstboot.sh").read_text(encoding="utf-8")
|
||||
unit = (SOURCE_ROOT / "systemd" / "matrix-image-firstboot.service").read_text(encoding="utf-8")
|
||||
success = 'status "SUCCESS: provisioning completed; the device is rebooting into the installed controller."'
|
||||
|
||||
assert "TimeoutStartSec=infinity" in unit
|
||||
assert "systemctl daemon-reload" not in firstboot
|
||||
assert firstboot.index('rm -f -- /etc/systemd/system/matrix-image-firstboot.service') < firstboot.index(success)
|
||||
assert firstboot.index('rm -rf -- "$WORK"') < firstboot.index(success)
|
||||
assert firstboot.index("sync\n") < firstboot.index(success) < firstboot.index("systemctl reboot")
|
||||
|
||||
|
||||
def test_image_export_installs_and_verifies_managed_ssh_policy():
|
||||
exporter = (SOURCE_ROOT / "scripts" / "export_release.py").read_text(encoding="utf-8")
|
||||
installer = (SOURCE_ROOT / "scripts" / "install_image_bootstrap.sh").read_text(encoding="utf-8")
|
||||
verifier = (SOURCE_ROOT / "scripts" / "verify_image_bootstrap.sh").read_text(encoding="utf-8")
|
||||
name = "10-walnutpi-screen-hardening.conf"
|
||||
assert exporter.count(name) == 2
|
||||
assert name in installer and 'enable ssh.service' in installer
|
||||
assert name in verifier and 'multi-user.target.wants/ssh.service' in verifier
|
||||
Reference in New Issue
Block a user