From 4e3a6c8032892457874b652a026d2fa72f288e17 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:47:06 +0800 Subject: [PATCH] perf(docker): isolate high-frequency image payloads (#6365) --- .dockerignore | 31 +++++- .github/workflows/beta.yml | 41 +++++++ .github/workflows/build-v3.yml | 40 +++++++ .gitignore | 5 +- docker/Dockerfile | 152 ++++++++++++++++++++------ frontend-dist/.gitkeep | 1 + tests/test_database_backup_docker.py | 8 +- tests/test_docker_bootstrap.py | 11 +- tests/test_docker_payload_contract.py | 135 +++++++++++++++++++++++ 9 files changed, 376 insertions(+), 48 deletions(-) create mode 100644 frontend-dist/.gitkeep create mode 100644 tests/test_docker_payload_contract.py diff --git a/.dockerignore b/.dockerignore index 7787a1adb..844a38d0b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -10,14 +10,17 @@ LICENSE # Development files .pylintrc -*.pyc -__pycache__/ -*.pyo -*.pyd +**/*.pyc +**/__pycache__/ +**/*.pyo +**/*.pyd .Python -*.so +**/*.so .pytest_cache/ +.ruff_cache/ .coverage +coverage.json +coverage.xml htmlcov/ .tox/ .nox/ @@ -27,12 +30,30 @@ htmlcov/ dmypy.json # Virtual environments +.venv/ +.worktrees/ venv/ env/ ENV/ env.bak/ venv.bak/ +# Runtime state and locally synchronized payloads +.build/ +.agent-work/ +.runtime/ +.tmp/ +node_modules/ +public/ +.moviepilot.env +.env +.env.* +config/* +!config/category.yaml +app/plugins/** +!app/plugins/__init__.py +app/application/site/*.bin + # IDE .vscode/ .idea/ diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml index b8450c804..7757c0e4b 100644 --- a/.github/workflows/beta.yml +++ b/.github/workflows/beta.yml @@ -26,7 +26,34 @@ jobs: id: release_version run: | app_version=$(cat version.py |sed -ne "s/APP_VERSION\s=\s'v\(.*\)'/\1/gp") + frontend_version=$(sed -ne "s/FRONTEND_VERSION\s*=\s*'\([^']*\)'/\1/gp" version.py) echo "app_version=$app_version" >> $GITHUB_ENV + echo "SOURCE_COMMIT=$(git rev-parse HEAD)" >> $GITHUB_ENV + echo "frontend_version=$frontend_version" >> "$GITHUB_OUTPUT" + + - name: Resolve External Payload Revisions + id: payloads + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + FRONTEND_VERSION: ${{ steps.release_version.outputs.frontend_version }} + run: | + plugins_revision=$(git ls-remote https://github.com/jxxghp/MoviePilot-Plugins.git refs/heads/main | awk '{print $1}') + resources_revision=$(git ls-remote https://github.com/jxxghp/MoviePilot-Resources.git refs/heads/main | awk '{print $1}') + frontend_digest=$(gh api "repos/jxxghp/MoviePilot-Frontend/releases/tags/${FRONTEND_VERSION}" \ + --jq '.assets[] | select(.name == "dist.zip") | .digest') + + [[ "$plugins_revision" =~ ^[0-9a-f]{40}$ ]] + [[ "$resources_revision" =~ ^[0-9a-f]{40}$ ]] + case "$frontend_digest" in + sha256:*) frontend_sha256=${frontend_digest#sha256:} ;; + *) echo "dist.zip 缺少 SHA-256 摘要" >&2; exit 1 ;; + esac + [[ "$frontend_sha256" =~ ^[0-9a-f]{64}$ ]] + + echo "plugins_revision=$plugins_revision" >> "$GITHUB_OUTPUT" + echo "resources_revision=$resources_revision" >> "$GITHUB_OUTPUT" + echo "frontend_digest=$frontend_digest" >> "$GITHUB_OUTPUT" + echo "frontend_sha256=$frontend_sha256" >> "$GITHUB_OUTPUT" - name: Checkout Wiki Plugin Market uses: actions/checkout@v4 @@ -48,6 +75,7 @@ jobs: echo "wiki_commit=$wiki_commit" >> "$GITHUB_OUTPUT" - name: Download models.dev catalog + id: models_catalog run: | temp_file=$(mktemp app/agent/llm/models.json.XXXXXX) trap 'rm -f "$temp_file"' EXIT @@ -58,6 +86,7 @@ jobs: # Git keeps only a small placeholder; the beta image receives the current catalog. chmod 644 "$temp_file" mv "$temp_file" app/agent/llm/models.json + echo "digest=sha256:$(sha256sum app/agent/llm/models.json | awk '{print $1}')" >> "$GITHUB_OUTPUT" echo "Downloaded models.dev catalog ($(wc -c < app/agent/llm/models.json) bytes)" - name: Docker Meta @@ -99,8 +128,20 @@ jobs: linux/arm64/v8 push: true tags: ${{ steps.meta.outputs.tags }} + build-args: | + MOVIEPILOT_FRONTEND_VERSION=${{ steps.release_version.outputs.frontend_version }} + MOVIEPILOT_FRONTEND_SHA256=${{ steps.payloads.outputs.frontend_sha256 }} + MOVIEPILOT_PLUGINS_REF=${{ steps.payloads.outputs.plugins_revision }} + MOVIEPILOT_RESOURCES_REF=${{ steps.payloads.outputs.resources_revision }} labels: | ${{ steps.meta.outputs.labels }} + org.opencontainers.image.revision=${{ env.SOURCE_COMMIT }} + org.moviepilot.source-revision=${{ env.SOURCE_COMMIT }} + org.moviepilot.frontend-version=${{ steps.release_version.outputs.frontend_version }} + org.moviepilot.frontend-digest=${{ steps.payloads.outputs.frontend_digest }} + org.moviepilot.plugins-revision=${{ steps.payloads.outputs.plugins_revision }} + org.moviepilot.resources-revision=${{ steps.payloads.outputs.resources_revision }} org.moviepilot.plugin-market-wiki-revision=${{ steps.plugin_market.outputs.wiki_commit }} + org.moviepilot.models-catalog-digest=${{ steps.models_catalog.outputs.digest }} cache-from: type=gha,scope=moviepilot-v3-docker,version=2 cache-to: type=gha,scope=moviepilot-v3-docker,mode=max,version=2 diff --git a/.github/workflows/build-v3.yml b/.github/workflows/build-v3.yml index e331c0932..8f7ada043 100644 --- a/.github/workflows/build-v3.yml +++ b/.github/workflows/build-v3.yml @@ -34,8 +34,34 @@ jobs: id: release_version run: | app_version=$(cat version.py |sed -ne "s/APP_VERSION\s=\s'v\(.*\)'/\1/gp") + frontend_version=$(sed -ne "s/FRONTEND_VERSION\s*=\s*'\([^']*\)'/\1/gp" version.py) echo "app_version=$app_version" >> $GITHUB_ENV echo "SOURCE_COMMIT=$(git rev-parse HEAD)" >> $GITHUB_ENV + echo "frontend_version=$frontend_version" >> "$GITHUB_OUTPUT" + + - name: Resolve External Payload Revisions + id: payloads + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + FRONTEND_VERSION: ${{ steps.release_version.outputs.frontend_version }} + run: | + plugins_revision=$(git ls-remote https://github.com/jxxghp/MoviePilot-Plugins.git refs/heads/main | awk '{print $1}') + resources_revision=$(git ls-remote https://github.com/jxxghp/MoviePilot-Resources.git refs/heads/main | awk '{print $1}') + frontend_digest=$(gh api "repos/jxxghp/MoviePilot-Frontend/releases/tags/${FRONTEND_VERSION}" \ + --jq '.assets[] | select(.name == "dist.zip") | .digest') + + [[ "$plugins_revision" =~ ^[0-9a-f]{40}$ ]] + [[ "$resources_revision" =~ ^[0-9a-f]{40}$ ]] + case "$frontend_digest" in + sha256:*) frontend_sha256=${frontend_digest#sha256:} ;; + *) echo "dist.zip 缺少 SHA-256 摘要" >&2; exit 1 ;; + esac + [[ "$frontend_sha256" =~ ^[0-9a-f]{64}$ ]] + + echo "plugins_revision=$plugins_revision" >> "$GITHUB_OUTPUT" + echo "resources_revision=$resources_revision" >> "$GITHUB_OUTPUT" + echo "frontend_digest=$frontend_digest" >> "$GITHUB_OUTPUT" + echo "frontend_sha256=$frontend_sha256" >> "$GITHUB_OUTPUT" - name: Checkout Wiki Plugin Market uses: actions/checkout@v4 @@ -57,6 +83,7 @@ jobs: echo "wiki_commit=$wiki_commit" >> "$GITHUB_OUTPUT" - name: Download models.dev catalog + id: models_catalog run: | temp_file=$(mktemp app/agent/llm/models.json.XXXXXX) trap 'rm -f "$temp_file"' EXIT @@ -67,6 +94,7 @@ jobs: # Git keeps only a small placeholder; the release image receives the current catalog. chmod 644 "$temp_file" mv "$temp_file" app/agent/llm/models.json + echo "digest=sha256:$(sha256sum app/agent/llm/models.json | awk '{print $1}')" >> "$GITHUB_OUTPUT" echo "Downloaded models.dev catalog ($(wc -c < app/agent/llm/models.json) bytes)" - name: Create Release Snapshot @@ -122,10 +150,22 @@ jobs: linux/arm64/v8 push: true tags: ${{ steps.meta.outputs.tags }} + build-args: | + MOVIEPILOT_FRONTEND_VERSION=${{ steps.release_version.outputs.frontend_version }} + MOVIEPILOT_FRONTEND_SHA256=${{ steps.payloads.outputs.frontend_sha256 }} + MOVIEPILOT_PLUGINS_REF=${{ steps.payloads.outputs.plugins_revision }} + MOVIEPILOT_RESOURCES_REF=${{ steps.payloads.outputs.resources_revision }} labels: | ${{ steps.meta.outputs.labels }} org.opencontainers.image.revision=${{ steps.release_snapshot.outputs.release_commit }} + org.moviepilot.source-revision=${{ env.SOURCE_COMMIT }} + org.moviepilot.release-snapshot-revision=${{ steps.release_snapshot.outputs.release_commit }} + org.moviepilot.frontend-version=${{ steps.release_version.outputs.frontend_version }} + org.moviepilot.frontend-digest=${{ steps.payloads.outputs.frontend_digest }} + org.moviepilot.plugins-revision=${{ steps.payloads.outputs.plugins_revision }} + org.moviepilot.resources-revision=${{ steps.payloads.outputs.resources_revision }} org.moviepilot.plugin-market-wiki-revision=${{ steps.plugin_market.outputs.wiki_commit }} + org.moviepilot.models-catalog-digest=${{ steps.models_catalog.outputs.digest }} cache-from: type=gha,scope=moviepilot-v3-docker,version=2 cache-to: type=gha,scope=moviepilot-v3-docker,mode=max,version=2 diff --git a/.gitignore b/.gitignore index 855d45635..86a9974c0 100644 --- a/.gitignore +++ b/.gitignore @@ -55,5 +55,6 @@ pylint-report.json # Superpowers 设计/计划文档(本地协作产物,不纳入仓库) docs/superpowers/ -# 本地前端构建产物(docker build 用,不入库) -frontend-dist/ +# 保留本地前端构建产物目录,目录内产物不纳入仓库 +frontend-dist/* +!frontend-dist/.gitkeep diff --git a/docker/Dockerfile b/docker/Dockerfile index affdb49f7..e5e59be96 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,9 +1,20 @@ +# syntax=docker/dockerfile:1 + FROM ghcr.io/astral-sh/uv:0.12.5@sha256:e85be844203885286c60ffad8a858d48afb6c5a5c237ca0e67f12e74b8f174b1 AS uv FROM python:3.12.13-slim-bookworm AS base +# 准备外部制品所需的最小工具集 +FROM base AS prepare_payload + +RUN apt-get update \ + && apt-get install -y --no-install-recommends bash busybox ca-certificates curl jq \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + + # 准备软件包 FROM base AS prepare_package @@ -105,30 +116,100 @@ RUN python3 -m venv --without-pip ${VENV_PATH} \ --no-dev \ --no-install-project -# 下载准备代码 -FROM prepare_package AS prepare_code +# 准备后端源码 +FROM base AS prepare_backend WORKDIR /app COPY . . -# 构建上下文中存在 frontend-dist/ 时优先使用本地前端产物(fork 自有前端), -# 否则回退下载官方 release 的 dist.zip -RUN if [ -d /app/frontend-dist ]; then \ - mv /app/frontend-dist /public; \ +RUN rm -rf /app/frontend-dist + + +# 准备前端制品 +FROM prepare_payload AS prepare_frontend + +ARG MOVIEPILOT_FRONTEND_VERSION="" +ARG MOVIEPILOT_FRONTEND_SHA256="" + +COPY version.py /tmp/moviepilot-version.py +COPY frontend-dist/ /tmp/frontend-dist/ + +RUN set -eu; \ + mkdir -p /public; \ + if find /tmp/frontend-dist -mindepth 1 -type f ! -name '.gitkeep' -print -quit | grep -q .; then \ + rm -f /tmp/frontend-dist/.gitkeep; \ + cp -a /tmp/frontend-dist/. /public/; \ else \ - FRONTEND_VERSION=$(sed -n "s/^FRONTEND_VERSION\s*=\s*'\([^']*\)'/\1/p" /app/version.py) \ - && curl -sL "https://github.com/jxxghp/MoviePilot-Frontend/releases/download/${FRONTEND_VERSION}/dist.zip" | busybox unzip -d / - \ - && mv /dist /public; \ - fi \ - && curl -sL "https://github.com/jxxghp/MoviePilot-Plugins/archive/refs/heads/main.zip" | busybox unzip -d /tmp - \ - && mv -f /tmp/MoviePilot-Plugins-main/plugins.v2/* /app/app/plugins/ \ - && cat /tmp/MoviePilot-Plugins-main/package.json | jq -r 'to_entries[] | select(.value.v2 == true) | .key' | awk '{print tolower($0)}' | \ - while read -r i; do if [ ! -d "/app/app/plugins/$i" ]; then mv "/tmp/MoviePilot-Plugins-main/plugins/$i" "/app/app/plugins/"; else echo "跳过 $i"; fi; done \ - && curl -fsSL "https://raw.githubusercontent.com/jxxghp/MoviePilot-Resources/main/resources.v3/user.sites.v3.bin" -o /app/app/application/site/user.sites.v3.bin \ - && python_ver=$(python3 -c 'import sys; print(f"cpython-{sys.version_info.major}{sys.version_info.minor}")') \ - && ARCH=$(uname -m) \ - && if [ "$ARCH" = "aarch64" ]; then SUFFIX="aarch64-linux-gnu"; else SUFFIX="x86_64-linux-gnu"; fi \ - && curl -fsSL "https://raw.githubusercontent.com/jxxghp/MoviePilot-Resources/main/resources.v3/sites.${python_ver}-${SUFFIX}.so" -o "/app/app/application/site/sites.${python_ver}-${SUFFIX}.so" + frontend_version="${MOVIEPILOT_FRONTEND_VERSION:-$(sed -n "s/^FRONTEND_VERSION[[:space:]]*=[[:space:]]*'\([^']*\)'/\1/p" /tmp/moviepilot-version.py)}"; \ + test -n "${frontend_version}"; \ + curl -fsSL "https://github.com/jxxghp/MoviePilot-Frontend/releases/download/${frontend_version}/dist.zip" -o /tmp/frontend.zip; \ + if [ -n "${MOVIEPILOT_FRONTEND_SHA256}" ]; then \ + printf '%s %s\n' "${MOVIEPILOT_FRONTEND_SHA256}" /tmp/frontend.zip | sha256sum -c -; \ + fi; \ + busybox unzip -q /tmp/frontend.zip -d /tmp/frontend-release; \ + cp -a /tmp/frontend-release/dist/. /public/; \ + fi; \ + test -f /public/index.html + + +# 准备镜像内置插件 +FROM prepare_payload AS prepare_plugins + +ARG MOVIEPILOT_PLUGINS_REF="main" + +RUN set -eu; \ + test -n "${MOVIEPILOT_PLUGINS_REF}"; \ + curl -fsSL "https://github.com/jxxghp/MoviePilot-Plugins/archive/${MOVIEPILOT_PLUGINS_REF}.zip" -o /tmp/plugins.zip; \ + busybox unzip -q /tmp/plugins.zip -d /tmp/plugins-src; \ + plugin_root="$(find /tmp/plugins-src -mindepth 1 -maxdepth 1 -type d -print -quit)"; \ + test -n "${plugin_root}"; \ + mkdir -p /plugins; \ + cp -a "${plugin_root}/plugins.v2/." /plugins/; \ + jq -r 'to_entries[] | select(.value.v2 == true) | .key' "${plugin_root}/package.json" | awk '{print tolower($0)}' | \ + while read -r plugin_id; do \ + if [ ! -d "/plugins/${plugin_id}" ]; then \ + cp -a "${plugin_root}/plugins/${plugin_id}" /plugins/; \ + fi; \ + done + + +# 准备站点资源 +FROM prepare_payload AS prepare_resources + +ARG TARGETARCH +ARG MOVIEPILOT_RESOURCES_REF="main" + +RUN set -eu; \ + test -n "${MOVIEPILOT_RESOURCES_REF}"; \ + python_ver="$(python3 -c 'import sys; print(f"cpython-{sys.version_info.major}{sys.version_info.minor}")')"; \ + target_arch="${TARGETARCH:-$(uname -m)}"; \ + case "${target_arch}" in \ + arm64|aarch64) suffix="aarch64-linux-gnu" ;; \ + amd64|x86_64) suffix="x86_64-linux-gnu" ;; \ + *) printf 'Unsupported architecture: %s\n' "${target_arch}" >&2; exit 1 ;; \ + esac; \ + mkdir -p /resources; \ + curl -fsSL "https://raw.githubusercontent.com/jxxghp/MoviePilot-Resources/${MOVIEPILOT_RESOURCES_REF}/resources.v3/user.sites.v3.bin" -o /resources/user.sites.v3.bin; \ + curl -fsSL "https://raw.githubusercontent.com/jxxghp/MoviePilot-Resources/${MOVIEPILOT_RESOURCES_REF}/resources.v3/sites.${python_ver}-${suffix}.so" -o "/resources/sites.${python_ver}-${suffix}.so" + + +# 准备容器控制面 +FROM prepare_payload AS prepare_control + +WORKDIR /control + +COPY docker/ ./ +RUN mkdir -p /bundle/control /bundle/nginx /bundle/bin \ + && find . -maxdepth 1 -type f -name '*.sh' ! -name 'launcher.sh' -exec cp -f -t /bundle/control {} + \ + && cp -f launcher.sh /bundle/entrypoint.sh \ + && cp -f nginx.common.conf /bundle/nginx/common.conf \ + && cp -f nginx.template.conf /bundle/nginx/nginx.template.conf \ + && cp -f docker_http_proxy.conf /bundle/nginx/docker_http_proxy.conf \ + && printf '%s\n' '#!/usr/bin/env bash' 'set -euo pipefail' 'cd /app' 'exec "${VENV_PATH:-/opt/venv}/bin/python3" -m app.cli "$@"' > /bundle/bin/moviepilot \ + && bash -n /bundle/entrypoint.sh \ + && for control_script in /bundle/control/*.sh; do bash -n "${control_script}" || exit 1; done \ + && chmod 755 /bundle/entrypoint.sh /bundle/bin/moviepilot \ + && chmod 500 /bundle/control/*.sh # final 阶段: 安装运行时依赖和配置最终镜像 FROM prepare_package AS final @@ -153,24 +234,15 @@ RUN playwright install-deps chromium \ /var/lib/apt/lists/* \ /var/tmp/* -# 准备运行代码 -WORKDIR /app +# 准备容器控制面和运行目录 +COPY --from=prepare_control /bundle/entrypoint.sh /entrypoint.sh +COPY --from=prepare_control /bundle/control /usr/local/lib/moviepilot/control +COPY --from=prepare_control /bundle/nginx/common.conf /etc/nginx/common.conf +COPY --from=prepare_control /bundle/nginx/nginx.template.conf /etc/nginx/nginx.template.conf +COPY --from=prepare_control /bundle/nginx/docker_http_proxy.conf /etc/nginx/docker_http_proxy.conf +COPY --from=prepare_control /bundle/bin/moviepilot /usr/local/bin/moviepilot -COPY --from=prepare_code /app /app -COPY --from=prepare_code /public /public - -RUN cp -f /app/docker/nginx.common.conf /etc/nginx/common.conf \ - && cp -f /app/docker/nginx.template.conf /etc/nginx/nginx.template.conf \ - && mkdir -p /usr/local/lib/moviepilot/control \ - && find /app/docker -maxdepth 1 -type f -name '*.sh' ! -name 'launcher.sh' -exec cp -f -t /usr/local/lib/moviepilot/control {} + \ - && cp -f /app/docker/launcher.sh /entrypoint.sh \ - && cp -f /app/docker/docker_http_proxy.conf /etc/nginx/docker_http_proxy.conf \ - && printf '%s\n' '#!/usr/bin/env bash' 'set -euo pipefail' 'cd /app' 'exec "${VENV_PATH:-/opt/venv}/bin/python3" -m app.cli "$@"' > /usr/local/bin/moviepilot \ - && bash -n /entrypoint.sh \ - && for control_script in /usr/local/lib/moviepilot/control/*.sh; do bash -n "${control_script}" || exit 1; done \ - && chmod 755 /entrypoint.sh /usr/local/bin/moviepilot \ - && chmod 500 /usr/local/lib/moviepilot/control/*.sh \ - && mkdir -p ${HOME} \ +RUN mkdir -p ${HOME} \ && groupadd -r moviepilot -g 918 \ && useradd -r moviepilot -g moviepilot -d ${HOME} -s /bin/bash -u 918 \ && python_ver=$(python3 -V | awk '{print $2}') \ @@ -180,6 +252,14 @@ RUN cp -f /app/docker/nginx.common.conf /etc/nginx/common.conf \ && echo "zh_CN.UTF-8 UTF-8" >> /etc/locale.gen \ && locale-gen zh_CN.UTF-8 +# 载荷按独立更新频率写入镜像,后端源码保持在最后一层。 +WORKDIR /app + +COPY --link --from=prepare_frontend /public /public +COPY --link --from=prepare_plugins /plugins /app/app/plugins +COPY --link --from=prepare_resources /resources /app/app/application/site +COPY --link --from=prepare_backend /app /app + EXPOSE 3000 VOLUME [ "${CONFIG_DIR}" ] HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 CMD /usr/bin/curl -fsS "http://127.0.0.1:${PORT:-3001}/api/v1/system/global?token=moviepilot" >/dev/null || exit 1 diff --git a/frontend-dist/.gitkeep b/frontend-dist/.gitkeep new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/frontend-dist/.gitkeep @@ -0,0 +1 @@ + diff --git a/tests/test_database_backup_docker.py b/tests/test_database_backup_docker.py index ab7c36ea2..bae60fe77 100644 --- a/tests/test_database_backup_docker.py +++ b/tests/test_database_backup_docker.py @@ -44,7 +44,11 @@ def test_runtime_image_keeps_pgdg_setup_architecture_neutral_and_cleans_apt_cach assert len(pgdg_sources) == 1 pgdg_source = pgdg_sources[0] assert "arch=" not in pgdg_source - assert "/var/lib/apt/lists/*" in dockerfile - assert dockerfile.index("postgresql-client-18") < dockerfile.index( + runtime_packages = dockerfile[ + dockerfile.index("FROM base AS prepare_package") : + dockerfile.index("FROM base AS prepare_venv") + ] + assert "/var/lib/apt/lists/*" in runtime_packages + assert runtime_packages.index("postgresql-client-18") < runtime_packages.index( "/var/lib/apt/lists/*" ) diff --git a/tests/test_docker_bootstrap.py b/tests/test_docker_bootstrap.py index 7ff552d2a..15409e797 100644 --- a/tests/test_docker_bootstrap.py +++ b/tests/test_docker_bootstrap.py @@ -41,12 +41,17 @@ def test_dockerfile_control_bundle_build_checks_fail_closed() -> None: assert "uv-pip-compat" not in dockerfile assert "requirements.in" not in dockerfile assert "${VENV_PATH}/bin/pip" not in dockerfile - assert "-exec cp -f -t /usr/local/lib/moviepilot/control {} +" in dockerfile - assert "bash -n /entrypoint.sh" in dockerfile + assert "FROM prepare_payload AS prepare_control" in dockerfile + assert "-exec cp -f -t /bundle/control {} +" in dockerfile + assert "bash -n /bundle/entrypoint.sh" in dockerfile + assert ( + "COPY --from=prepare_control /bundle/control " + "/usr/local/lib/moviepilot/control" in dockerfile + ) assert 'ENTRYPOINT [ "/usr/bin/tini", "-g", "--", "/entrypoint.sh" ]' in dockerfile assert "CMD /usr/bin/curl -fsS" in dockerfile assert ( - 'for control_script in /usr/local/lib/moviepilot/control/*.sh; do bash -n "${control_script}" || exit 1; done' + 'for control_script in /bundle/control/*.sh; do bash -n "${control_script}" || exit 1; done' in dockerfile ) diff --git a/tests/test_docker_payload_contract.py b/tests/test_docker_payload_contract.py new file mode 100644 index 000000000..ceb5320b3 --- /dev/null +++ b/tests/test_docker_payload_contract.py @@ -0,0 +1,135 @@ +"""Docker 构建输入和镜像载荷分层合同。""" + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +DOCKERFILE = ROOT / "docker" / "Dockerfile" +RELEASE_WORKFLOW = ROOT / ".github" / "workflows" / "build-v3.yml" +BETA_WORKFLOW = ROOT / ".github" / "workflows" / "beta.yml" + + +def _read(path: Path) -> str: + """读取构建合同文件。""" + return path.read_text(encoding="utf-8") + + +def test_build_context_excludes_runtime_state_and_keeps_release_inputs() -> None: + """本地运行数据不得进入构建上下文,发布所需入口必须保留。""" + dockerignore = _read(ROOT / ".dockerignore") + + for pattern in ( + ".venv/", + ".worktrees/", + ".build/", + ".agent-work/", + ".runtime/", + ".tmp/", + "node_modules/", + "public/", + ".moviepilot.env", + ".env", + ".env.*", + "**/*.pyc", + "**/__pycache__/", + "**/*.pyd", + "**/*.so", + "coverage.json", + "coverage.xml", + "config/*", + "app/plugins/**", + "app/application/site/*.bin", + ): + assert pattern in dockerignore + + assert "!config/category.yaml" in dockerignore + assert "!app/plugins/__init__.py" in dockerignore + assert "frontend-dist" not in dockerignore + + +def test_dockerfile_assigns_each_payload_to_an_independent_stage() -> None: + """高频载荷必须由独立 stage 生成,并在 final 中分别写入目标目录。""" + dockerfile = _read(DOCKERFILE) + + for stage in ( + "prepare_backend", + "prepare_frontend", + "prepare_plugins", + "prepare_resources", + "prepare_control", + ): + assert f" AS {stage}" in dockerfile + + final_start = dockerfile.index("FROM prepare_package AS final") + final = dockerfile[final_start:] + copies = ( + "COPY --link --from=prepare_frontend /public /public", + "COPY --link --from=prepare_plugins /plugins /app/app/plugins", + "COPY --link --from=prepare_resources /resources /app/app/application/site", + "COPY --link --from=prepare_backend /app /app", + ) + positions = [final.index(copy) for copy in copies] + + assert positions == sorted(positions) + for copy in copies: + assert final.count(copy) == 1 + assert "COPY --from=prepare_control /bundle/entrypoint.sh /entrypoint.sh" in final + assert "RUN rm -rf /app/frontend-dist" in dockerfile + + +def test_release_workflows_pin_and_record_external_payload_identities() -> None: + """正式与 Beta 构建都必须以真实制品身份驱动缓存并写入镜像标签。""" + for workflow_path in (RELEASE_WORKFLOW, BETA_WORKFLOW): + workflow = _read(workflow_path) + + for build_arg in ( + "MOVIEPILOT_FRONTEND_VERSION=", + "MOVIEPILOT_FRONTEND_SHA256=", + "MOVIEPILOT_PLUGINS_REF=", + "MOVIEPILOT_RESOURCES_REF=", + ): + assert build_arg in workflow + + for label in ( + "org.moviepilot.source-revision=", + "org.moviepilot.frontend-version=", + "org.moviepilot.frontend-digest=", + "org.moviepilot.plugins-revision=", + "org.moviepilot.resources-revision=", + "org.moviepilot.plugin-market-wiki-revision=", + "org.moviepilot.models-catalog-digest=", + ): + assert label in workflow + + assert "git ls-remote https://github.com/jxxghp/MoviePilot-Plugins.git" in workflow + assert "git ls-remote https://github.com/jxxghp/MoviePilot-Resources.git" in workflow + assert "sha256:*) frontend_sha256=" in workflow + assert "^[0-9a-f]{40}$" in workflow + assert "^[0-9a-f]{64}$" in workflow + + +def test_same_version_rebuild_identity_is_not_derived_from_image_tag() -> None: + """重复发布同一版本时,缓存身份必须来自源码和制品而不是镜像 Tag。""" + release_workflow = _read(RELEASE_WORKFLOW) + + assert "SOURCE_COMMIT=$(git rev-parse HEAD)" in release_workflow + assert "org.moviepilot.source-revision=${{ env.SOURCE_COMMIT }}" in release_workflow + assert ( + "org.moviepilot.release-snapshot-revision=" + "${{ steps.release_snapshot.outputs.release_commit }}" in release_workflow + ) + assert "type=raw,value=${{ env.app_version }}" in release_workflow + assert "MOVIEPILOT_PLUGINS_REF=${{ steps.payloads.outputs.plugins_revision }}" in release_workflow + assert "MOVIEPILOT_RESOURCES_REF=${{ steps.payloads.outputs.resources_revision }}" in release_workflow + + +def test_custom_frontend_directory_is_stable_but_artifacts_remain_untracked() -> None: + """干净 checkout 可直接构建,自定义前端产物仍保持本地状态。""" + gitignore = _read(ROOT / ".gitignore") + dockerfile = _read(DOCKERFILE) + + assert (ROOT / "frontend-dist" / ".gitkeep").is_file() + assert "frontend-dist/*" in gitignore + assert "!frontend-dist/.gitkeep" in gitignore + assert "COPY frontend-dist/ /tmp/frontend-dist/" in dockerfile + assert "! -name '.gitkeep'" in dockerfile