diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 943e1e3..b6e41f8 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -4,4 +4,6 @@ 沿用 v0.1.21 的 Alpine 安全基础镜像和完整构建流程;本版本不沿用此前漏洞库快照的零检出结论。 -验证:589 项测试、Ruff、格式和 mypy 通过;真实设备结果以部署后的只读回执为准。 +验证:594 项测试、Ruff、格式和 mypy 通过;真实设备结果以部署后的只读回执为准。 + +生产主机无法访问镜像仓库时,支持导入完整构建镜像,并在切换前核对固定镜像 ID、版本、提交及运行源码哈希;不复用旧运行镜像覆盖源码。 diff --git a/SHA256SUMS b/SHA256SUMS index 9043a65..8d44807 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -10,7 +10,7 @@ e210623dda97c2bcbd57f37d264c912ac11a20ab3bbd6fbf616439edd3a3491a Dockerfile 250a2bcec248b074786d90e0329874628d70d5a0fe3b4fba47c8c57d4f84dcce NOTICE.md 26ad0d9063c0ee843f0f532604b8fd66e8b39fd72d129595ed078677c0d6d13c README.md ee00cb267b38485f1e574d610f0a57b51d4ae575731a18e57d06a7c43dc0a8b0 README.zh-CN.md -0e6aad6e9c52cfccecef96a9aa55ba8ee64d22e1ac6eace5ea63bba9afa1e90f RELEASE_NOTES.md +360222ab2f70226dd5edbca358e540b02cfc0a3d5398099ddda6dd4b975c9706 RELEASE_NOTES.md 6a0c85c8d52a2d4a3b779dce306a93c4efc4dfe3ab3b23e4a6a3bb7c55079910 SECURITY.md e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 build-cache/wheels/.gitkeep e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 config/.gitkeep @@ -21,7 +21,7 @@ fd53125a5359dcae1d81e95b8447aac755270f9aeb354d3e1140d7fccb57c42e deploy/docker- 4464545a9d91bbc9fff9cd9941db63f44fa61120caca426687d1ed06635cc3e6 deploy/organized-photos-source.json f78d6c3d40e6c5246601bb99c2b46eef91e883a8f83841d85c925394708af0c0 docker-compose.yml 30d3e705870ebb012ce122bd7d8f95192d86664c4d2bb45a3169c86988fbaf72 docs/AUTOMATIC_COLLAGE.md -5f727c9588504a18009ea892f3204fce9520b03754a56dbc9f59e3505ce8a1b2 docs/BUILDING.md +e897b531b5ec4c2411fb11c4ea38808a6305e066782fe6112953f1cf63cef78f docs/BUILDING.md d9f07164c75c757e85f4ace2b55deaf82b8b904216c5222be6c0e3ab7b28cb36 docs/EXTENSIONS.md 1d8c5f79eff3115b1cd13d8f63fff1332b80d1fe1bec6b0f2713b5f80b0cbf67 docs/KOKONNA_RECOVERY.md 0fe1896a53cef4cfd2e380a0b979eceda493733082a9b56c3d7e89e7f62a65b3 docs/NAS.md @@ -138,7 +138,7 @@ cdabf30144ea089ae8e64228f6762951f53d4fe8c191673fb5676331d249473d scripts/migrat b0b877cfbb2bdf830e2e41097c2057538bb9d0b61a3c39952fe505d910cd5c29 scripts/persistence-check.py b7247c5590fe17ae461311f2fbdbeda0daa8a34c0804cef22a314c7ff8f4c389 scripts/production-deploy.py 11627ecbd8a3be01aa079bdf6166b4368e670f5ad4a165b5971754b8187bbc2c scripts/run_local.py -792dafe1ffb2be6a50aba549e642aa95357469ac1ec94a04bdc93a0f419d5f63 scripts/server-release.py +68fc95bb91e5529135a669a33450120465109222a4ce9bd7c3e2842c814626f5 scripts/server-release.py 1fc16c43485a60a05012322cb1e4a219309dbd7e11f002f510602beea743ce30 scripts/smoke-test.py 1236d3b2d27559cbe73a27dcf56248ef3c60404c6fb21453caa7225570d4ac45 scripts/ui-audit-regressions.cjs 57a79a534a71442f5e3a70ed9ff96f426007d9f6dd0bb9b1f5d79eac773d2254 scripts/ui-auto-collage.cjs @@ -210,7 +210,7 @@ f0601f5ed89e8a14a4584a9683611aa7510a964f50a642348b8ab6c62df72278 tests/test_pub 484e005d5211509aa9209ad487b4623292daa878ddec773fdbc3ed8b5b744bef tests/test_recovery.py 072e8ca2fa68ca73fb078b107ced96ea683652410b286017db883c97608d78f6 tests/test_retention.py ab2cd30163453fe571b55c30cb26dc047a104a536ec01d2c64aa2bc91996ba11 tests/test_scheduler.py -5f25f608f13f96b245d6963c02a98add679c6e7d70d398ebb5c3c49b38fbeda8 tests/test_server_release.py +0cccc08adf35a06dbf704eb5c35966bc1c3de0ce39d3014efd7ca751253ce79f tests/test_server_release.py a7ae1b4a64bfcbcf2b7ff795b30ba91db24ee2ad7544073dca2bf43d021cd266 tests/test_smoke_dependencies.py 2624fb6ca01f32d93499d7d771144dcaa2850e5f77b2ac3b7b566891b5e9d283 tests/test_synology_photos.py ca98469f6a6b8b49c36a00657ea1a81775c3496e1fbdd5e38eb4d5298b5936b4 tests/test_synology_photos_api.py diff --git a/docs/BUILDING.md b/docs/BUILDING.md index 9d3f3b5..a4e6f5c 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -81,3 +81,7 @@ If dependency installation fails, the always() cleanup step still executes. It skips the Python/PyYAML cleanup script when no CI Compose manifest exists, as no Docker verification resources have been created at that point. + +### Verified prebuilt production image + +When the production host cannot reach the registry, build the complete Dockerfile in a trusted build environment and transfer it with `docker image save` / `docker image load`. Supply the immutable `sha256:` image ID privately as `FRAME_FLOW_PREBUILT_IMAGE_ID` to `scripts/server-release.py`. The expected version/commit tag must already exist. The script checks the ID, version and revision labels, then verifies runtime source and license hashes in an isolated container without production mounts before switching the service. Existing storage checks, backups, post-deployment verification and rollback remain in effect. diff --git a/scripts/server-release.py b/scripts/server-release.py index 21061cf..9cedb32 100644 --- a/scripts/server-release.py +++ b/scripts/server-release.py @@ -127,6 +127,48 @@ def main(): release(lock) +def verify_prebuilt_image(image, commit, version, hashes): + expected = os.getenv("FRAME_FLOW_PREBUILT_IMAGE_ID", "") + if not expected: + return False + assert re.fullmatch(r"sha256:[0-9a-f]{64}", expected), "Require immutable image ID" + metadata = json.loads(run("docker", "image", "inspect", image))[0] + assert metadata["Id"] == expected, "Prebuilt image ID mismatch" + labels = metadata["Config"].get("Labels") or {} + assert labels.get("org.opencontainers.image.revision") == commit + assert labels.get("org.opencontainers.image.version") == version + project = { + n: h + for n, h in hashes.items() + if n.startswith(("pigway_frame_flow/", "vendor/")) + or n in {"requirements.lock", "LICENSE", "NOTICE.md"} + } + # Validate the artifact before it can access production config or data. + code = "import hashlib,json,pathlib,sys; h=json.load(sys.stdin); assert h and all(hashlib.sha256((pathlib.Path('/app')/n).read_bytes()).hexdigest()==v for n,v in h.items())" + subprocess.run( + [ + "docker", + "run", + "--rm", + "-i", + "--network=none", + "--read-only", + "--cap-drop=ALL", + "--security-opt=no-new-privileges", + "--user=10333:10333", + "--entrypoint=python", + expected, + "-c", + code, + ], + input=json.dumps(project), + text=True, + check=True, + timeout=60, + ) + return True + + def release(lock): ctx = Path(__file__).resolve().parent.parent assert ctx.parent == ROOT / "migration-inbox" @@ -154,23 +196,24 @@ def release(lock): version = tomllib.loads((ctx / "pyproject.toml").read_text())["project"]["version"] assert re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", version) image = "pigway-frame-flow:" + version + "-" + commit[:12] - # A source-only overlay would retain vulnerable packages from the old image. - run( - "docker", - "build", - "--pull", - "-f", - str(ctx / "Dockerfile"), - "--build-arg", - "BUILD_COMMIT=" + commit, - "--build-arg", - "BUILD_TIME=" + stamp, - "--build-arg", - "ALPINE_MIRROR=" + os.getenv("ALPINE_MIRROR", "dl-cdn.alpinelinux.org"), - "-t", - image, - str(ctx), - ) + if not verify_prebuilt_image(image, commit, version, hashes): + # A source-only overlay would retain vulnerable packages from the old image. + run( + "docker", + "build", + "--pull", + "-f", + str(ctx / "Dockerfile"), + "--build-arg", + "BUILD_COMMIT=" + commit, + "--build-arg", + "BUILD_TIME=" + stamp, + "--build-arg", + "ALPINE_MIRROR=" + os.getenv("ALPINE_MIRROR", "dl-cdn.alpinelinux.org"), + "-t", + image, + str(ctx), + ) compose = json.loads((APP / "compose.json").read_text()) service = compose["services"][CONTAINER] service["image"] = image diff --git a/tests/test_server_release.py b/tests/test_server_release.py index 91daead..4e83b36 100644 --- a/tests/test_server_release.py +++ b/tests/test_server_release.py @@ -92,3 +92,53 @@ def test_release_rejects_wrong_storage_uuid(release_tool, monkeypatch): monkeypatch.setattr(release_tool, "run", lambda *args: "different-storage-uuid") with pytest.raises(AssertionError): release_tool.main() + + +@pytest.mark.parametrize("invalid", [None, "id", "commit", "version", "content"]) +def test_prebuilt_image_is_bound_to_source_before_deployment( + release_tool, monkeypatch, invalid +): + import json + import subprocess + + digest = "sha256:" + "a" * 64 + monkeypatch.setenv("FRAME_FLOW_PREBUILT_IMAGE_ID", digest) + metadata = { + "Id": digest, + "Config": { + "Labels": { + "org.opencontainers.image.revision": "fixture-commit", + "org.opencontainers.image.version": "0.1.22", + } + }, + } + if invalid == "id": + metadata["Id"] = "sha256:" + "b" * 64 + if invalid == "commit": + metadata["Config"]["Labels"]["org.opencontainers.image.revision"] = "wrong" + if invalid == "version": + metadata["Config"]["Labels"]["org.opencontainers.image.version"] = "wrong" + monkeypatch.setattr(release_tool, "run", lambda *args: json.dumps([metadata])) + calls = [] + + def isolated(command, **kwargs): + calls.append(command) + assert "--network=none" in command and "--read-only" in command + assert "--cap-drop=ALL" in command and "--user=10333:10333" in command + assert "-v" not in command and "--mount" not in command + assert json.loads(kwargs["input"]) == {"LICENSE": "fixture-hash"} + if invalid == "content": + raise subprocess.CalledProcessError(1, command) + + monkeypatch.setattr(release_tool.subprocess, "run", isolated) + if invalid: + with pytest.raises((AssertionError, subprocess.CalledProcessError)): + release_tool.verify_prebuilt_image( + "fixture-image", "fixture-commit", "0.1.22", {"LICENSE": "fixture-hash"} + ) + else: + assert release_tool.verify_prebuilt_image( + "fixture-image", "fixture-commit", "0.1.22", {"LICENSE": "fixture-hash"} + ) + if invalid in ("id", "commit", "version"): + assert not calls