Files
matrix-screen-controller/核桃派软件源代码/tests/test_release_image.py
T

410 lines
18 KiB
Python

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