diff --git a/bk-plugin-framework/pytest.ini b/bk-plugin-framework/pytest.ini index 2a8c655..787660a 100644 --- a/bk-plugin-framework/pytest.ini +++ b/bk-plugin-framework/pytest.ini @@ -1,2 +1,3 @@ [pytest] DJANGO_SETTINGS_MODULE = tests.settings +pythonpath = . diff --git a/bk-plugin-framework/tests/__init__.py b/bk-plugin-framework/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bk-plugin-framework/tests/template/__init__.py b/bk-plugin-framework/tests/template/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bk-plugin-framework/tests/template/test_docker_template.py b/bk-plugin-framework/tests/template/test_docker_template.py new file mode 100644 index 0000000..1da9179 --- /dev/null +++ b/bk-plugin-framework/tests/template/test_docker_template.py @@ -0,0 +1,84 @@ +import json +import re +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[3] +TEMPLATE_ROOT = ROOT / "template" +PLUGIN_TEMPLATE_ROOT = TEMPLATE_ROOT / "{{cookiecutter.project_name}}" +COOKIECUTTER_JSON = TEMPLATE_ROOT / "cookiecutter.json" +TEMPLATE_REQUIREMENTS = PLUGIN_TEMPLATE_ROOT / "requirements.txt" +TEMPLATE_DOCKERFILE = PLUGIN_TEMPLATE_ROOT / "Dockerfile" +CUSTOM_REQUIREMENTS = PLUGIN_TEMPLATE_ROOT / "custom_requirements.txt" +BASE_DOCKERFILE = ROOT / "docker" / "base" / "Dockerfile" +BASE_README = ROOT / "docker" / "base" / "README.md" + + +def read_text(path): + return path.read_text(encoding="utf-8") + + +def load_cookiecutter_config(): + return json.loads(read_text(COOKIECUTTER_JSON)) + + +def get_framework_version(): + requirements = read_text(TEMPLATE_REQUIREMENTS) + match = re.search(r"^bk-plugin-framework==([^\s#]+)$", requirements, re.MULTILINE) + assert match, "template requirements.txt must pin bk-plugin-framework with ==" + return match.group(1) + + +def get_base_image_tag(base_image): + assert ":" in base_image, "base_image must include a fixed tag" + return base_image.rsplit(":", 1)[1] + + +def test_cookiecutter_base_image_matches_framework_version(): + config = load_cookiecutter_config() + + assert "base_image" in config + assert config["base_image"].split("/")[-1].startswith("bk-plugin-python-base:") + assert get_base_image_tag(config["base_image"]) == get_framework_version() + + +def test_template_dockerfile_uses_base_image_and_custom_requirements_only(): + dockerfile = read_text(TEMPLATE_DOCKERFILE) + + assert "FROM {{cookiecutter.base_image}}" in dockerfile + assert "COPY custom_requirements.txt /app/custom_requirements.txt" in dockerfile + assert "python -m pip install --no-cache-dir -r /app/custom_requirements.txt" in dockerfile + assert "COPY requirements.txt" not in dockerfile + assert "-r /app/requirements.txt" not in dockerfile + assert "-r requirements.txt" not in dockerfile + + +def test_custom_requirements_contains_no_default_dependencies(): + custom_requirements = read_text(CUSTOM_REQUIREMENTS) + + assert "bk-plugin-framework" not in custom_requirements + assert "opentelemetry-" not in custom_requirements + assert "celery-prometheus-exporter" not in custom_requirements + + +def test_base_dockerfile_installs_template_requirements_from_repo_root(): + dockerfile = read_text(BASE_DOCKERFILE) + + assert "ARG PYTHON_BASE_IMAGE=python:3.10.5-slim" in dockerfile + assert "FROM ${PYTHON_BASE_IMAGE}" in dockerfile + assert "COPY template/{{cookiecutter.project_name}}/requirements.txt /tmp/bk-plugin-default-requirements.txt" in dockerfile + assert "python -m pip install --no-cache-dir -r /tmp/bk-plugin-default-requirements.txt" in dockerfile + assert "importlib.metadata" in dockerfile + assert "bk-plugin-framework" in dockerfile + assert "bk-plugin-runtime" in dockerfile + + +def test_base_image_readme_documents_bk_ci_build_command(): + readme = read_text(BASE_README) + expected_tag = f"bk-plugin-python-base:{get_framework_version()}" + + assert "docker build \\" in readme + assert "-f docker/base/Dockerfile" in readme + assert "--build-arg PYTHON_BASE_IMAGE=" in readme + assert f"-t {expected_tag}" in readme + assert f"docker push /{expected_tag}" in readme diff --git a/docker/base/Dockerfile b/docker/base/Dockerfile new file mode 100644 index 0000000..184a3de --- /dev/null +++ b/docker/base/Dockerfile @@ -0,0 +1,17 @@ +ARG PYTHON_BASE_IMAGE=python:3.10.5-slim +FROM ${PYTHON_BASE_IMAGE} + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +WORKDIR /app + +COPY template/{{cookiecutter.project_name}}/requirements.txt /tmp/bk-plugin-default-requirements.txt + +RUN python --version \ + && python -m pip --version \ + && python -m pip install --no-cache-dir --upgrade pip==21.2.2 \ + && python -m pip --version \ + && python -m pip install --no-cache-dir -r /tmp/bk-plugin-default-requirements.txt \ + && python -c "import importlib.metadata as metadata; print('bk-plugin-framework', metadata.version('bk-plugin-framework')); print('bk-plugin-runtime', metadata.version('bk-plugin-runtime'))" diff --git a/docker/base/README.md b/docker/base/README.md new file mode 100644 index 0000000..60cb8ec --- /dev/null +++ b/docker/base/README.md @@ -0,0 +1,47 @@ +# BK Plugin Python Base Image + +This directory contains the Dockerfile for the shared plugin Python base image. BK-CI should build this image from the repository root so the Docker build can copy the cookiecutter template requirements file. + +## Tag Policy + +The image tag must match the `bk-plugin-framework` version pinned in `template/{{cookiecutter.project_name}}/requirements.txt`. + +For the current template: + +```txt +bk-plugin-framework==2.3.14 +bk-plugin-python-base:2.3.14 +``` + +The Python patch version is not part of the image tag. Keep the upstream Python image aligned with `template/{{cookiecutter.project_name}}/runtime.txt`. + +## BK-CI Build Command + +Run the build from the repository root: + +```bash +docker build \ + -f docker/base/Dockerfile \ + --build-arg PYTHON_BASE_IMAGE= \ + -t bk-plugin-python-base:2.3.14 \ + . +``` + +Push the image to the internal registry: + +```bash +docker tag bk-plugin-python-base:2.3.14 /bk-plugin-python-base:2.3.14 +docker push /bk-plugin-python-base:2.3.14 +``` + +If the template should generate a full internal image path, set `base_image` in `template/cookiecutter.json` to the pushed image name, such as `/bk-plugin-python-base:2.3.14`. + +## Verification + +After building the image, verify the default framework and runtime packages are installed: + +```bash +docker run --rm bk-plugin-python-base:2.3.14 python -c "import bk_plugin_framework, bk_plugin_runtime" +``` + +The command exits with status `0` when both packages are importable. diff --git a/docs/superpowers/specs/2026-06-30-plugin-base-image-deployment-acceleration-design.md b/docs/superpowers/specs/2026-06-30-plugin-base-image-deployment-acceleration-design.md new file mode 100644 index 0000000..5ee86a4 --- /dev/null +++ b/docs/superpowers/specs/2026-06-30-plugin-base-image-deployment-acceleration-design.md @@ -0,0 +1,163 @@ +# Plugin Base Image Deployment Acceleration Design + +## Purpose + +Plugin deployment is slow because every generated plugin project installs the full default dependency set during deployment. Most plugins do not need custom dependencies, so each deployment repeats the same dependency resolution and package installation work. + +This design adds a reusable Python base image for the default plugin environment and updates the cookiecutter template to build plugin images incrementally. The repository provides the base image Dockerfile and template Dockerfile. The base image is built and published by an external BK-CI pipeline. + +## Goals + +- Build a base image that contains the default plugin runtime dependencies. +- Keep `template/{{cookiecutter.project_name}}/requirements.txt` as the single source of default Python dependencies. +- Preserve compatibility with existing non-Docker deployment flows that install `requirements.txt` directly. +- Add a generated plugin Dockerfile that uses the base image and only installs user custom dependencies. +- Use fixed base image tags aligned with the `bk-plugin-framework` version, such as `bk-plugin-python-base:2.3.14`. +- Allow BK-CI to override the upstream Python image through a build argument. + +## Non-Goals + +- This change does not add GitHub Actions or BK-CI pipeline configuration for publishing the image. +- This change does not redesign `app_desc.yml` process commands. +- This change does not introduce a new dependency lock system. +- This change does not remove or shrink the existing template `requirements.txt`. + +## Decisions + +The selected approach is to reuse `template/{{cookiecutter.project_name}}/requirements.txt` when building the base image. The template keeps that file unchanged for old deployment flows, while Docker-based plugin builds install only `custom_requirements.txt`. + +The base image tag matches the framework version, not the Python version. For example, when the template pins `bk-plugin-framework==2.3.14`, the matching base image is `bk-plugin-python-base:2.3.14`. Users normally do not need to know the Python patch version. The Python version remains an internal base image implementation detail and should stay aligned with `runtime.txt`. + +The base image Dockerfile starts from: + +```dockerfile +ARG PYTHON_BASE_IMAGE=python:3.10.5-slim +FROM ${PYTHON_BASE_IMAGE} +``` + +BK-CI can pass an internal Python image with `--build-arg PYTHON_BASE_IMAGE=...`. + +## File Structure + +Add these files: + +```txt +docker/base/Dockerfile +docker/base/README.md +template/{{cookiecutter.project_name}}/Dockerfile +template/{{cookiecutter.project_name}}/custom_requirements.txt +``` + +Update `template/cookiecutter.json` with a `base_image` parameter. Its default value should be the fixed base image tag for the template's current framework version, for example: + +```json +"base_image": "bk-plugin-python-base:2.3.14" +``` + +## Base Image Design + +`docker/base/Dockerfile` uses the repository root as the Docker build context. It copies `template/{{cookiecutter.project_name}}/requirements.txt` into the image and installs it during the base image build. + +The base image should set predictable Python runtime defaults: + +```dockerfile +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +``` + +It should also print useful build diagnostics, including Python version, pip version, and the installed `bk-plugin-framework` version. This keeps BK-CI failure logs easy to inspect when dependency resolution, package indexes, or upstream image changes break the build. + +## Template Image Design + +The generated plugin `Dockerfile` uses: + +```dockerfile +FROM {{cookiecutter.base_image}} +``` + +It copies the plugin project into a stable working directory, installs `custom_requirements.txt`, and leaves process startup to `app_desc.yml`. + +`custom_requirements.txt` is present by default and contains comments only. Users add custom third-party dependencies there when needed. The default plugin path should not reinstall `requirements.txt`, because those dependencies are already in the base image. + +The existing `requirements.txt` remains part of the template and keeps the full default dependency list. It has two jobs: + +1. It supports existing non-Docker deployment flows. +2. It is the dependency source for the base image. + +## Build Flow + +For a framework version such as `2.3.14`, BK-CI should build from the repository root: + +```bash +docker build \ + -f docker/base/Dockerfile \ + --build-arg PYTHON_BASE_IMAGE= \ + -t bk-plugin-python-base:2.3.14 \ + . +``` + +Then BK-CI pushes the image to the internal registry: + +```bash +docker tag bk-plugin-python-base:2.3.14 /bk-plugin-python-base:2.3.14 +docker push /bk-plugin-python-base:2.3.14 +``` + +The published tag must match the `bk-plugin-framework` pin in the template `requirements.txt`. + +## Plugin Build Flow + +Users generate a plugin project from the cookiecutter template. The generated Dockerfile references the fixed base image through `{{cookiecutter.base_image}}`. + +Most users leave `custom_requirements.txt` unchanged. Their image build only copies plugin code and runs an empty custom dependency install step. Users with extra dependencies add them to `custom_requirements.txt`; conflicts then fail in the plugin image build instead of affecting the shared base image. + +The existing `app_desc.yml` commands continue to run: + +- `gunicorn bk_plugin_runtime.wsgi ...` +- `celery -A blueapps.core.celery worker ...` +- `celery -A blueapps.core.celery beat ...` +- `celery-prometheus-exporter ...` + +## Error Handling + +Base image build failures should expose enough log context to identify whether the failure comes from the upstream Python image, pip, package indexes, or dependency resolution. The base image build should fail fast when default dependencies cannot be installed. + +Plugin image build failures caused by `custom_requirements.txt` remain local to that plugin. This preserves a fast and stable default path while still allowing plugin-specific dependencies. + +If the framework version in template `requirements.txt` changes, the base image tag in `template/cookiecutter.json` must be updated to the same version. The implementation should include a lightweight validation test for this rule. + +## Testing And Acceptance + +Static checks: + +- `docker/base/Dockerfile` builds from the repository root. +- `docker/base/Dockerfile` copies `template/{{cookiecutter.project_name}}/requirements.txt`. +- The generated template Dockerfile uses `{{cookiecutter.base_image}}`. +- `custom_requirements.txt` exists and contains no default third-party dependencies. +- The `bk-plugin-framework` version in template `requirements.txt` matches the tag in `template/cookiecutter.json` `base_image`. + +Base image build check: + +```bash +docker build -f docker/base/Dockerfile -t bk-plugin-python-base:2.3.14 . +docker run --rm bk-plugin-python-base:2.3.14 python -c "import bk_plugin_framework, bk_plugin_runtime" +``` + +Template image check: + +1. Generate a plugin project with cookiecutter. +2. Build its Dockerfile with the fixed base image. +3. Confirm an empty `custom_requirements.txt` does not reinstall the full default dependency set. +4. Run `python bin/manage.py check` inside the image. +5. Add one lightweight custom dependency to `custom_requirements.txt` and confirm only the plugin image layer changes. + +## Operational Notes + +The base image should be published before templates reference the tag in normal user workflows. If the internal registry requires a full image name, set `base_image` in `cookiecutter.json` to the full registry path before releasing the template. + +When releasing a new framework version, the expected order is: + +1. Update template `requirements.txt` with the new `bk-plugin-framework` version. +2. Build and publish `bk-plugin-python-base:`. +3. Update template `base_image` to the matching fixed tag. +4. Verify a generated plugin project builds from that tag. diff --git a/template/cookiecutter.json b/template/cookiecutter.json index 4d1d112..f99fea4 100644 --- a/template/cookiecutter.json +++ b/template/cookiecutter.json @@ -9,6 +9,7 @@ "apigw_cors_allow_methods": "GET,POST,PUT,PATCH,HEAD,DELETE,OPTIONS", "apigw_cors_allow_headers": "Accept,Cache-Control,Content-Type,Keep-Alive,Origin,User-Agent,X-Requested-With", "bk_apigw_default_timeout": "60", + "base_image": "bk-plugin-python-base:2.3.14", "_copy_without_render": [ ".ci" ] diff --git a/template/{{cookiecutter.project_name}}/Dockerfile b/template/{{cookiecutter.project_name}}/Dockerfile new file mode 100644 index 0000000..af3820e --- /dev/null +++ b/template/{{cookiecutter.project_name}}/Dockerfile @@ -0,0 +1,11 @@ +FROM {{cookiecutter.base_image}} + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +COPY custom_requirements.txt /app/custom_requirements.txt +RUN python -m pip install --no-cache-dir -r /app/custom_requirements.txt + +COPY . /app diff --git a/template/{{cookiecutter.project_name}}/custom_requirements.txt b/template/{{cookiecutter.project_name}}/custom_requirements.txt new file mode 100644 index 0000000..cd05851 --- /dev/null +++ b/template/{{cookiecutter.project_name}}/custom_requirements.txt @@ -0,0 +1,2 @@ +# Add plugin-specific Python dependencies below this line. +# The default runtime dependencies are already installed in the base image. diff --git a/template/{{cookiecutter.project_name}}/requirements.txt b/template/{{cookiecutter.project_name}}/requirements.txt index d7a139c..405e9a9 100644 --- a/template/{{cookiecutter.project_name}}/requirements.txt +++ b/template/{{cookiecutter.project_name}}/requirements.txt @@ -1,6 +1,6 @@ # base # DO NOT DELETE ANY PACKAGE IN base SECTION !!! -bk-plugin-framework==2.3.6 +bk-plugin-framework==2.3.14 # opentelemetry celery-prometheus-exporter==1.7.0 opentelemetry-api==1.25.0