Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28e971c0ff | ||
|
|
a2e8a204e8 | ||
|
|
50bb41899d | ||
|
|
cb8ac32b74 |
+1
-1
@@ -43,7 +43,7 @@ WORKDIR /app
|
||||
ARG BUILD_COMMIT=development
|
||||
ARG BUILD_TIME=unknown
|
||||
ENV BUILD_COMMIT=$BUILD_COMMIT BUILD_TIME=$BUILD_TIME
|
||||
LABEL org.opencontainers.image.title="PIGWay Frame Flow" org.opencontainers.image.version="0.1.21" org.opencontainers.image.revision=$BUILD_COMMIT
|
||||
LABEL org.opencontainers.image.title="PIGWay Frame Flow" org.opencontainers.image.version="0.1.23" org.opencontainers.image.revision=$BUILD_COMMIT
|
||||
USER 10333:10333
|
||||
EXPOSE 18787
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
|
||||
+5
-7
@@ -1,9 +1,7 @@
|
||||
# pigway-frame-flow v0.1.21
|
||||
# pigway-frame-flow v0.1.23
|
||||
|
||||
基础映像改为固定摘要的官方 Python 3.14 Alpine 3.24,并应用系统安全更新,移除 Debian 基础系统遗留告警。保留原字体路径、中文字体、时区和第三方许可。
|
||||
- 自动调度、自动重试和后台清理在同一设备已有图片等待同步时排队,避免 /upload 激活新图或删除操作更新云端计数,干扰尚未确认的目标。等待沿用现有同步超时;保留用户手动切换与优先级行为。
|
||||
- 在操作回执中保留最多 12 条设备读回状态及受限错误字段,记录实际图片、同步、在线和心跳,供定位未确认原因;不保存原始异常、URL 或凭据。采样不延长同步超时,原始上传证据保留。
|
||||
- 沿用安全基础镜像、完整构建和固定镜像 ID 的预构建验收流程;不重发不确定的设备写入。
|
||||
|
||||
发布脚本统一从完整 Dockerfile 构建,避免复用旧运行映像继续携带漏洞;版本标签读取项目元数据。移除旧 Debian 离线字体缓存和运行映像覆盖构建方式,同步构建参数与文档。
|
||||
|
||||
585 项测试、Ruff、格式和 mypy 通过。AMD64 容器启动/重启、只读接口及中文实际字形和 PNG 渲染验证通过。Trivy 0.76.0 扫描系统包和全部 25 项 Python 运行依赖,各严重级别均未检出已知漏洞;结论限于 2026-10-18 漏洞库快照。
|
||||
|
||||
生产升级须通过现有挂载身份校验和部署流程完成,发布源码不等同于生产升级。
|
||||
验证:601 项测试、Ruff、格式和 mypy 通过。历史设备物理刷新状态无法从缺失的原始采样追溯,不能将此版本描述为已修复所有网络或设备故障。
|
||||
|
||||
+14
-14
@@ -5,12 +5,12 @@ b45d35d737f119a70bf40cb1f9626ff80772ed7913cb35f6e9b870d6ada01a15 .github/workfl
|
||||
d3ad09bdc5dc60c41a774e1cad43f3e3f8ae5c851303ae6ec2aac030392a2939 .github/workflows/verify.yml
|
||||
3931cc314019760a582e0914e2d9c0f3b862a004d0a30d374900edcad5f599e5 .gitignore
|
||||
4b4ed4926a350cbeb71436541b4566e4ba7d2494ac95236b88827a9f5dd171c2 CONTRIBUTING.md
|
||||
660c76fccccae8643633d052d0d6733ecf278f2a3016de29e6850b312a365413 Dockerfile
|
||||
c6fc984a1ca46cbd3e06de4591e41d21e4eb592416385d4000ade8fd570aad0c Dockerfile
|
||||
6eb7728e6e73f9568ca6fd5ad6383d13ffc638f2870fefa2d86f72f39685c0fc LICENSE
|
||||
250a2bcec248b074786d90e0329874628d70d5a0fe3b4fba47c8c57d4f84dcce NOTICE.md
|
||||
26ad0d9063c0ee843f0f532604b8fd66e8b39fd72d129595ed078677c0d6d13c README.md
|
||||
ee00cb267b38485f1e574d610f0a57b51d4ae575731a18e57d06a7c43dc0a8b0 README.zh-CN.md
|
||||
304bec983ac8242107b1ec4154522f451a5d33b6a77ed26669926cb3e161f863 RELEASE_NOTES.md
|
||||
5ec5770a6e0202d981c5719de8cb09fefbd5d0a79b537d949b91ec67c8bce72b 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
|
||||
@@ -33,23 +33,23 @@ edf44436fd044561539c48480c481e66c1a38164f51ba5226cde04c756f7bacb docs/PHOTO_SOU
|
||||
bfc38b889fa966031e195c3131db1d4a713e0dc35c644e3047c7ed94f2354700 docs/RESEARCH.md
|
||||
fac36f986e1a7a331d3669aa8e2013e6c0ea7a99c16a61ba3ff573e836aa87e1 docs/SYNOLOGY_PHOTOS_API.md
|
||||
ac0481196e593ff9cca2e5133ff41fb3614268e6a10728a0deca3ad3bfb80b34 docs/integrations/TICKTAR.md
|
||||
b191be41aec1dab92ff0d2956d32ec6a325b49786ce9051e5299fe20e2b6b62d docs/openapi.json
|
||||
1794e3b09fa4eb9fb59cd18f729f49d6387885fc97a0b4f227e825cc49603554 docs/openapi.json
|
||||
54bf7061e0626900ce35e19e81efc5c32c8fd6a0438bdad68f494e1ce81616d4 docs/research/KOKONNA_ACCOUNT_CAPABILITIES.md
|
||||
a07cbe71571c6e2d5a1b94897bd63321c74675c05d15438c4964eef39db0fd76 docs/research/KOKONNA_CONTENT_SUBSCRIPTIONS.md
|
||||
ec53d587b855be84283c50f8fb45be62a4c1caba78b28d436a8260fd171943a2 docs/research/KOKONNA_LOCAL_CONTROL.md
|
||||
2a68fe95ba4afc24aec6abd3e6ab24a360eb6bc3005ab0fefd696190915d76c1 docs/research/KOKONNA_WIDGET_CATALOG.json
|
||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/__init__.py
|
||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/api/__init__.py
|
||||
06e2e5156383120271ec989b89236bd1baa2d7ba3d6e51b3018de2b23bd39d91 pigway_frame_flow/api/app.py
|
||||
f28c2277b30d583fb7702e425ca99fe229c7e7bdc45b6142fc1bfe2e989440a6 pigway_frame_flow/api/app.py
|
||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/core/__init__.py
|
||||
fbadf6beaf25b9670dcbf125ec9d192188cf9e076ee4d4a0b5decdaf3834417c pigway_frame_flow/core/accounts.py
|
||||
818e8fb29d22c13159a6cb22bcbc6b9e211a8a1301588126eb192ac4ed71441b pigway_frame_flow/core/auth.py
|
||||
8f2220fa90b0dc2e45df2023dc5ac695efe55361644ef266829553c99151949b pigway_frame_flow/core/registry.py
|
||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/display/__init__.py
|
||||
81effa34d6c452579cfa65781909f7c5a9e8ddad136e4c47ee0704939851879a pigway_frame_flow/display/cloud_library.py
|
||||
558d2e2111e3dea1917eaa4f81d7c2dac3b47af3eda1cdc9426af4bf9bc570dd pigway_frame_flow/display/recovery.py
|
||||
5e7e272e671a6d01d9b677416c77967ca488b4e2aa559f4211cf0de4ba1e88bc pigway_frame_flow/display/recovery.py
|
||||
e1e4ae3ba321ae7282c5a4e061c52f24d2a6a6e3d4637e69e4b1fbd05004f5f6 pigway_frame_flow/display/retention.py
|
||||
1ee9ee8745dc497dadcc20e5e238c78f6c228f7f8a4ed310ced32f9be55b512e pigway_frame_flow/display/service.py
|
||||
6b09243081d53fb6c8a780d77e064f0685267c61e05f514262c8514ee3a36fda pigway_frame_flow/display/service.py
|
||||
0975b24488be19f24e768c4c76d9f3c0348417c1351c4e410897da00f7be6656 pigway_frame_flow/display/upload_reconciliation.py
|
||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/drivers/__init__.py
|
||||
9a8386ef368a6df7fd8cb23bcc442c28e784729a5ce4a78911ad86245d5c5794 pigway_frame_flow/drivers/base.py
|
||||
@@ -82,7 +82,7 @@ a518965aa4db9abeb60865c315229c3053c8e9edd26d80f24dc0af0528bad9e6 pigway_frame_f
|
||||
2a68fe95ba4afc24aec6abd3e6ab24a360eb6bc3005ab0fefd696190915d76c1 pigway_frame_flow/integrations/kokonna_official/widget_catalog.json
|
||||
aea594fb4e4c9a6e2094d4d7973eb627ac02fb632811b2d1a558a0ee1cd12e77 pigway_frame_flow/integrations/kokonna_official/widgets.py
|
||||
1fe53f4bfb407b8db774fe730131ecf1213e16d752a3506c4c56b46365ea14e8 pigway_frame_flow/main.py
|
||||
0a7a96f84c3c1248eadf80ae412df36c71d308771f9f53512f3fc3c9bc6d5976 pigway_frame_flow/models.py
|
||||
c3fbe6af9d22989f1dc2fa980f64cf51672d7d45c28c5c8e88ba121a9709b578 pigway_frame_flow/models.py
|
||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/providers/__init__.py
|
||||
b7a6cb8eb40d8dedfbce18777f8fbd433a174f14bcdadf498bd834132aa568fd pigway_frame_flow/providers/cloud_drives.py
|
||||
58b070f0c814d639a4a912d079479d751d459816d59f2199d0be03e4d573ec3b pigway_frame_flow/providers/external.py
|
||||
@@ -120,12 +120,12 @@ c43faa235848bec6370d7a3d8a7b3475cbd8eb4a6e494518dfaf160ca8f77bfa pigway_frame_f
|
||||
d7d55b06ead173b4c23a277fa39800cadfc18f1163c500e8617cfc1f4ee0dea9 pigway_frame_flow/web/extensions.js
|
||||
fe19e508ad60fab0ab4ca61f5d966f8340143e89f1572de00e78b3f1fed8384b pigway_frame_flow/web/features.js
|
||||
3c4e3be6321d40547d45724fc3c54af090ebbbc2a41dec717966596577c72c5d pigway_frame_flow/web/i18n.js
|
||||
d4518595bc39adfd88d908f98c8c14c02ffbc67a4289003e792b2ceade2838dd pigway_frame_flow/web/index.html
|
||||
2086605c67bb818b5cabae20ea4d9ccecb17e350c59cdb0d0f2ba1b70f7c8601 pigway_frame_flow/web/index.html
|
||||
0609e796b5ca8346405b1bb6e65ac9f88ee163f7a93024d96d54d4e6ab96ba41 pigway_frame_flow/web/style.css
|
||||
cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30 pigway_frame_flow/web/swagger-LICENSE
|
||||
050bc415ee7048dcd881682678f720264e7da5e373f7461d7c58c755305255f7 pigway_frame_flow/web/swagger-ui-bundle.js
|
||||
1ac324f7dcd27e4b9386b4bd6421271ec147e922a22c05ba24b11515e9aa6321 pigway_frame_flow/web/swagger-ui.css
|
||||
da96be4eeb3b57f6805ca766919e3899968179f1e7e6a5cb25dbabfd13bc1569 pyproject.toml
|
||||
97aaf8a332dcf472b8933b0a39aa329865133171e828892d932d29694e011ea5 pyproject.toml
|
||||
d34ac82e4c3feacd38546aa2f0a4491ab17ddbf3ad8f489375783166d78923f0 requirements.lock
|
||||
ae264ad19391bb2515def85963f39458cfa75a8611ff54e5329106c35bd8cc6e scripts/build-verify.sh
|
||||
43e8345ac5f29c2a5d1fa751c0e7c963b00b5391817393541e72c52919f0f2b4 scripts/check-compose-remote.py
|
||||
@@ -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
|
||||
@@ -207,10 +207,10 @@ f14bad45534041ff0b7dead21d2952ec9d6c60e9f718ab5393bdf5a47b2a6d3e tests/test_pho
|
||||
ab773ca6868ec007786e7142d5425663a5c8da35b9aa90837ac8e46b51e51507 tests/test_production_bootstrap.py
|
||||
fee152c5f6eaeda712d38cf88a79213f5e885ba323eb15dfa5a12cf4c88028c3 tests/test_production_deploy.py
|
||||
f0601f5ed89e8a14a4584a9683611aa7510a964f50a642348b8ab6c62df72278 tests/test_publication_security.py
|
||||
0ed189d3469097aa7538325d418425e0c7a3ccc8a285bcf4c2ac10658af4b0df tests/test_recovery.py
|
||||
072e8ca2fa68ca73fb078b107ced96ea683652410b286017db883c97608d78f6 tests/test_retention.py
|
||||
915992a2ef9aeb8ae5c688d5dd50c6acd37ba726eb5aff1a0df991ffe374d94e tests/test_recovery.py
|
||||
e0a426eed6f1b6f59207b4de7d8286b6852550bfa28ad30e20f7e911ad9865ee 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+3
-3
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "PIGWay Frame Flow API",
|
||||
"version": "0.1.21"
|
||||
"version": "0.1.23"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/devices/{device_id}/widgets/selection": {
|
||||
@@ -15227,7 +15227,7 @@
|
||||
"version": {
|
||||
"type": "string",
|
||||
"title": "Version",
|
||||
"default": "0.1.21"
|
||||
"default": "0.1.23"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
@@ -17490,7 +17490,7 @@
|
||||
"version": {
|
||||
"type": "string",
|
||||
"title": "Version",
|
||||
"default": "0.1.21"
|
||||
"default": "0.1.23"
|
||||
},
|
||||
"api_version": {
|
||||
"type": "string",
|
||||
|
||||
@@ -126,7 +126,7 @@ def create_app(settings=None, start_worker=True):
|
||||
app = FastAPI(
|
||||
title="PIGWay Frame Flow API",
|
||||
docs_url=None,
|
||||
version="0.1.21",
|
||||
version="0.1.23",
|
||||
lifespan=lifespan,
|
||||
responses=error_responses,
|
||||
)
|
||||
@@ -1040,7 +1040,7 @@ def create_app(settings=None, start_worker=True):
|
||||
supports_upload=True,
|
||||
),
|
||||
],
|
||||
version="0.1.21",
|
||||
version="0.1.23",
|
||||
mock=settings.mock,
|
||||
write_enabled=settings.write_enabled,
|
||||
api_key_configured=bool(settings.api_key)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""Durable, read-only recovery progress for writes with uncertain outcomes."""
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from pigway_frame_flow.errors import ErrorCode
|
||||
from pigway_frame_flow.storage.database import encode, now_iso
|
||||
|
||||
|
||||
@@ -71,3 +73,92 @@ class RecoveryMonitor:
|
||||
if row:
|
||||
row["requires_manual_review"] = row["state"] == "manual_review"
|
||||
return row
|
||||
|
||||
def observe_device(self, op, *, device=None, error=None):
|
||||
row = self.db.one("SELECT result FROM operations WHERE id=?", (op["id"],))
|
||||
result = json.loads(row["result"] or "{}")
|
||||
observation = {"checked_at": now_iso()}
|
||||
if device is not None:
|
||||
observation.update(
|
||||
image_id=device.current_content.id,
|
||||
synced=device.current_content.synced,
|
||||
online=device.status == "online",
|
||||
heartbeat=device.last_seen_at,
|
||||
)
|
||||
else:
|
||||
observation["read_error"] = {
|
||||
"code": error.code,
|
||||
**{
|
||||
k: v
|
||||
for k, v in error.details.items()
|
||||
if k
|
||||
in ("transport_error", "http_status", "attempts", "retry_exhausted")
|
||||
},
|
||||
}
|
||||
samples = result.get("sync_observations", [])
|
||||
if samples:
|
||||
old = samples[-1]
|
||||
changed = {k: v for k, v in old.items() if k != "checked_at"} != {
|
||||
k: v for k, v in observation.items() if k != "checked_at"
|
||||
}
|
||||
if (
|
||||
not changed
|
||||
and (
|
||||
datetime.now(UTC) - datetime.fromisoformat(old["checked_at"])
|
||||
).total_seconds()
|
||||
< 60
|
||||
):
|
||||
return observation
|
||||
samples.append(observation)
|
||||
result["sync_observations"] = (
|
||||
[samples[0], *samples[-11:]] if len(samples) > 12 else samples
|
||||
)
|
||||
# Evidence must not reset updated_at: that is the sync timeout's clock.
|
||||
self.db.execute(
|
||||
"UPDATE operations SET result=? WHERE id=?", (encode(result), op["id"])
|
||||
)
|
||||
return observation
|
||||
|
||||
def close_superseded_displays(self):
|
||||
for op in self.db.all(
|
||||
"SELECT * FROM operations WHERE action='display' AND write_started=1 "
|
||||
"AND status IN ('SYNCING','UNKNOWN')"
|
||||
):
|
||||
result = json.loads(op["result"] or "{}")
|
||||
if not result.get("image_id") or json.loads(op["payload"]).get(
|
||||
"options", {}
|
||||
).get("restore_previous"):
|
||||
continue
|
||||
newer = self.db.one(
|
||||
"SELECT id,result FROM operations WHERE device_id=? AND action='display' "
|
||||
"AND status='COMPLETED' AND write_started=1 AND created_at>? "
|
||||
"AND COALESCE(json_extract(payload,'$.options.restore_previous'),0)=0 "
|
||||
"AND json_extract(result,'$.image_id') IS NOT NULL "
|
||||
"ORDER BY created_at DESC LIMIT 1",
|
||||
(op["device_id"], op["created_at"]),
|
||||
)
|
||||
if (
|
||||
not newer
|
||||
or json.loads(newer["result"])["image_id"] == result["image_id"]
|
||||
):
|
||||
continue
|
||||
# A later confirmed permanent display ends this attempt, but cannot
|
||||
# prove whether the earlier image was ever shown. Never replay it.
|
||||
self.service.update(
|
||||
op["id"],
|
||||
"FAILED",
|
||||
error={
|
||||
"code": ErrorCode.DISPLAY_NOT_CONFIRMED,
|
||||
"message": "Superseded by a later confirmed display; earlier display unconfirmed",
|
||||
"request_id": op["request_id"],
|
||||
"details": {
|
||||
"superseded_by_operation_id": newer["id"],
|
||||
"previous_error": json.loads(op["error"] or "null"),
|
||||
},
|
||||
},
|
||||
)
|
||||
self.db.history(op, "DISPLAY_NOT_CONFIRMED")
|
||||
self.db.event(
|
||||
"operation.failed",
|
||||
{"operation_id": op["id"], "code": ErrorCode.DISPLAY_NOT_CONFIRMED},
|
||||
)
|
||||
|
||||
@@ -174,7 +174,7 @@ class DisplayService:
|
||||
if result:
|
||||
row = self.db.one("SELECT result FROM operations WHERE id=?", (id,))
|
||||
previous = json.loads(row["result"]) if row and row["result"] else {}
|
||||
for key in ("photo_attempts", "upload_intent"):
|
||||
for key in ("photo_attempts", "upload_intent", "sync_observations"):
|
||||
if key in previous:
|
||||
result = {**result, key: previous[key]}
|
||||
self.db.execute(
|
||||
@@ -518,6 +518,25 @@ class DisplayService:
|
||||
) and self.blocked(op["device_id"]):
|
||||
continue
|
||||
payload = json.loads(op["payload"])
|
||||
if (
|
||||
op["action"] in ("display", "delete", "restore")
|
||||
and op["client_id"]
|
||||
in ("scheduler", "scheduler-retry", "cloud-library", "retention")
|
||||
and self.db.one(
|
||||
"SELECT id FROM operations WHERE device_id=? AND action='display' "
|
||||
"AND status='SYNCING' AND write_started=1 AND updated_at>? LIMIT 1",
|
||||
(
|
||||
op["device_id"],
|
||||
(
|
||||
datetime.now(UTC)
|
||||
- timedelta(seconds=self.settings.sync_timeout)
|
||||
).isoformat(),
|
||||
),
|
||||
)
|
||||
):
|
||||
# Both upload and deletion update the cloud frame counter.
|
||||
# Automatic writes must not invalidate a pending display ack.
|
||||
continue
|
||||
if op["action"] == "display":
|
||||
priority = payload["options"]["priority"]
|
||||
top = self.top(op["device_id"])
|
||||
@@ -823,6 +842,7 @@ class DisplayService:
|
||||
try:
|
||||
driver = self.registry.get(op["device_id"])
|
||||
device = driver.device(refresh=True)
|
||||
self.recovery.observe_device(op, device=device)
|
||||
if intent:
|
||||
expected = self.upload_reconciliation.check(op, driver, device)
|
||||
if (
|
||||
@@ -850,7 +870,8 @@ class DisplayService:
|
||||
if device.current_content.id == expected
|
||||
else "target_mismatch",
|
||||
)
|
||||
except FlowError:
|
||||
except FlowError as exc:
|
||||
self.recovery.observe_device(op, error=exc)
|
||||
self.recovery.observe(op, "read_unavailable")
|
||||
except Exception:
|
||||
# Bad vendor metadata for one operation must not stop the worker.
|
||||
@@ -858,6 +879,7 @@ class DisplayService:
|
||||
for op in self.db.all("SELECT * FROM operations WHERE status='SYNCING'"):
|
||||
try:
|
||||
device = self.registry.get(op["device_id"]).device()
|
||||
observation = self.recovery.observe_device(op, device=device)
|
||||
target = json.loads(op["result"])["image_id"]
|
||||
if (
|
||||
device.current_content.id == target
|
||||
@@ -878,11 +900,13 @@ class DisplayService:
|
||||
"code": E.WRITE_RESULT_UNKNOWN,
|
||||
"message": "Frame refresh was not confirmed",
|
||||
"request_id": op["request_id"],
|
||||
"details": {},
|
||||
"details": {"last_device_observation": observation},
|
||||
},
|
||||
)
|
||||
except FlowError:
|
||||
pass
|
||||
except FlowError as exc:
|
||||
self.recovery.observe_device(op, error=exc)
|
||||
|
||||
self.recovery.close_superseded_displays()
|
||||
|
||||
def expire(self):
|
||||
if not self.settings.mock and not self.settings.write_enabled:
|
||||
|
||||
@@ -366,7 +366,7 @@ class SettingsView(BaseModel):
|
||||
|
||||
class Health(BaseModel):
|
||||
status: str
|
||||
version: str = "0.1.21"
|
||||
version: str = "0.1.23"
|
||||
|
||||
|
||||
class Readiness(BaseModel):
|
||||
@@ -397,7 +397,7 @@ class RestoreRequest(StrictModel):
|
||||
|
||||
class Version(BaseModel):
|
||||
product: str = "PIGWay Frame Flow"
|
||||
version: str = "0.1.21"
|
||||
version: str = "0.1.23"
|
||||
api_version: str = "v1"
|
||||
commit: str = "development"
|
||||
build_time: str = "unknown"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="color-scheme" content="light dark"><title>PIGWay Frame Flow</title><link rel="stylesheet" href="/ui/style.css"></head>
|
||||
<body><aside class="sidebar"><a class="brand" href="/"><span>PIGWay Frame Flow<small>Smart Frame Automation</small></span></a><nav id="nav"></nav><div class="sidebar-bottom"><small>Version 0.1.21</small></div></aside><div class="workspace"><header><div><span class="eyebrow">PIGWay Frame Flow</span><h1 id="page-title"></h1></div><div class="toolbar"><select id="device" aria-label="Device"></select><button id="lang" class="icon-button" aria-label="Change language">EN</button><button id="theme" class="icon-button" aria-label="Change theme">◐</button></div></header><main id="main"></main><footer id="footer"></footer></div><div id="toast" role="status" aria-live="polite"></div><dialog id="modal"><div id="modal-content"></div></dialog><script src="/ui/i18n.js?v=0.1.21"></script><script src="/ui/features.js?v=0.1.21"></script><script src="/ui/extensions.js?v=0.1.21"></script><script src="/ui/app.js?v=0.1.21"></script></body></html>
|
||||
<body><aside class="sidebar"><a class="brand" href="/"><span>PIGWay Frame Flow<small>Smart Frame Automation</small></span></a><nav id="nav"></nav><div class="sidebar-bottom"><small>Version 0.1.23</small></div></aside><div class="workspace"><header><div><span class="eyebrow">PIGWay Frame Flow</span><h1 id="page-title"></h1></div><div class="toolbar"><select id="device" aria-label="Device"></select><button id="lang" class="icon-button" aria-label="Change language">EN</button><button id="theme" class="icon-button" aria-label="Change theme">◐</button></div></header><main id="main"></main><footer id="footer"></footer></div><div id="toast" role="status" aria-live="polite"></div><dialog id="modal"><div id="modal-content"></div></dialog><script src="/ui/i18n.js?v=0.1.23"></script><script src="/ui/features.js?v=0.1.23"></script><script src="/ui/extensions.js?v=0.1.23"></script><script src="/ui/app.js?v=0.1.23"></script></body></html>
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "pigway-frame-flow"
|
||||
version = "0.1.21"
|
||||
version = "0.1.23"
|
||||
readme = "README.md"
|
||||
license = {file = "LICENSE"}
|
||||
requires-python = ">=3.14,<3.15"
|
||||
|
||||
+60
-17
@@ -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
|
||||
|
||||
+185
-1
@@ -1,5 +1,6 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from test_display import submit
|
||||
|
||||
from pigway_frame_flow.display.service import DisplayService
|
||||
@@ -60,7 +61,9 @@ def test_persistent_block_escalates_once_survives_restart_and_preserves_evidence
|
||||
== 1
|
||||
)
|
||||
assert service.operation(operation)["recovery"]["checks"] == 3
|
||||
assert service.operation(operation)["result"] == original["result"]
|
||||
current_result = service.operation(operation)["result"]
|
||||
assert current_result.pop("sync_observations")
|
||||
assert current_result == original["result"]
|
||||
service.reconcile(operation, acknowledge=True)
|
||||
assert service.operation(operation)["recovery"]["state"] == "resolved"
|
||||
assert not service.operation(operation)["recovery"]["requires_manual_review"]
|
||||
@@ -107,3 +110,184 @@ def test_malformed_reconciliation_does_not_abort_other_background_work(
|
||||
assert op["recovery"]["reason"] == "reconciliation_error"
|
||||
assert "private" not in str(op["recovery"])
|
||||
assert service.blocked("living-room")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", ["SYNCING", "UNKNOWN"])
|
||||
def test_later_confirmed_display_closes_earlier_syncing_attempt_without_replay(
|
||||
service, status
|
||||
):
|
||||
first = submit(service)
|
||||
service.process_one()
|
||||
assert service.operation(first)["status"] == "SYNCING"
|
||||
second = submit(service)
|
||||
service.process_one()
|
||||
driver = service.registry.get("living-room")
|
||||
uploads = driver.client.uploads
|
||||
previous_error = {"code": "WRITE_RESULT_UNKNOWN", "message": "Refresh unconfirmed"}
|
||||
if status == "UNKNOWN":
|
||||
service.update(first, status, error=previous_error)
|
||||
service.sync()
|
||||
assert service.operation(second)["status"] == "COMPLETED"
|
||||
old = service.operation(first)
|
||||
assert old["status"] == "FAILED"
|
||||
assert old["error"]["code"] == "DISPLAY_NOT_CONFIRMED"
|
||||
assert old["error"]["details"]["superseded_by_operation_id"] == second
|
||||
if status == "UNKNOWN":
|
||||
assert old["error"]["details"]["previous_error"] == previous_error
|
||||
assert driver.client.uploads == uploads
|
||||
assert not service.blocked("living-room")
|
||||
service.sync()
|
||||
assert (
|
||||
len(
|
||||
service.db.all(
|
||||
"SELECT * FROM history WHERE operation_id=? AND result='DISPLAY_NOT_CONFIRMED'",
|
||||
(first,),
|
||||
)
|
||||
)
|
||||
== 1
|
||||
)
|
||||
|
||||
|
||||
def test_unconfirmed_later_display_does_not_close_old_attempt(service):
|
||||
first = submit(service)
|
||||
service.process_one()
|
||||
second = submit(service)
|
||||
service.process_one()
|
||||
service.recovery.close_superseded_displays()
|
||||
assert service.operation(first)["status"] == "SYNCING"
|
||||
assert service.operation(second)["status"] == "SYNCING"
|
||||
|
||||
|
||||
def test_confirmed_temporary_override_does_not_close_earlier_display(service):
|
||||
first = submit(service)
|
||||
service.process_one()
|
||||
second = submit(service, 60, True)
|
||||
service.process_one()
|
||||
service.sync()
|
||||
assert service.operation(second)["status"] == "COMPLETED"
|
||||
assert service.operation(first)["status"] == "SYNCING"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("client_id", ["scheduler", "scheduler-retry"])
|
||||
def test_automatic_upload_waits_for_prior_sync_confirmation(service, client_id):
|
||||
first = submit(service)
|
||||
service.process_one()
|
||||
payload = service.db.one("SELECT payload FROM operations WHERE id=?", (first,))[
|
||||
"payload"
|
||||
]
|
||||
import json
|
||||
|
||||
second = service.submit(
|
||||
"display", json.loads(payload), "living-room", client_id, "fixture"
|
||||
)
|
||||
uploads = service.registry.get("living-room").client.uploads
|
||||
service.process_one()
|
||||
assert service.operation(second)["status"] == "QUEUED"
|
||||
assert service.registry.get("living-room").client.uploads == uploads
|
||||
service.sync()
|
||||
assert service.operation(first)["status"] == "COMPLETED"
|
||||
service.process_one()
|
||||
service.sync()
|
||||
assert service.operation(second)["status"] == "COMPLETED"
|
||||
assert service.registry.get("living-room").client.uploads == uploads + 1
|
||||
|
||||
|
||||
def test_device_evidence_is_bounded_and_does_not_extend_sync_timeout(service):
|
||||
first = submit(service)
|
||||
service.process_one()
|
||||
op = service.db.one("SELECT * FROM operations WHERE id=?", (first,))
|
||||
before = service.operation(first)["updated_at"]
|
||||
device = service.registry.get("living-room").device()
|
||||
for i in range(20):
|
||||
sample = device.model_copy(
|
||||
update={
|
||||
"current_content": device.current_content.model_copy(
|
||||
update={"id": 1000 + i}
|
||||
)
|
||||
}
|
||||
)
|
||||
service.recovery.observe_device(op, device=sample)
|
||||
result = service.operation(first)
|
||||
samples = result["result"]["sync_observations"]
|
||||
assert len(samples) == 12
|
||||
assert samples[0]["image_id"] == 1000 and samples[-1]["image_id"] == 1019
|
||||
assert result["updated_at"] == before
|
||||
service.update(first, "UNKNOWN", {"image_id": 7})
|
||||
assert service.operation(first)["result"]["sync_observations"] == samples
|
||||
|
||||
|
||||
def test_device_read_failure_evidence_never_copies_raw_error_details(service):
|
||||
first = submit(service)
|
||||
service.process_one()
|
||||
op = service.db.one("SELECT * FROM operations WHERE id=?", (first,))
|
||||
service.recovery.observe_device(
|
||||
op,
|
||||
error=FlowError(
|
||||
ErrorCode.CLOUD_UNAVAILABLE,
|
||||
"unavailable",
|
||||
details={
|
||||
"transport_error": "read_timeout",
|
||||
"url": "private-url-must-not-be-stored",
|
||||
},
|
||||
),
|
||||
)
|
||||
sample = service.operation(first)["result"]["sync_observations"][-1]
|
||||
assert sample["read_error"]["transport_error"] == "read_timeout"
|
||||
assert "private-url" not in str(sample)
|
||||
|
||||
|
||||
def test_sync_timeout_includes_observed_device_state(service):
|
||||
first = submit(service)
|
||||
service.process_one()
|
||||
service.registry.get("living-room").client.synced = False
|
||||
service.registry.get("living-room").invalidate()
|
||||
service.settings.sync_timeout = 0
|
||||
service.sync()
|
||||
op = service.operation(first)
|
||||
assert op["status"] == "UNKNOWN"
|
||||
assert op["error"]["details"]["last_device_observation"]["synced"] is False
|
||||
|
||||
|
||||
def test_automatic_wait_does_not_extend_past_sync_timeout(service):
|
||||
import json
|
||||
|
||||
first = submit(service)
|
||||
service.process_one()
|
||||
payload = service.db.one("SELECT payload FROM operations WHERE id=?", (first,))[
|
||||
"payload"
|
||||
]
|
||||
service.db.execute(
|
||||
"UPDATE operations SET updated_at=? WHERE id=?",
|
||||
(
|
||||
(
|
||||
datetime.now(UTC) - timedelta(seconds=service.settings.sync_timeout + 1)
|
||||
).isoformat(),
|
||||
first,
|
||||
),
|
||||
)
|
||||
second = service.submit(
|
||||
"display", json.loads(payload), "living-room", "scheduler", "fixture"
|
||||
)
|
||||
service.process_one()
|
||||
assert service.operation(second)["status"] == "SYNCING"
|
||||
|
||||
|
||||
def test_automatic_cleanup_waits_for_display_confirmation(service, monkeypatch):
|
||||
first = submit(service)
|
||||
service.process_one()
|
||||
monkeypatch.setattr(service.cloud_library, "eligible", lambda *args: True)
|
||||
cleanup = service.submit(
|
||||
"delete",
|
||||
{"image_id": 1, "cloud_cleanup": True},
|
||||
"living-room",
|
||||
"cloud-library",
|
||||
"fixture-cleanup",
|
||||
)
|
||||
service.process_one()
|
||||
assert service.operation(cleanup)["status"] == "QUEUED"
|
||||
assert 1 in [x.image_id for x in service.registry.get("living-room").images()]
|
||||
service.sync()
|
||||
service.process_one()
|
||||
assert service.operation(first)["status"] == "COMPLETED"
|
||||
assert service.operation(cleanup)["status"] == "COMPLETED"
|
||||
assert 1 not in [x.image_id for x in service.registry.get("living-room").images()]
|
||||
|
||||
@@ -37,6 +37,8 @@ def due(service, image_id):
|
||||
"UPDATE image_retention SET expires_at=? WHERE image_id=?",
|
||||
((datetime.now(UTC) - timedelta(days=1)).isoformat(), image_id),
|
||||
)
|
||||
# Complete the upload before simulating expiry days later and switching away.
|
||||
service.sync()
|
||||
# The test driver switches to a different image, not a real device.
|
||||
driver = service.registry.get("living-room")
|
||||
driver.display(1)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user