Compare commits

...
9 Commits
Author SHA1 Message Date
渡口浪人 cc3fd02eb7 fix: identify history images and keep failure reasons visible
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 7m29s
2026-10-18 22:58:48 +08:00
渡口浪人 3a5acaa72a fix: allow a pinned CI base-image mirror on Server
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 5m51s
2026-10-18 21:58:10 +08:00
渡口浪人 8e66b6d741 docs: move trusted CI runner from NAS to Server
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Failing after 5m55s
2026-10-18 21:31:03 +08:00
渡口浪人 acdf267e48 fix: align deployment labels and refresh v0.1.23 audit
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 5m23s
2026-10-18 20:59:32 +08:00
渡口浪人 28e971c0ff fix: defer background cleanup until pending display is confirmed
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 7m44s
2026-10-18 20:32:37 +08:00
渡口浪人 a2e8a204e8 fix: defer automatic uploads while device synchronization is pending
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 5m30s
2026-10-18 20:21:52 +08:00
渡口浪人 50bb41899d fix: verify full prebuilt images before offline production deployment
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 5m37s
2026-10-18 19:45:30 +08:00
渡口浪人 cb8ac32b74 fix: close unconfirmed displays superseded by confirmed updates
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 6m29s
2026-10-18 19:27:35 +08:00
渡口浪人 dec17aeb1d fix: replace vulnerable container bases and release v0.1.21
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Failing after 6m49s
2026-10-18 19:10:00 +08:00
37 changed files with 765 additions and 209 deletions
+1 -2
View File
@@ -28,5 +28,4 @@ SYNOLOGY_PHOTOS_CERT_SHA256=
# Build-only mirror hosts; official defaults work outside mainland China.
# For mainland NAS builds: source ./deploy/build-cn.env before build-verify.sh.
DEBIAN_MIRROR=deb.debian.org
DEBIAN_SECURITY_MIRROR=deb.debian.org
ALPINE_MIRROR=dl-cdn.alpinelinux.org
+4 -4
View File
@@ -7,7 +7,7 @@ on:
workflow_dispatch:
jobs:
verify:
# The NAS Docker runner must never execute code from external forks.
# The self-hosted Docker runner must never execute code from external forks.
if: github.server_url != 'https://github.com' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
runs-on: ubuntu-latest
env:
@@ -15,8 +15,8 @@ jobs:
DOCKER_PIP_FALLBACK_INDEX_URL: ${{ vars.DOCKER_PIP_FALLBACK_INDEX_URL }}
CI_PIP_INDEX_URL: ${{ vars.CI_PIP_INDEX_URL }}
CI_PIP_FALLBACK_INDEX_URL: ${{ vars.CI_PIP_FALLBACK_INDEX_URL }}
DEBIAN_MIRROR: ${{ vars.DEBIAN_MIRROR }}
DEBIAN_SECURITY_MIRROR: ${{ vars.DEBIAN_SECURITY_MIRROR }}
CI_PYTHON_BASE: ${{ vars.CI_PYTHON_BASE }}
ALPINE_MIRROR: ${{ vars.ALPINE_MIRROR }}
steps:
- uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: https://github.com/actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
@@ -42,4 +42,4 @@ jobs:
fi
# Production moved to Ubuntu Server. Release manually with scripts/server-release.py;
# the shared NAS runner performs isolated verification only.
# the shared Server runner performs isolated verification only.
-2
View File
@@ -16,8 +16,6 @@ dist/
.mypy_cache/
build-cache/debs/*
!build-cache/debs/.gitkeep
build-cache/wheels/*
!build-cache/wheels/.gitkeep
+11 -22
View File
@@ -1,36 +1,24 @@
ARG PYTHON_BASE=python:3.14.5-slim-bookworm@sha256:ec58d916f9e24a6035cab2bdf07f6206c4cc092a16613c60597534711332d9d6
ARG PYTHON_BASE=python:3.14.8-alpine3.24@sha256:f6a589d43c42b9e7f7dc67a12d37132491f362859a5d750607710cc56da3bc72
FROM ${PYTHON_BASE} AS prepared
ARG DEBIAN_MIRROR=deb.debian.org
ARG DEBIAN_SECURITY_MIRROR=deb.debian.org
ARG ALPINE_MIRROR=dl-cdn.alpinelinux.org
ARG TARGETARCH
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 TZ=Asia/Shanghai \
DATA_DIR=/data CONFIG_DIR=/config
WORKDIR /app
# CJK fonts intentionally retained for readable Chinese rendering.
RUN --mount=type=cache,id=frameflow-bookworm-apt-${TARGETARCH},target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=frameflow-bookworm-lists-${TARGETARCH},target=/var/lib/apt/lists,sharing=locked \
--mount=type=bind,source=build-cache/debs,target=/build-debs,readonly \
rm -f /etc/apt/apt.conf.d/docker-clean \
&& printf 'Binary::apt::APT::Keep-Downloaded-Packages "true";\n' > /etc/apt/apt.conf.d/keep-cache \
&& sed -i "s|http://deb.debian.org/debian-security|https://${DEBIAN_SECURITY_MIRROR}/debian-security|g; s|http://deb.debian.org/debian|https://${DEBIAN_MIRROR}/debian|g" /etc/apt/sources.list.d/debian.sources \
&& apt-get -o Acquire::Retries=3 -o Acquire::https::Timeout=60 update \
&& apt-get -y --no-install-recommends upgrade \
&& if [ -f /build-debs/fonts-noto-cjk.deb ]; then \
font_version=$(apt-cache policy fonts-noto-cjk | awk '$1=="Candidate:" {print $2}'); \
font_sha=$(apt-cache show "fonts-noto-cjk=$font_version" | awk '$1=="SHA256:" {print $2;exit}'); \
test -n "$font_sha"; \
echo "$font_sha /build-debs/fonts-noto-cjk.deb" | sha256sum -c -; \
apt-get install -y --no-install-recommends tzdata; \
dpkg -i /build-debs/fonts-noto-cjk.deb; \
else apt-get install -y --no-install-recommends fonts-noto-cjk tzdata; fi \
&& groupadd --gid 10333 frameflow && useradd --uid 10333 --gid 10333 --no-create-home frameflow \
# Preserve the existing FONT_PATH contract for deployments and Chinese rendering.
RUN sed -i "s|https://dl-cdn.alpinelinux.org/alpine|https://${ALPINE_MIRROR}/alpine|g" /etc/apk/repositories \
&& apk upgrade --no-cache \
&& apk add --no-cache font-noto-cjk tzdata \
&& mkdir -p /usr/share/fonts/opentype/noto \
&& ln -s /usr/share/fonts/noto/NotoSansCJK-Regular.ttc /usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc \
&& addgroup -g 10333 frameflow && adduser -D -H -u 10333 -G frameflow frameflow \
&& mkdir -p /data/images /data/generated /data/cache /config \
&& chown -R 10333:10333 /data /config
COPY requirements.lock ./
COPY scripts/ci-install.py /build-tools/install.py
ARG DOCKER_PIP_INDEX_URL=https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
ARG DOCKER_PIP_FALLBACK_INDEX_URL=https://pypi.org/simple
RUN --mount=type=cache,id=frameflow-pip-py314-${TARGETARCH},target=/root/.cache/pip,sharing=locked \
RUN --mount=type=cache,id=frameflow-pip-py314-musl-${TARGETARCH},target=/root/.cache/pip,sharing=locked \
--mount=type=bind,source=build-cache/wheels,target=/build-wheels,readonly \
if [ -f /build-wheels/SHA256SUMS ]; then \
(cd /build-wheels && sha256sum -c SHA256SUMS) \
@@ -55,6 +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.24" org.opencontainers.image.revision=$BUILD_COMMIT
USER 10333:10333
EXPOSE 18787
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
+2 -1
View File
@@ -21,7 +21,8 @@ for endpoint coverage. Their source code was not incorporated into runtime.
FastAPI, Starlette, Uvicorn, Requests, croniter, PyYAML, Pydantic and their transitive
dependencies retain their respective upstream licenses. Pillow uses its upstream
HPND license. Debian Noto CJK fonts retain their font package license.
HPND license. Noto CJK fonts use SIL OFL 1.1; the license is included in
`vendor/noto-cjk-OFL-1.1.txt`, with copyright notices retained in font metadata.
Development dependencies (pytest, Ruff, mypy, pip-audit, HTTPX2, Playwright) retain
their upstream licenses and are not shipped in the runtime image.
+5 -7
View File
@@ -1,9 +1,7 @@
# pigway-frame-flow v0.1.20
# pigway-frame-flow v0.1.24
统一开源许可证、贡献和安全反馈入口;完善当前版本的文档导航、源码分发和校验和。插件 ZIP 携带许可证。运行功能边界保持项目首页所述。
- 历史记录标明云端图片 ID 或本地图片 ID,同时显示图片名称和错误原因,避免编号遮住失败提示。
- 详情优先展示可用的本地预览;云端图片清理后仍可关联原上传记录。保留图片名称快照,本地文件清理后不再提供失效预览。
- 不重发设备指令,不改变图片清理策略。
本轮 585 项 Python 测试通过。源码语法、文档入口、打包和校验和检查通过;生产运行状态以实际部署验收为准。
Ruff、格式、mypy、25 项锁定依赖的漏洞检查通过;AMD64 Docker 构建和两轮隔离启动/重启只读冒烟验证通过。
Docker 构建增加发行版安全更新,清除可修复系统包告警;仍有上游未修复包级告警,分析及默认部署边界见 [安全说明](SECURITY.md),不宣称映像零漏洞。
验证:603 项测试、Ruff、mypy 及中英文桌面/平板/手机历史列表、错误提示和预览检查通过。
+3 -7
View File
@@ -14,12 +14,8 @@ A useful private report includes the affected version, deployment mode, required
permissions, impact, and minimal steps using mock data. Do not test on another
person's deployment or write to real frames without explicit authorization.
## 当前容器基础系统扫描
## 当前容器映像检查
2026-10-18 使用 Trivy 0.76.0 扫描当前映像。在构建阶段应用 Debian 安全更新后,可修复的 HIGH/CRITICAL 项已清除;仍有 63 条发行版包级告警,合并为 16 个 CVE,当前 Bookworm 无可用修复版本。不能将 Python 依赖检查通过描述为整个映像零漏洞。
v0.1.23 使用官方 Python Alpine 3.24 基础映像并固定摘要,构建时应用 APK 安全更新。2026-10-18 使用 Trivy 0.76.0 扫描与当前生产运行 ID 一致的 Linux AMD64 映像,涵盖操作系统和 Python 包、全部严重级别,未检出已知漏洞;未配置漏洞忽略规则。该结果是当日漏洞库快照,不是未来无漏洞的保证,也不代表旧运行容器已升级。
- [CVE-2023-45853](https://security-tracker.debian.org/tracker/CVE-2023-45853) 针对 MiniZip;Debian 明确 Bookworm 的 zlib 未构建相应代码,本映像也未安装 MiniZip。
- [CVE-2025-7458](https://security-tracker.debian.org/tracker/CVE-2025-7458) 需要攻击者执行特制 SQL;Frame Flow 不接受任意 SQL 或上传外部 SQLite 数据库。FTS5 相关 [CVE-2026-11822](https://security-tracker.debian.org/tracker/CVE-2026-11822)、[CVE-2026-11824](https://security-tracker.debian.org/tracker/CVE-2026-11824) 的匹配查询也未被应用使用。此结论不适用于自行替换数据库或修改服务 SQL 的部署。
- 其他告警涉及挂载/命名空间工具、systemd-homed、ACL、ncurses、Perl/LZH CLI 及 DTLS;应用未调用这些路径。默认 UID/GID 10333、只读根目录、清空 capabilities、禁提权和独立数据卷仍是必要边界。
本轮未发现这些遗留项在默认应用路径中的可达利用入口,不将该判断扩展为基础系统已修复。后续基础映像更新仍须复扫;不得为消除扫描数字而混装其他 Debian 发行版包或删除许可声明。
基础映像更新后须重新构建并复扫。默认非 root、只读根目录、清空 capabilities 和 no-new-privileges 的部署限制继续保留。中文字体、第三方许可证和依赖元数据均保留;不以删除包清单掩盖告警。
+34 -34
View File
@@ -1,57 +1,55 @@
c2792b2c75880ff09822804632b1b3b6dd6190b38841b8c3567bb1b7e7b59a8c .dockerignore
fe4b8e57c14d6f4df261de0ec274f33153def558227a47f38c3f91d5cfc77f80 .env.example
d20031a8e861a0f3e19b7750ddb53bc84c480c469c44a70ecdf8261ec05431c0 .env.example
0c7f4585d62a8d35bfc22e0bebdf7a479a179122b2b092bfe434e3038cddd3d5 .gitattributes
b45d35d737f119a70bf40cb1f9626ff80772ed7913cb35f6e9b870d6ada01a15 .github/workflows/community.yml
c93afa220096b0a3a27115e74617db9dbe97e98c6a3735a0abfb528cdca7e668 .github/workflows/verify.yml
342242379194ca16021542722407a42858f22685293d16162d0e6fe004cbd62c .gitignore
c8c545718e9b9880555c950a078cac160c82e0cd6e593afcf20bc13727539ec5 .github/workflows/verify.yml
3931cc314019760a582e0914e2d9c0f3b862a004d0a30d374900edcad5f599e5 .gitignore
4b4ed4926a350cbeb71436541b4566e4ba7d2494ac95236b88827a9f5dd171c2 CONTRIBUTING.md
6588694aee24d5e662061be78027a049f6726d50dd99ef00a60fba05051ef299 Dockerfile
0acd6eb62044b52828dae0dbd6f34ae91fb64ea1b6fa9f67062c6125a7364e40 Dockerfile
6eb7728e6e73f9568ca6fd5ad6383d13ffc638f2870fefa2d86f72f39685c0fc LICENSE
ef0d1ecd6427325fc92324032fbf7b880624a68629acbaf430cd8be80c73b698 NOTICE.md
250a2bcec248b074786d90e0329874628d70d5a0fe3b4fba47c8c57d4f84dcce NOTICE.md
26ad0d9063c0ee843f0f532604b8fd66e8b39fd72d129595ed078677c0d6d13c README.md
ee00cb267b38485f1e574d610f0a57b51d4ae575731a18e57d06a7c43dc0a8b0 README.zh-CN.md
3c0ab38958a3bdb17ee95b5642c2ee691affc3da44252a5c4a3048ebf7860fe4 RELEASE_NOTES.md
d1233db9c8da6d391028e1e515813cd45ce8d8f97e48087daa325a61789987bc SECURITY.md
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 build-cache/debs/.gitkeep
4eb58ccd6f4b073d6f6e2ed2ca1ce430813064831b1143d63c6ab8b0c453638f RELEASE_NOTES.md
3dd364e47fdbd2ce9ce2df83342c523adc21583f093a5439d501b3424878ce2f SECURITY.md
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 build-cache/wheels/.gitkeep
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 config/.gitkeep
a897bf4a9f95f3e7b96b286a4bf7d124bb4a50fd47a16c6a3d9fbb70bce47f15 deploy/Dockerfile.runtime-update
4cc0b0813f527ac0ca311993f791005de5c8e89e6810ecbf5cf058e58e2fe572 deploy/build-cn.env
dff739157875c8a70235c4994df868afc6824e0d3861b2716ac28014bb38144f deploy/build-cn.env
3683a3cfb63f08cc9e6144162117ab68c607d38bdeaae0e82cb418e812a450f2 deploy/compose.ubuntu-photos-api.yml
d68d727e01c592c098fde71a900bcdd234a9ca6610d0799019a6f585983b4494 deploy/devices.yml.example
fd53125a5359dcae1d81e95b8447aac755270f9aeb354d3e1140d7fccb57c42e deploy/docker-compose.photos.yml
4464545a9d91bbc9fff9cd9941db63f44fa61120caca426687d1ed06635cc3e6 deploy/organized-photos-source.json
582dae3db4a08192d714e81e5e8234b170b4ac4231faf251b2c26eeec54f0942 docker-compose.yml
f78d6c3d40e6c5246601bb99c2b46eef91e883a8f83841d85c925394708af0c0 docker-compose.yml
30d3e705870ebb012ce122bd7d8f95192d86664c4d2bb45a3169c86988fbaf72 docs/AUTOMATIC_COLLAGE.md
6b602b686db5b25060f91cfecf05a840a09ee5b2a6d5099636cb3f47880a4a4d docs/BUILDING.md
c14983d5c65775ad80244fcbb77fe5c0c4a413cc27945b7184bbc923a53d38a8 docs/EXTENSIONS.md
93b6e60570d9ca2ce14a454641a32885ca84d16a82db05db22cd62e488191dff docs/BUILDING.md
d9f07164c75c757e85f4ace2b55deaf82b8b904216c5222be6c0e3ab7b28cb36 docs/EXTENSIONS.md
1d8c5f79eff3115b1cd13d8f63fff1332b80d1fe1bec6b0f2713b5f80b0cbf67 docs/KOKONNA_RECOVERY.md
3b4d24d633dea8474a6a7b8868c5afdd37a5205df32356d3967cf838bcafdba9 docs/NAS.md
0fe1896a53cef4cfd2e380a0b979eceda493733082a9b56c3d7e89e7f62a65b3 docs/NAS.md
3280bddcbe2d2721bb7efeb0eb2ec03d9d5ef867c69f3e70066b69f1ad5926e2 docs/PHOTO_SOURCES.md
edf44436fd044561539c48480c481e66c1a38164f51ba5226cde04c756f7bacb docs/PHOTO_SOURCE_CONNECTIONS.md
4b00d08eb5a2425b205af13b87f9be04dd6870a734b42463a3e075658039dac9 docs/PUBLICATION.md
b48f3ab6f28404bb34c188067a832384f4ba290e56912461ef3565528aa157d8 docs/PUBLICATION.md
5fe4641e75e0746929f71256c6ff11116c777cca8d4288ccc2d7aee91bd7a6ac docs/README.md
5eb19e7e4ed1ceddbdf388fd55697e40f1b7ecd9a4fbdf8ab485132cc684dcb2 docs/REAL_DEVICE_TEST.md
bfc38b889fa966031e195c3131db1d4a713e0dc35c644e3047c7ed94f2354700 docs/RESEARCH.md
6772ddfd79b28bb52ce469e40188f8781f66275bdf4b84861f81c32a87aea6eb docs/SYNOLOGY_PHOTOS_API.md
fac36f986e1a7a331d3669aa8e2013e6c0ea7a99c16a61ba3ff573e836aa87e1 docs/SYNOLOGY_PHOTOS_API.md
ac0481196e593ff9cca2e5133ff41fb3614268e6a10728a0deca3ad3bfb80b34 docs/integrations/TICKTAR.md
757a4d87aec789054dc5268d314cd218f6d7cab49082cfacef55f9860d2362b0 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
632e8be140d1c206388b84e6e08039fc42d6449e83ea23b58d7a57c3404a8747 pigway_frame_flow/api/app.py
53aa03c15f847a1d770f2132d117f793602ecf766b6ab92973bcdf2169946eae 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
5f941b8b402efd6d1adab66df1dff29b1a3effda5ab23776daa300d55d5ca924 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
@@ -84,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
fb41e20b181815a62e213a1d179f6479b155586fc7000c0a103737ddc16b1c83 pigway_frame_flow/models.py
365cb15a6b449bd0e7048666ae9485570956bafca4a5ba2324b6248f4cb08c95 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
@@ -118,18 +116,18 @@ f9e777980ea5bb3ea8fb2357f23530802811ea2674d25100af9b728cbc9f83a9 pigway_frame_f
8f37296f52a88c11a000a5a0767310354550da34e1d4c0c7260393ec00cd0d9b pigway_frame_flow/templates/quote.json
82c17f4e7eaac95483592add181f549186dea9d7c0375fa9cad3554e3ccd1794 pigway_frame_flow/templates/system-alert.json
d6f705b9d183b57d50759e283db2161f8493c4e3969459e74da635252014c925 pigway_frame_flow/templates/weather.json
c43faa235848bec6370d7a3d8a7b3475cbd8eb4a6e494518dfaf160ca8f77bfa pigway_frame_flow/web/app.js
3acad7c209e3483481c3b966b27a0609f2d12c2383955274dfd226a1fe5f41bf pigway_frame_flow/web/app.js
d7d55b06ead173b4c23a277fa39800cadfc18f1163c500e8617cfc1f4ee0dea9 pigway_frame_flow/web/extensions.js
fe19e508ad60fab0ab4ca61f5d966f8340143e89f1572de00e78b3f1fed8384b pigway_frame_flow/web/features.js
3c4e3be6321d40547d45724fc3c54af090ebbbc2a41dec717966596577c72c5d pigway_frame_flow/web/i18n.js
0ff0e5c80f6cfb3dc71a63813e01fd8cde5355b0f135a4fbb2781d43383a569f pigway_frame_flow/web/index.html
cd3dc54289418eea667ad71e236cce61e9dc6add4925bdb46c8a9b7c19860c48 pigway_frame_flow/web/features.js
a43bde8abb2ac9a9e0f41f83219c43c20804d76f0984407308847238554b1f3d pigway_frame_flow/web/i18n.js
d2eae1d2116dc426da00b709154516ca633ee0cd41abd23a5d8f165b1f1673ef 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
a363adb54463d0345cd4e8d0ebe6cac758856e8c0e1d4da49468ddee1ca1aec6 pyproject.toml
7cc44dc5400afa2b12e99ff07252c26c27ec9f8ad87e779d1ab16caf77e29e50 pyproject.toml
d34ac82e4c3feacd38546aa2f0a4491ab17ddbf3ad8f489375783166d78923f0 requirements.lock
ae264ad19391bb2515def85963f39458cfa75a8611ff54e5329106c35bd8cc6e scripts/build-verify.sh
008af5f4adf616c86e805febc016faad60be0008abf5dddad6e955f1d03606cf scripts/build-verify.sh
43e8345ac5f29c2a5d1fa751c0e7c963b00b5391817393541e72c52919f0f2b4 scripts/check-compose-remote.py
8186d4d9c2542d42d786404b096fbbdee22e4c74edae78ddebab5bd3f632764c scripts/ci-audit.py
b0ec44fba0adfceca3655628021683030469be77c9e303c3f5cf830e8fd0cee3 scripts/ci-compose-verify.py
@@ -138,9 +136,9 @@ a11e660a0343f2631c1a3dd0aa28471db827fb1f43d1af4217f51209aa3afe32 scripts/create
cdabf30144ea089ae8e64228f6762951f53d4fe8c191673fb5676331d249473d scripts/migrate-photos-api.py
80813607835c19d321085fac4cabb17d865d98ed62a09b7f45195ac48bec94a8 scripts/package-nas.py
b0b877cfbb2bdf830e2e41097c2057538bb9d0b61a3c39952fe505d910cd5c29 scripts/persistence-check.py
3213ef2c17dbcf7423b04440794b842c691dc65616d56c1e1a85dd286ee34ada scripts/production-deploy.py
b7247c5590fe17ae461311f2fbdbeda0daa8a34c0804cef22a314c7ff8f4c389 scripts/production-deploy.py
11627ecbd8a3be01aa079bdf6166b4368e670f5ad4a165b5971754b8187bbc2c scripts/run_local.py
0fe19e94ffa4e9ff56d4f4a079b19f74991b35f0e18679ac793e8de38ecc3833 scripts/server-release.py
5a0f8560d44352f2c5595875455ab1b2397d4eb8158a8160b5587a89a934f712 scripts/server-release.py
1fc16c43485a60a05012322cb1e4a219309dbd7e11f002f510602beea743ce30 scripts/smoke-test.py
1236d3b2d27559cbe73a27dcf56248ef3c60404c6fb21453caa7225570d4ac45 scripts/ui-audit-regressions.cjs
57a79a534a71442f5e3a70ed9ff96f426007d9f6dd0bb9b1f5d79eac773d2254 scripts/ui-auto-collage.cjs
@@ -151,7 +149,7 @@ f981da8a8b4a043b4d39686feadd0e107e008c4e9e6804c45b92607777d2dbbc scripts/ui-gal
01991b94630c9b4af9f52d304b3b7e7b121c59e32d5765f778b53d238cbc6c06 scripts/ui-gallery-destinations.cjs
bc47ba7a196d84a155d9df5f0e2e23e892256979faa435e01b0a63816562fa01 scripts/ui-gallery-stars.cjs
02bae6aea8e9b6aef99afc690ad6ddf1fe796501fd83667109cedfe67d5c2586 scripts/ui-history-layout.cjs
cb0ccb04e087e2117f2a2e7ebe7f1a6b116e39e2bfc197ea996675ef628c98ae scripts/ui-history-localization.cjs
29c9b2ef02e11aa3320d63daec28185bf6e1e781d33d3ba934125c79c81ba33a scripts/ui-history-localization.cjs
1f87c8b0c6d403c96bf91ca62b6679d1ccdfa3d8cca3f13a006d73f669fe7b09 scripts/ui-image-loading.cjs
742197504264a547bcf5f121ed7fae447d8d31f51672d41278b4b984e94b5129 scripts/ui-integrations.cjs
66a12251ea36f372072923ee0b8c7bfebac222fe1dbb702b22242a3859512a9a scripts/ui-interval-schedule.cjs
@@ -172,7 +170,7 @@ e474740bdb7dbe0617f7c6b170ada918e51f5eed8edbb9c2c47c4590a5dc2359 scripts/verify
b7816947e33a77997a4dd7d595ce385081fc578b2821aedb859277c3b490bf07 scripts/verify-photos-api.py
d4f04e4c8f51c56aa21389d60115ecd1dda384da444dfd4d40237314fb51e3ea tests/conftest.py
41f128b9bf09fd160b8e99f3c811b73443b765b019c71306ada3dc18b83a31ca tests/contract.json
c44080785bb2cca54bb17ac9e25f2e91a36839688bc70e40faecaea42d75e9f8 tests/schema-contract.json
c2c398d7323e47255646e580c498a211a8f1a4fee32861954eaa29628bec8301 tests/schema-contract.json
14a7c56c40ea4b520585c20341b3ec70fa3ba52518144a10ba3494ceceb30758 tests/test_accounts.py
e87ce325c8260f96e586b20697ab0bac71058918ba73f7412eb482364f83f73e tests/test_ai_images.py
94c01e6d8bdbfffbff72edb91823eaf39a325ad8782aba1d8e2804402e256aec tests/test_api.py
@@ -188,6 +186,7 @@ fc7a3b37e43ae6716c3afeeeddc0631c932175d527bd8adfea6e5e0acb166311 tests/test_con
f5d737520934371fa8291727458285ccbc6285d8612f286e424c0f0ea6c34666 tests/test_extensions.py
f695398aabf0ef7a5c86a23bef44421a09eed8b24c655987d87cd793df6a8a32 tests/test_external_photo_sources.py
2ab265986f89df625b8e9c8e057ffa2e421e1c6e9bb02884b355b1b71cb918f7 tests/test_gallery_thumbnail.py
5dc4027bcdad63c4a3e02c7735e6b8445c69edb43f2a3c0cd8902ef6aa1c6342 tests/test_history_images.py
cbc44ca3bd0eab93b39f0aabfe5c9d923105f4556df7fd7b86593c06daddcae5 tests/test_ical.py
787bb5ca8a03b8c94496b00e5a21957dea756b9ba1a4daf354b9e27a5f56d7ef tests/test_images.py
95892c597f80aed1e5217c81ce187c3eae09a18542093c02c97fd2d950cc76c6 tests/test_interval_schedule.py
@@ -209,10 +208,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
@@ -224,5 +223,6 @@ e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 vendor/__init_
4cae8606769fdb590c427bc1716a871017bf22d91aabf8a52513bf126cfb847f vendor/guooscar_kokonna.py
63c7a2f1a6adf98e7fb83a83ae2d6a2dddcecd7bce4d7de25dd6f26bd496019f vendor/guooscar_protocol.py
e5f5b7a5955f2438da131859a08c9d78cda3718ada8a9365a74cbbb9ac5e7853 vendor/metadata.json
6a73f9541c2de74158c0e7cf6b0a58ef774f5a780bf191f2d7ec9cc53efe2bf2 vendor/noto-cjk-OFL-1.1.txt
e8cf7d54ea46c19aba793983889b7f7425e1ebfcaaccec764a7db091646e203c vendor/paho-mqtt-EDL-1.0.txt
57938f47d5f05f7de119b17fcecd4509c5d13644c8d5f43db30e45684d09d07e vendor/swagger-metadata.json
View File
-33
View File
@@ -1,33 +0,0 @@
# Reuse a reviewed image only when the locked Python dependencies are identical.
# For a fresh installation use the repository's main Dockerfile instead.
ARG BASE_IMAGE
FROM ${BASE_IMAGE} AS prepared
# Cleanup runs only in the disposable preparation stage.
USER 0
COPY requirements.lock /app/requirements.expected.lock
RUN python -c "from pathlib import Path; assert Path('/app/requirements.lock').read_bytes() == Path('/app/requirements.expected.lock').read_bytes(), 'Dependency lock changed; use the full Dockerfile'"
COPY pigway_frame_flow /app/pigway_frame_flow
COPY vendor /app/vendor
COPY LICENSE NOTICE.md /app/
# Publish only the cleaned filesystem, without retaining build-stage layers.
RUN rm -rf /usr/local/lib/python*/site-packages/pip \
/usr/local/lib/python*/site-packages/pip-*.dist-info \
/usr/local/lib/python*/ensurepip \
&& rm -f /usr/local/bin/pip /usr/local/bin/pip[0-9]* \
&& find / -name .DS_Store -type f -delete
FROM scratch
COPY --from=prepared / /
ENV PATH=/usr/local/bin:/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin LANG=C.UTF-8
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 TZ=Asia/Shanghai \
DATA_DIR=/data CONFIG_DIR=/config
WORKDIR /app
USER 10333:10333
EXPOSE 18787
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:18787/health',timeout=3)"
CMD ["python", "-m", "uvicorn", "pigway_frame_flow.main:app", "--host", "0.0.0.0", "--port", "18787", "--workers", "1", "--no-access-log"]
ARG BUILD_COMMIT
ARG BUILD_TIME
ENV BUILD_COMMIT=$BUILD_COMMIT BUILD_TIME=$BUILD_TIME
LABEL org.opencontainers.image.title="PIGWay Frame Flow" org.opencontainers.image.version="0.1.19" org.opencontainers.image.revision=$BUILD_COMMIT
+3 -5
View File
@@ -1,5 +1,3 @@
# Shared mainland-China build profile; source this file before Compose builds.
# Nonempty environment / Gitea repository variables override these defaults.
DEBIAN_MIRROR=${DEBIAN_MIRROR:-mirrors.tuna.tsinghua.edu.cn}
DEBIAN_SECURITY_MIRROR=${DEBIAN_SECURITY_MIRROR:-mirrors.tuna.tsinghua.edu.cn}
export DEBIAN_MIRROR DEBIAN_SECURITY_MIRROR
# Shared mainland-China build profile; source before Compose builds.
ALPINE_MIRROR=${ALPINE_MIRROR:-mirrors.tuna.tsinghua.edu.cn}
export ALPINE_MIRROR
+1 -2
View File
@@ -7,8 +7,7 @@ services:
build:
context: .
args:
DEBIAN_MIRROR: ${DEBIAN_MIRROR:-deb.debian.org}
DEBIAN_SECURITY_MIRROR: ${DEBIAN_SECURITY_MIRROR:-deb.debian.org}
ALPINE_MIRROR: ${ALPINE_MIRROR:-dl-cdn.alpinelinux.org}
DOCKER_PIP_INDEX_URL: ${DOCKER_PIP_INDEX_URL:-https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple}
DOCKER_PIP_FALLBACK_INDEX_URL: ${DOCKER_PIP_FALLBACK_INDEX_URL:-https://pypi.org/simple}
BUILD_COMMIT: ${BUILD_COMMIT:-development}
+38 -39
View File
@@ -2,53 +2,48 @@
### Build networking and cache
The Dockerfile retains the pinned Python base, locked Python dependencies,
`fonts-noto-cjk`, Debian repository signature checks, and offline artifact
SHA256 verification. Production uses `scripts/build-verify.sh`; CI uses `scripts/ci-compose-verify.py`.
Both build the same Dockerfile with BuildKit and retain `docker compose build --pull`.
The runtime uses an official Python 3.14 Alpine 3.24 image pinned by digest,
locked Python dependencies, and `font-noto-cjk` for Chinese rendering. APK
repository signatures and offline wheel SHA256 checks remain enabled.
Production uses `scripts/build-verify.sh`; CI uses `scripts/ci-compose-verify.py`.
Both build the same Dockerfile with BuildKit and `docker compose build --pull`.
For a CI host that cannot reach Docker Hub, the optional `CI_PYTHON_BASE`
repository variable passes a registry mirror reference through the build script.
Keep the same official Python image digest; an unset variable preserves the
Dockerfile default. This setting does not change the shared Docker daemon.
APT mirror hosts are configurable through `DEBIAN_MIRROR` and
`DEBIAN_SECURITY_MIRROR` (hosts only, without scheme or path). Defaults are
`deb.debian.org`; HTTPS is used for both Debian and security repositories.
For mainland-China NAS builds, use the shared profile:
`ALPINE_MIRROR` accepts a mirror host (without scheme or path), defaulting to
`dl-cdn.alpinelinux.org`. For mainland-China builds:
```sh
. ./deploy/build-cn.env
./scripts/build-verify.sh
```
CI loads this same profile, using Tsinghua TUNA for both repositories unless
Gitea repository variables `DEBIAN_MIRROR` / `DEBIAN_SECURITY_MIRROR` override
it. Production may also set the two hosts directly in `.env` when calling the
build script without the profile. An alternate mainland mirror is
`mirrors.ustc.edu.cn`; availability and throughput depend on the Runner network.
No fallback disables signature verification or removes packages.
CI uses this profile; the Gitea `ALPINE_MIRROR` repository variable can override
it. APK upgrades available packages during a fresh dependency-layer build.
Use `docker compose build --pull --no-cache` to refresh OS packages explicitly.
Layer order stays: pinned base → APT/font dependencies → `requirements.lock`
→ pip dependencies → application/vendor source → build metadata.
A source-only change therefore reuses the APT and pip layers, provided the
same builder, base digest, mirror arguments, and optional offline artifacts
are used. Changing the lockfile rebuilds pip and later layers, not APT.
Changing the mirror or APT instructions rebuilds APT and later layers.
Previously an APT layer miss redownloaded indexes/packages, and pip used
`--no-cache-dir`. Now locked BuildKit mounts retain APT indexes, downloaded
`.deb` files, and pip downloads/wheels outside the final image. Debian's
`docker-clean` hook is disabled so it cannot discard cached packages.
Re-executed APT steps still run `apt-get update` and validate repository
signatures; cached packages and pip artifacts can avoid repeat large downloads.
The initial build after this Dockerfile change will miss dependency layers;
subsequent builds benefit. No measured speedup is claimed before CI completes.
Caches belong to the Docker builder, not the ephemeral CI workspace. Keep the
NAS Runner's builder storage across jobs; removing the builder, pruning caches,
or using a fresh Docker daemon loses the benefit. Cache mounts are disposable:
an empty cache still builds correctly. `--pull` remains enabled; a pinned base
digest check alone does not invalidate dependency layers. To deliberately
refresh installed APT versions, use `docker compose build --pull --no-cache`.
Layer order is pinned base → APK/fonts → requirements → Python dependencies
→ application source → metadata. BuildKit caches pip downloads separately for
musl and architecture. Optional `build-cache/wheels/` must contain compatible
musllinux wheels and their SHA256 manifest; empty-cache builds work normally.
Do not reuse Debian/manylinux wheels on Alpine. Caches are disposable builder
resources and are not runtime mounts or dependencies.
### CI with a host Docker socket
The managed CI runner runs on Ubuntu Server; the NAS no longer executes jobs.
It retains `ubuntu-latest` compatibility and fixed runner/job image digests,
accepts one job at a time, and limits each task container to 4 CPUs and 8 GiB.
The runner's private state resides on the guarded business disk. Its systemd
service checks the mount identity before starting. Registration credentials are
supplied through a temporary file and removed after registration; the daemon
container does not retain the reusable registration token in its environment.
Keep the current runner and job images even when no task is executing: they are
active CI dependencies. Only trusted repository events may reach this runner's
Docker socket; CI does not automatically deploy production or write to frames.
Run `python scripts/ci-compose-verify.py` after loading the build mirror profile.
The script derives a temporary Compose document from production settings, with
an absolute client-side build context, a UUID project name, a unique CI image
@@ -60,7 +55,7 @@ owned `/data` and `/config` directories; the service remains UID/GID 10333.
Build contexts are sent by the Docker client, so the job workspace can remain
inside the job container. Runtime storage is managed by the daemon; no matching
NAS host `/workspace` directory is needed. HTTP readiness and the unmodified
Docker host `/workspace` directory is needed. HTTP readiness and the unmodified
nine-endpoint smoke script run inside the service with `compose exec -T`.
Restart is followed by another readiness check and the same smoke test.
@@ -94,10 +89,14 @@ certificate environment settings, and proxy settings remain in effect.
These `CI_PIP_*` settings apply only to the Job's pip install. They are not
passed into Docker build arguments or persisted into the production image.
The Dockerfile keeps its own cached/offline dependency installation and logs
the Docker pip index host for online installs. Debian mirror variables affect
APT only and are loaded later in the Docker verification step.
the Docker pip index host for online installs. The Alpine mirror variable affects
APK only and are loaded later in the Docker verification step.
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.
+1 -1
View File
@@ -81,7 +81,7 @@ A failed mirror installation retries the same locked requirements against the
fallback, without extra indexes, version downgrades, or disabling build isolation.
Logs show index hosts and redact URL credentials/query parameters.
APT/font layers remain cached when Python dependencies change. The Python layer
APK/font layers remain cached when Python dependencies change. The Python layer
changes only when the lock, installer, base image, or mirror arguments change;
application source changes reuse it. BuildKit pip cache survives layer misses.
An explicitly supplied, checksum-verified offline wheel directory still takes
+4 -8
View File
@@ -128,23 +128,19 @@ all uncompressed rootfs diff IDs before loading. With the verified local tag loa
```sh
sudo env DOCKER_BUILDKIT=1 /usr/local/bin/docker compose build \
--build-arg PYTHON_BASE=python:3.14.5-slim-bookworm
--build-arg PYTHON_BASE=python:3.14.8-alpine3.24
```
This per-build override does not change global registry or network configuration.
A slow Debian CDN can be overridden only for this build, retaining APT GPG
A slow Alpine CDN can be overridden for this build, retaining APK signature
verification and changing no NAS system settings:
```sh
sudo /usr/local/bin/docker compose build --build-arg DEBIAN_MIRROR=mirrors.aliyun.com
sudo /usr/local/bin/docker compose build --build-arg ALPINE_MIRROR=mirrors.tuna.tsinghua.edu.cn
```
Optional `build-cache/debs/fonts-noto-cjk.deb` avoids a slow build-network download.
Dockerfile verifies it against SHA-256 from the GPG-authenticated APT index before use.
The cache is not a runtime mount or committed dependency, and empty-cache builds work.
Optional `build-cache/wheels/` can hold all 24 Linux AMD64/Python 3.14 wheels
Optional `build-cache/wheels/` can hold all locked Linux AMD64/Python 3.14 musllinux wheels
and `SHA256SUMS`, verified against official PyPI release metadata. Builds verify
the manifest and use pip `--no-index`; incomplete caches fail explicitly.
Build commit/time are set after dependency installation to preserve build cache.
+1 -1
View File
@@ -4,7 +4,7 @@ Runtime keys, passwords, sessions, private photos, databases and local `.env` fi
must remain outside Git. Examples use documentation-only addresses and identities.
Personal deployment reports and screenshots are excluded from public history.
The Gitea NAS Docker runner accepts trusted repository events only. GitHub mirrors
The Gitea Server Docker runner accepts trusted repository events only. GitHub mirrors
use the separate community workflow on GitHub-hosted disposable runners, with
read-only repository permissions and no deployment configuration. External fork PRs
are not executed on this runner; maintainers must review them before copying changes
+1 -1
View File
@@ -35,7 +35,7 @@
FRAME_FLOW_IMAGE='<已验证的镜像标签>' docker compose -f deploy/compose.ubuntu-photos-api.yml config --quiet
```
完整构建使用根目录 Dockerfile。若使用 `deploy/Dockerfile.runtime-update`,必须指定已审核的基础镜像,构建步骤会检查依赖锁与基础镜像相同;不相同时必须完整构建。本次依赖未新增,已比对现有 0.1.8 基础镜像的锁文件。
发布统一使用根目录 Dockerfile 完整构建,包含固定基础映像、系统安全更新和锁定依赖,避免继承旧映像中的系统漏洞。
## 旧来源转换与恢复
+3 -3
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "PIGWay Frame Flow API",
"version": "0.1.19"
"version": "0.1.23"
},
"paths": {
"/api/v1/devices/{device_id}/widgets/selection": {
@@ -15227,7 +15227,7 @@
"version": {
"type": "string",
"title": "Version",
"default": "0.1.19"
"default": "0.1.23"
}
},
"type": "object",
@@ -17490,7 +17490,7 @@
"version": {
"type": "string",
"title": "Version",
"default": "0.1.19"
"default": "0.1.23"
},
"api_version": {
"type": "string",
+2 -2
View File
@@ -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.20",
version="0.1.24",
lifespan=lifespan,
responses=error_responses,
)
@@ -1040,7 +1040,7 @@ def create_app(settings=None, start_worker=True):
supports_upload=True,
),
],
version="0.1.20",
version="0.1.24",
mock=settings.mock,
write_enabled=settings.write_enabled,
api_key_configured=bool(settings.api_key)
+91
View File
@@ -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},
)
+60 -6
View File
@@ -152,6 +152,31 @@ class DisplayService:
)
return id
def operation_image(self, device_id, result):
result = dict(result or {})
asset_id = result.get("rendered_asset_id") or result.get("asset_id")
if not asset_id and result.get("image_id") is not None:
# Cloud cleanup removes image_sources; the original upload receipt survives.
row = self.db.one(
"SELECT result FROM operations WHERE device_id=? "
"AND json_extract(result,'$.image_id')=? "
"AND json_extract(result,'$.rendered_asset_id') IS NOT NULL "
"ORDER BY created_at DESC LIMIT 1",
(device_id, result["image_id"]),
)
if row:
original = json.loads(row["result"])
asset_id = original["rendered_asset_id"]
result.setdefault("image_name", original.get("image_name"))
if asset_id:
result.setdefault("asset_id", asset_id)
asset = self.db.one("SELECT * FROM assets WHERE id=?", (asset_id,))
if asset:
result["image_name"] = result.get("image_name") or asset["name"]
if self.assets.path(asset_id).is_file():
result["preview_url"] = f"/api/v1/assets/{asset_id}"
return result or None
def operation(self, id):
op = self.db.one("SELECT * FROM operations WHERE id=?", (id,))
if not op:
@@ -162,7 +187,9 @@ class DisplayService:
device_id=op["device_id"],
created_at=op["created_at"],
updated_at=op["updated_at"],
result=json.loads(op["result"]) if op["result"] else None,
result=self.operation_image(
op["device_id"], json.loads(op["result"]) if op["result"] else None
),
error=json.loads(op["error"]) if op["error"] else None,
request_id=op["request_id"],
client_id=op["client_id"],
@@ -172,9 +199,12 @@ class DisplayService:
def update(self, id, status, result=None, error=None):
if result:
op = self.db.one("SELECT device_id FROM operations WHERE id=?", (id,))
result = self.operation_image(op["device_id"], result)
result.pop("preview_url", None)
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 +548,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 +872,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 +900,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 +909,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 +930,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:
+4 -2
View File
@@ -168,6 +168,8 @@ class ErrorResponse(BaseModel):
class OperationResult(BaseModel):
image_name: str | None = None
preview_url: str | None = None
cover_id: int | None = None
photo_attempts: list[dict[str, str]] = Field(default_factory=list)
image_id: int | None = None
@@ -366,7 +368,7 @@ class SettingsView(BaseModel):
class Health(BaseModel):
status: str
version: str = "0.1.20"
version: str = "0.1.24"
class Readiness(BaseModel):
@@ -397,7 +399,7 @@ class RestoreRequest(StrictModel):
class Version(BaseModel):
product: str = "PIGWay Frame Flow"
version: str = "0.1.20"
version: str = "0.1.24"
api_version: str = "v1"
commit: str = "development"
build_time: str = "unknown"
+9 -1
View File
@@ -38,6 +38,14 @@ window.addEventListener('resize',revealCurrentNavigation);
function deviceNameForId(id){return deviceDisplayName(state.devices.find(d=>d.id===id)||{id,name:id});}
function operationActionLabel(action){const labels={'render':['生成预览','Generate preview'],'render-ai':['AI 生成图片','Generate AI image'],'official-widget':['更新官方订阅','Update official widget'],'delete':['删除图片','Delete image'],'display':['显示图片','Display image'],'restore':['恢复图片','Restore image'],'local-upload':['保存到图库','Save to gallery'],'reconcile':['核对状态','Reconcile']};return labels[action]?.[state.lang==='zh'?0:1]||t('operation');}
function operationClientLabel(client){return client==='admin'?tr('管理员','Administrator'):client==='scheduler'?tr('自动调度','Scheduler'):client;}
function operationResultHTML(o){
const r=o.result||{},parts=[];
if(r.image_name)parts.push(`<div>${esc(r.image_name)}</div>`);
if(r.image_id!=null)parts.push(`<small>${t('cloudImageId')}: ${esc(r.image_id)}</small>`);
else if(r.asset_id||r.rendered_asset_id)parts.push(`<small>${t('localAssetId')}: ${esc(r.asset_id||r.rendered_asset_id)}</small>`);
if(o.error)parts.push(`<div class="history-status bad">${esc(operationErrorMessage(o.error))}</div>`);
return parts.join('')||'—';
}
function operationErrorLabel(error){if(!error?.code)return '';const label=t(error.code);return label===error.code?t('error'):label;}
function deviceDisplayName(device){const name=device.name||device.id;return device.id==='living-room'&&['living-room','Living room'].includes(name)?t('livingRoom'):name;}
function shell(){document.documentElement.lang=state.lang==='zh'?'zh-CN':'en';$('#lang').textContent=state.lang==='zh'?'EN':'中文';$('#theme').title=t('theme');$('#nav').innerHTML=Object.entries(icons).map(([id,icon])=>`<button data-page="${id}" class="${id===state.page?'active':''}" ${id===state.page?'aria-current="page"':''}><span class="nav-icon">${icon}</span><span>${t(id)}</span></button>`).join('');document.querySelectorAll('[data-page]').forEach(b=>b.onclick=()=>navigate(b.dataset.page));$('#page-title').textContent=t(state.page);$('#device').innerHTML=(state.devices.length?state.devices:[{id:state.device,name:state.device}]).map(d=>`<option value="${esc(d.id)}" ${d.id===state.device?'selected':''}>${esc(deviceDisplayName(d))}</option>`).join('');revealCurrentNavigation();}
@@ -143,7 +151,7 @@ const views={
async upload(){state.preview=null;$('#main').innerHTML=notice()+`<div class="notice">${t('uploadNotice')}</div><div class="columns"><section class="card"><h2>${t('upload')}</h2><div class="dropzone"><strong>${t('selectFile')}</strong><p class="hint">${t('uploadHint')}</p><input id="file" type="file" accept="image/jpeg,image/png,image/webp" aria-label="${t('selectFile')}"></div><div class="form-grid" style="margin-top:24px"><div class="field"><label for="fit">${t('mode')}</label><select id="fit">${['contain','cover','smart'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div><div class="field"><label for="profile">${t('profile')}</label><select id="profile">${['color','grayscale'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div></div>${buttons('make-preview','preview',true)}<p class="hint">${t('previewHint')}</p></section><section class="card"><h3>${t('preview')}</h3><div id="preview-box" class="empty">${t('previewHint')}</div><div class="actions" style="margin-top:18px"><button id="save-local" disabled>${t('staging')}</button><button id="display-upload" class="primary" disabled>${t('display')}</button></div></section></div>`;bind('make-preview',async()=>{const file=$('#file').files[0];if(!file)throw new Error(t('selectFile'));const form=new FormData();form.append('file',file);form.append('fit',$('#fit').value);form.append('profile',$('#profile').value);state.preview=await api('/api/v1/preview',{method:'POST',body:form});$('#preview-box').className='';$('#preview-box').innerHTML='<img id="upload-preview" class="preview" alt="Preview">';await blobImage($('#upload-preview'),state.preview.url);$('#save-local').disabled=false;$('#display-upload').disabled=!liveWriteAllowed();});for(const id of ['file','fit','profile'])$('#'+id).onchange=()=>{state.preview=null;$('#save-local').disabled=true;$('#display-upload').disabled=true;};bind('save-local',async()=>{const id=await accepted('/api/v1/devices/'+state.device+'/upload',{type:'image',asset_id:state.preview.asset_id});await waitOperation(id);state.tab='local';return navigate('gallery');});bind('display-upload',()=>displayDialog({type:'image',asset_id:state.preview.asset_id}));},
async templates(){state.templates=await api('/api/v1/templates');const selected=state.templates.find(x=>x.id===state.selectedTemplate)||state.templates[0];state.selectedTemplate=selected.id;if(state.pendingTemplateData?.template===selected.id){selected.defaults=state.pendingTemplateData.data;state.initialProfile=state.pendingTemplateData.profile;state.pendingTemplateData=null;}state.templatePreview=null;$('#main').innerHTML=notice()+`<div class="notice">${t('templateHint')}<p>${t('templateSteps')}</p></div><div class="template-grid">${state.templates.map((x,i)=>`<div class="card template-card ${x.id===selected.id?'selected':''}" role="button" tabindex="0" data-template="${x.id}"><span>${{'daily-poetry':'詩','calendar':'▦','daily-dashboard':'☀','photo-text':'▣','quote':'❝','system-alert':'◒','weather':'☁'}[x.id]||'▦'}</span><div><h3>${esc(x.name.split(' / ')[state.lang==='zh'?1:0]||x.name)}</h3><small>1200 × 1600 · ${esc(x.id)}</small></div></div>`).join('')}</div><div class="columns"><section class="card"><h3>${t('templateData')}</h3><p class="hint">${t('templateGuideTitle')}</p><p class="hint">${t({'photo-text':'guidePhotoText','daily-dashboard':'guideDashboard',weather:'guideWeather',calendar:'guideCalendar',quote:'guideQuote','daily-poetry':'guidePoetry','system-alert':'guideAlert'}[selected.id]||'templateContentHelp')}</p>${['weather','daily-dashboard'].includes(selected.id)?`<p class="hint">${t('templateWeatherHelp')}</p>`:''}<textarea id="template-data" aria-label="${t('templateData')}">${esc(JSON.stringify(selected.defaults,null,2))}</textarea><div class="field" style="margin-top:16px"><label for="template-profile">${t('profile')}</label><select id="template-profile"><option value="color">${t('color')}</option><option value="grayscale">${t('grayscale')}</option></select><p class="hint">${t('helpProfile')}</p></div><p class="hint">${t('templateActionHelp')}</p><p class="hint">${t('templateAutomationHelp')}</p><div class="actions">${buttons('render-template','render',true)}${buttons('save-template','saveTemplate')}</div></section><section class="card"><h3>${t('preview')}</h3><div id="template-preview" class="empty">${t('templatePreviewHint')}</div><button id="display-template" class="primary" style="margin-top:18px" disabled>${t('display')}</button></section></div>`;document.querySelectorAll('[data-template]').forEach(b=>{b.onclick=()=>{state.selectedTemplate=b.dataset.template;navigate(['collage','poster'].includes(b.dataset.template)?'studio':undefined);};b.onkeydown=e=>{if(e.key==='Enter')b.click();};});bind('render-template',async()=>{const content={type:'template',template:selected.id,data:JSON.parse($('#template-data').value),profile:$('#template-profile').value};if([...document.querySelectorAll('#template-fields textarea'),$('#template-data')].some(x=>!x.checkValidity())){toast(t('INVALID_REQUEST'));return;}const id=await accepted('/api/v1/render',content);$('#template-preview').textContent=t('previewLoading');const op=await waitOperation(id);state.templatePreview=op.result.asset_id;$('#template-preview').className='';$('#template-preview').innerHTML='<img id="template-image" class="preview" alt="Template preview">';await blobImage($('#template-image'),op.result.url);$('#display-template').disabled=!liveWriteAllowed();});bind('save-template',async()=>{await api('/api/v1/templates/'+selected.id+'/config',{method:'PUT',body:{data:JSON.parse($('#template-data').value)}});toast(t('done'));});$('#template-data').oninput=()=>$('#display-template').disabled=true;$('#template-profile').onchange=()=>$('#display-template').disabled=true;bind('display-template',()=>displayDialog({type:'image',asset_id:state.templatePreview}));await templateEditor(selected);if(state.initialProfile){$('#template-profile').value=state.initialProfile;state.initialProfile=null;}},
async playlists(epoch){const playlists=await api('/api/v1/playlists');if(epoch!==state.epoch)return;$('#main').innerHTML=notice()+`<div class="notice">${t('playlistNotice')}</div><div class="section-heading"><h2>${t('playlists')}</h2>${buttons('new-playlist','newPlaylist',true)}</div><div class="gallery">${playlists.map((p,i)=>`<section class="card"><span class="badge">${t(p.enabled?'enabled':'disabled')}</span><h3 style="margin-top:16px">${esc(p.name)}</h3><p class="hint">${p.image_ids.length} ${t('count')} · ${p.interval_minutes} ${t('minutes')} · ${t(p.mode)}</p><p><code>${esc(p.image_ids.join(' → '))}</code></p><small>${t('lastScheduled')}: ${p.position>=0?esc(p.image_ids[p.position]):'—'}</small><div class="actions" style="margin-top:16px"><button data-pl-edit="${i}">${t('edit')}</button><button class="danger" data-pl-delete="${i}">${t('remove')}</button></div></section>`).join('')||`<div class="card empty"><strong>${t('noTasks')}</strong></div>`}</div>`;bind('new-playlist',()=>playlistEditor());document.querySelectorAll('[data-pl-edit]').forEach(b=>b.onclick=()=>playlistEditor(playlists[b.dataset.plEdit]));document.querySelectorAll('[data-pl-delete]').forEach(b=>b.onclick=()=>confirmDialog(t('removeConfirm'),async()=>{await api('/api/v1/playlists/'+playlists[b.dataset.plDelete].id,{method:'DELETE'});await navigate();}));},
async history(epoch){const ops=await api('/api/v1/operations');if(epoch!==state.epoch)return;$('#main').innerHTML=`<div class="section-heading"><h2>${t('history')}</h2>${buttons('history-refresh','refresh')}</div>${ops.some(x=>x.status==='UNKNOWN')?`<div class="notice warning">${t('unknown')}</div>`:''}<div class="card table-wrap">${ops.length?`<table><thead><tr>${['created','action','status','result','client','requestId'].map(x=>`<th>${t(x)}</th>`).join('')}</tr></thead><tbody>${ops.map((o,i)=>`<tr><td>${fmt(o.created_at)}</td><td>${esc(operationActionLabel(o.action))}<br><small>${esc(deviceNameForId(o.device_id))}</small></td><td><span class="history-status ${['FAILED','UNKNOWN'].includes(o.status)?'bad':''}">${t(o.status)}</span>${o.status==='UNKNOWN'?`<div><button data-reconcile="${i}">${t('reconcile')}</button></div>`:''}</td><td>${esc(o.result?.image_id||o.result?.asset_id||operationErrorLabel(o.error)||'—')}</td><td>${esc(operationClientLabel(o.client_id))}</td><td><code>${esc(o.request_id)}<br>${esc(o.operation_id)}</code></td></tr>`).join('')}</tbody></table>`:`<div class="empty">${t('noHistory')}</div>`}</div>`;bind('history-refresh',()=>navigate());document.querySelectorAll('[data-reconcile]').forEach(b=>b.onclick=busy(async()=>{const o=ops[b.dataset.reconcile];const r=await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:false}});showModal(`<h2>${t('reconcile')}</h2><p>${t('current')}: ${esc(r.current_image_id)}</p><p>${t('result')}: ${esc(r.expected_image_id)}</p><p class="hint">${t('unknown')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}${r.resolved?'':buttons('acknowledge','acknowledge')}</div>`);bind('acknowledge',async()=>{await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:true}});$('#modal').close();await navigate();});}));},
async history(epoch){const ops=await api('/api/v1/operations');if(epoch!==state.epoch)return;$('#main').innerHTML=`<div class="section-heading"><h2>${t('history')}</h2>${buttons('history-refresh','refresh')}</div>${ops.some(x=>x.status==='UNKNOWN')?`<div class="notice warning">${t('unknown')}</div>`:''}<div class="card table-wrap">${ops.length?`<table><thead><tr>${['created','action','status','result','client','requestId'].map(x=>`<th>${t(x)}</th>`).join('')}</tr></thead><tbody>${ops.map((o,i)=>`<tr data-operation-id="${esc(o.operation_id)}"><td>${fmt(o.created_at)}</td><td>${esc(operationActionLabel(o.action))}<br><small>${esc(deviceNameForId(o.device_id))}</small></td><td><span class="history-status ${['FAILED','UNKNOWN'].includes(o.status)?'bad':''}">${t(o.status)}</span>${o.status==='UNKNOWN'?`<div><button data-reconcile="${i}">${t('reconcile')}</button></div>`:''}</td><td>${operationResultHTML(o)}</td><td>${esc(operationClientLabel(o.client_id))}</td><td><code>${esc(o.request_id)}<br>${esc(o.operation_id)}</code></td></tr>`).join('')}</tbody></table>`:`<div class="empty">${t('noHistory')}</div>`}</div>`;bind('history-refresh',()=>navigate());document.querySelectorAll('[data-reconcile]').forEach(b=>b.onclick=busy(async()=>{const o=ops[b.dataset.reconcile];const r=await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:false}});showModal(`<h2>${t('reconcile')}</h2><p>${t('current')}: ${esc(r.current_image_id)}</p><p>${t('result')}: ${esc(r.expected_image_id)}</p><p class="hint">${t('unknown')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}${r.resolved?'':buttons('acknowledge','acknowledge')}</div>`);bind('acknowledge',async()=>{await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:true}});$('#modal').close();await navigate();});}));},
async settings(){state.settings=await api('/api/v1/settings');const s=state.settings;$('#main').innerHTML=notice()+`<div class="columns settings-columns"><section class="card"><h3>${t('settings')}</h3><div class="form-grid settings-preferences"><div class="field"><label for="theme-select">${t('theme')}</label><select id="theme-select">${['system','light','dark'].map(x=>`<option value="${x}" ${state.theme===x?'selected':''}>${t(x==='system'?'systemTheme':x)}</option>`).join('')}</select></div><div class="field"><label for="language-select">${t('language')}</label><select id="language-select"><option value="zh" ${state.lang==='zh'?'selected':''}>简体中文</option><option value="en" ${state.lang==='en'?'selected':''}>English</option></select></div></div><p class="hint">${t('tokenHint')}</p>${buttons('logout','logout')}</section><section class="card"><h3>${t('auth')}</h3><div class="detail-grid">${detail('version',s.version)}${detail('writeProtection',t(!s.write_enabled?'yes':'no'))}${detail('keyConfigured',t(s.api_key_configured?'yes':'no'))}${detail('timezone',s.timezone)}</div><p class="hint">${t('refreshInterval')}</p><a href="/docs" target="_blank" rel="noopener">${t('apiDocs')} →</a><p class="hint">GET /openapi.json · /api/v1</p></section></div>`;$('#theme-select').onchange=()=>{state.theme=$('#theme-select').value;localStorage.setItem('ff-theme',state.theme);applyTheme();};$('#language-select').onchange=()=>{state.lang=$('#language-select').value;localStorage.setItem('ff-language',state.lang);navigate();};bind('logout',async()=>{await api('/api/v1/auth/logout',{method:'POST'});forgetLogin();shell();login();});accountSettings();}
};
function jobPayload(j,changes={}){return {name:j.name,enabled:j.enabled,device_id:j.device_id,schedule:j.schedule,source:j.source,display_duration:j.display_duration,restore_previous:j.restore_previous,priority:j.priority,cleanup_unstarred:j.cleanup_unstarred||false,...changes};}
+1 -1
View File
@@ -45,7 +45,7 @@ async function weatherSettings(){
result(weather);$('#weather-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/settings/weather',{method:'PUT',body:{provider:$('#weather-provider').value,location:$('#weather-location').value,latitude:Number($('#weather-latitude').value),longitude:Number($('#weather-longitude').value)}});result(await api('/api/v1/providers/weather'));toast(t('done'));});
}
async function enhanceHistory(){
const table=document.querySelector('#main table');if(!table)return;table.classList.add('history-table');const heading=document.createElement('th');heading.textContent=t('operation');table.querySelector('thead tr').append(heading);const rows=[...table.querySelectorAll('tbody tr')];const ops=await api('/api/v1/operations');rows.forEach((row,i)=>{const op=ops[i];if(!op)return;const button=document.createElement('button');button.textContent=t('details');const actions=document.createElement('td');actions.className='history-actions';actions.append(button);row.append(actions);button.onclick=busy(async()=>{const current=await api('/api/v1/operations/'+op.operation_id);showModal(`<h2>${t('details')}</h2><p>${t(current.status)} · ${esc(operationActionLabel(current.action))}</p><p>${esc(operationErrorLabel(current.error)||current.error?.message||'')}</p><pre style="white-space:pre-wrap;overflow-wrap:anywhere">${esc(JSON.stringify({action:current.action,device_id:current.device_id,error_code:current.error?.code,operation_id:current.operation_id,request_id:current.request_id,created_at:current.created_at,updated_at:current.updated_at,result:current.result},null,2))}</pre>${current.result?.url?'<img id="operation-preview" class="preview" alt="Preview">':''}<div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);if(current.result?.url)await blobImage($('#operation-preview'),current.result.url);});});}
const table=document.querySelector('#main table');if(!table)return;table.classList.add('history-table');const heading=document.createElement('th');heading.textContent=t('operation');table.querySelector('thead tr').append(heading);const rows=[...table.querySelectorAll('tbody tr')];rows.forEach(row=>{const operationId=row.dataset.operationId;if(!operationId)return;const button=document.createElement('button');button.textContent=t('details');const actions=document.createElement('td');actions.className='history-actions';actions.append(button);row.append(actions);button.onclick=busy(async()=>{const current=await api('/api/v1/operations/'+operationId);showModal(`<h2>${t('details')}</h2><p>${t(current.status)} · ${esc(operationActionLabel(current.action))}</p>${operationResultHTML(current)}<pre style="white-space:pre-wrap;overflow-wrap:anywhere">${esc(JSON.stringify({action:current.action,device_id:current.device_id,error_code:current.error?.code,operation_id:current.operation_id,request_id:current.request_id,created_at:current.created_at,updated_at:current.updated_at,result:current.result},null,2))}</pre>${(current.result?.preview_url||current.result?.url)?'<img id="operation-preview" class="preview" alt="Preview">':''}<div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);if(current.result?.preview_url||current.result?.url)await blobImage($('#operation-preview'),current.result.preview_url||current.result.url);});});}
async function enhanceAutomation(){
const jobs=await api('/api/v1/jobs');const rows=[...document.querySelectorAll('tbody tr')];rows.forEach((row,i)=>{const job=jobs[i];if(!job)return;const actions=row.querySelector('.row-actions');const run=document.createElement('button');run.textContent=t('runNow');run.disabled=!liveWriteAllowed();run.title=run.disabled?t('readOnlyWarning'):'';actions.append(run);run.onclick=()=>confirmDialog(t('runConfirm'),async()=>{await accepted('/api/v1/jobs/'+job.id+'/run',{});await navigate('history');});if(['template','auto_collage'].includes(job.source.type)){const preview=document.createElement('button');preview.textContent=t('jobPreview');actions.append(preview);preview.onclick=busy(async()=>{const id=await accepted('/api/v1/jobs/'+job.id+'/preview',{});const op=await waitOperation(id);showModal(`<h2>${t('jobPreview')}</h2><img id="job-preview-image" class="preview" alt="Preview"><div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);await blobImage($('#job-preview-image'),op.result.url);});}});
}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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.19</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=photo-quality-v0117"></script><script src="/ui/features.js?v=photo-quality-v0117"></script><script src="/ui/extensions.js?v=photo-quality-v0117"></script><script src="/ui/app.js?v=photo-quality-v0117"></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.24</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.24"></script><script src="/ui/features.js?v=0.1.24"></script><script src="/ui/extensions.js?v=0.1.24"></script><script src="/ui/app.js?v=0.1.24"></script></body></html>
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "pigway-frame-flow"
version = "0.1.20"
version = "0.1.24"
readme = "README.md"
license = {file = "LICENSE"}
requires-python = ">=3.14,<3.15"
+5 -1
View File
@@ -6,5 +6,9 @@ cd "$(dirname "$0")/.."
DOCKER_BIN=${DOCKER_BIN:-docker}
"$DOCKER_BIN" info >/dev/null
"$DOCKER_BIN" compose config --quiet
"$DOCKER_BIN" compose build --pull
set -- --pull
if [ -n "${CI_PYTHON_BASE:-}" ]; then
set -- "$@" --build-arg "PYTHON_BASE=$CI_PYTHON_BASE"
fi
"$DOCKER_BIN" compose build "$@"
"$DOCKER_BIN" image inspect "${FRAME_FLOW_IMAGE:-pigway-frame-flow:latest}" --format 'image={{.Id}} size_bytes={{.Size}} architecture={{.Architecture}}'
+1 -3
View File
@@ -375,9 +375,7 @@ class Deploy:
"DOCKER_PIP_FALLBACK_INDEX_URL"
)
or "https://pypi.org/simple",
"DEBIAN_MIRROR": self.env.get("DEBIAN_MIRROR") or "deb.debian.org",
"DEBIAN_SECURITY_MIRROR": self.env.get("DEBIAN_SECURITY_MIRROR")
or "deb.debian.org",
"ALPINE_MIRROR": self.env.get("ALPINE_MIRROR") or "dl-cdn.alpinelinux.org",
"BUILD_COMMIT": commit,
"BUILD_TIME": now,
}.items():
+60 -14
View File
@@ -10,6 +10,7 @@ import shutil
import sqlite3
import subprocess
import time
import tomllib
import urllib.request
from pathlib import Path
@@ -126,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"
@@ -139,9 +182,6 @@ def release(lock):
before = inspect()
old_env = dict(s.split("=", 1) for s in before["Config"]["Env"])
verify_runtime(old_env["BUILD_COMMIT"])
assert run("docker", "exec", CONTAINER, "cat", "/app/requirements.lock") == (
(ctx / "requirements.lock").read_text().strip()
), "Dependencies differ; full build required"
stamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%SZ")
backup = RECOVERY / ("release-" + stamp + "-" + commit[:12])
backup.mkdir(mode=0o700)
@@ -153,34 +193,40 @@ def release(lock):
with sqlite3.connect(backup / "frameflow.before.db") as dst:
src.backup(dst)
assert dst.execute("pragma quick_check").fetchone()[0] == "ok"
base = "pigway-frame-flow:release-base-" + commit[:12]
run("docker", "image", "tag", before["Image"], base)
image = "pigway-frame-flow:0.1.19-" + commit[:12]
try:
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]
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=false",
"--network=none",
"--pull",
"-f",
str(ctx / "deploy/Dockerfile.runtime-update"),
"--build-arg",
"BASE_IMAGE=" + base,
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),
)
finally:
run("docker", "image", "rm", base)
compose = json.loads((APP / "compose.json").read_text())
service = compose["services"][CONTAINER]
service["image"] = image
service["environment"]["BUILD_COMMIT"] = commit
service["environment"]["BUILD_TIME"] = stamp
labels = service.get("labels") or {}
if isinstance(labels, list):
labels = {item.partition("=")[0]: item.partition("=")[2] for item in labels}
service["labels"] = {
**labels,
"org.opencontainers.image.version": version,
"org.opencontainers.image.revision": commit,
}
changed = False
try:
changed = True
+3 -2
View File
@@ -4,6 +4,7 @@ const root=path.join(__dirname,'../pigway_frame_flow/web');
const p=await b.newPage({viewport:{width,height:900}});let errors=[];p.on('pageerror',e=>errors.push(e.message));let saved;
await p.addInitScript(lang=>localStorage.setItem('ff-language',lang),lang);
await p.route('**/*',async r=>{const u=new URL(r.request().url());if(u.pathname.startsWith('/api/')){
if(u.pathname==='/api/v1/assets/fixture')return r.fulfill({body:Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jBz8AAAAASUVORK5CYII=','base64'),contentType:'image/png'});
if(['PUT','POST'].includes(r.request().method())){saved=r.request().postDataJSON();return r.fulfill({json:{}})}
if(['/api/v1/jobs','/api/v1/assets','/api/v1/devices/living-room/images'].includes(u.pathname))return r.fulfill({json:[]});
if(u.pathname==='/api/v1/photo-sources')return r.fulfill({json:[]});
@@ -12,6 +13,6 @@ const root=path.join(__dirname,'../pigway_frame_flow/web');
return r.fulfill({status:401,json:{error:{code:'UNAUTHORIZED',message:'Sign in required'}}});}
const f=u.pathname==='/'?'index.html':u.pathname.replace(/^\/ui\//,'');return r.fulfill({body:fs.readFileSync(path.join(root,f)),contentType:f.endsWith('.js')?'application/javascript':f.endsWith('.css')?'text/css':'text/html'});});
await p.goto('http://frame.test/');await p.locator('#login-form').waitFor();
await p.evaluate(async()=>{const previous=api;api=async()=>[{operation_id:'fixture',action:'display',device_id:'living-room',status:'FAILED',client_id:'scheduler',error:{code:'WRITE_DISABLED'},request_id:'fixture'}];state.devices=[{id:'living-room',name:'living-room'}];try{await views.history(state.epoch);}finally{api=previous;}});
const text=await p.locator('#main').innerText();assert(!text.includes('living-room'));assert(!text.includes('WRITE_DISABLED'));assert(text.includes(lang==='zh'?'客厅':'Living room'));assert(text.includes(lang==='zh'?'显示图片':'Display image'));assert(text.includes(lang==='zh'?'自动调度':'Scheduler'));assert.equal(errors.length,0);console.log('PASS history',width,lang);await p.close();
await p.evaluate(async()=>{const op={operation_id:'fixture',action:'display',device_id:'living-room',status:'FAILED',client_id:'scheduler',result:{image_id:999,image_name:'example <photo>.jpg',preview_url:'/api/v1/assets/fixture'},error:{code:'WRITE_DISABLED'},request_id:'fixture'};api=async(path,options)=>options?.binary?(await fetch(path)).blob():path.endsWith('/fixture')?op:[op];state.devices=[{id:'living-room',name:'living-room'}];document.body.classList.remove('login-mode');shell();await views.history(state.epoch);await enhanceHistory();});
const text=await p.locator('#main').innerText();assert(!text.includes('living-room'));assert(!text.includes('WRITE_DISABLED'));assert(text.includes('example <photo>.jpg'));assert(text.includes('999'));assert(text.includes(lang==='zh'?'云端图片 ID':'Cloud image ID'));assert(await p.locator('td div.bad').count()===1);assert(text.includes(lang==='zh'?'客厅':'Living room'));assert(text.includes(lang==='zh'?'显示图片':'Display image'));assert(text.includes(lang==='zh'?'自动调度':'Scheduler'));await p.locator('.history-actions button').click();await p.locator('#operation-preview').waitFor();assert(await p.locator('#operation-preview').evaluate(img=>img.complete&&img.naturalWidth>0));assert(await p.locator('#modal-content').innerText().then(x=>x.includes('example <photo>.jpg')));assert(await p.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));assert.equal(errors.length,0);console.log('PASS history',width,lang);await p.close();
}}finally{await b.close()}})().catch(e=>{console.error(e);process.exit(1)});
+22
View File
@@ -690,6 +690,28 @@
},
"OperationResult": {
"properties": {
"image_name": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Image Name"
},
"preview_url": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Preview Url"
},
"cover_id": {
"anyOf": [
{
+62
View File
@@ -0,0 +1,62 @@
from io import BytesIO
from PIL import Image
from pigway_frame_flow.models import DisplayRequest
def test_history_image_survives_cloud_cleanup(client, service):
data = BytesIO()
Image.new("RGB", (40, 60), "blue").save(data, "JPEG")
asset = service.assets.put(data.getvalue(), "example.jpg", "images")
request = DisplayRequest(content={"type": "image", "asset_id": asset["asset_id"]})
op = service.submit(
"display", request.model_dump(mode="json"), "living-room", "admin", "fixture"
)
service.process_one()
service.sync()
result = service.operation(op)["result"]
image_id = result["image_id"]
# Simulate an older receipt without the new name snapshot, then cloud cleanup.
service.db.execute(
"UPDATE operations SET result=? WHERE id=?",
(
'{"image_id":%d,"rendered_asset_id":"%s"}' % (image_id, asset["asset_id"]),
op,
),
)
service.db.execute("DELETE FROM image_sources WHERE device_id=?", ("living-room",))
deletion = service.submit(
"delete",
{"image_id": image_id, "confirm_current": True},
"living-room",
"admin",
"delete-fixture",
)
service.update(deletion, "COMPLETED", {"image_id": image_id})
receipt = client.get("/api/v1/operations/" + deletion).json()["result"]
assert receipt["image_name"] == "example.jpg"
assert client.get(receipt["preview_url"]).status_code == 200
service.assets.path(asset["asset_id"]).unlink()
service.db.execute("DELETE FROM assets WHERE id=?", (asset["asset_id"],))
receipt = client.get("/api/v1/operations/" + deletion).json()["result"]
assert receipt["image_name"] == "example.jpg"
assert receipt["preview_url"] is None
def test_history_missing_local_image_does_not_break_list(client, service):
op = service.submit("delete", {"image_id": 999}, "living-room", "admin", "fixture")
service.update(
op,
"FAILED",
{"image_id": 999},
{
"code": "DEVICE_OFFLINE",
"message": "Device offline",
"request_id": "fixture",
},
)
receipt = client.get("/api/v1/operations/" + op).json()
assert receipt["result"]["image_id"] == 999
assert receipt["result"]["preview_url"] is None
assert receipt["error"]["code"] == "DEVICE_OFFLINE"
+185 -1
View File
@@ -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()]
+2
View File
@@ -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)
+50
View File
@@ -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
+92
View File
@@ -0,0 +1,92 @@
This Font Software is licensed under the SIL Open Font License,
Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font
creation efforts of academic and linguistic communities, and to
provide a free and open framework in which fonts may be shared and
improved in partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply to
any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software
components as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to,
deleting, or substituting -- in part or in whole -- any of the
components of the Original Version, by changing formats or by porting
the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed,
modify, redistribute, and sell modified and unmodified copies of the
Font Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components, in
Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the
corresponding Copyright Holder. This restriction only applies to the
primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created using
the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.