Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions bk-plugin-framework/pytest.ini
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
[pytest]
DJANGO_SETTINGS_MODULE = tests.settings
pythonpath = .
Empty file.
Empty file.
84 changes: 84 additions & 0 deletions bk-plugin-framework/tests/template/test_docker_template.py
Original file line number Diff line number Diff line change
@@ -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 <registry>/{expected_tag}" in readme
17 changes: 17 additions & 0 deletions docker/base/Dockerfile
Original file line number Diff line number Diff line change
@@ -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'))"
47 changes: 47 additions & 0 deletions docker/base/README.md
Original file line number Diff line number Diff line change
@@ -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=<internal-python-3.10.5-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 <registry>/bk-plugin-python-base:2.3.14
docker push <registry>/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 `<registry>/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.
Original file line number Diff line number Diff line change
@@ -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=<internal-python-3.10.5-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 <registry>/bk-plugin-python-base:2.3.14
docker push <registry>/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:<framework-version>`.
3. Update template `base_image` to the matching fixed tag.
4. Verify a generated plugin project builds from that tag.
1 change: 1 addition & 0 deletions template/cookiecutter.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
Expand Down
11 changes: 11 additions & 0 deletions template/{{cookiecutter.project_name}}/Dockerfile
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Add plugin-specific Python dependencies below this line.
# The default runtime dependencies are already installed in the base image.
2 changes: 1 addition & 1 deletion template/{{cookiecutter.project_name}}/requirements.txt
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading