mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-11-09 17:53:56 -10:00
refactor(platform): put the single-container image's code layers last (#14969)
### Why / What / How **Why:** The single-container image was built on top of the finished backend `server` image. Everything the appliance adds (Postgres, Valkey, nginx, supervisor, Node, RabbitMQ/Erlang, FalkorDB and the frontend, about 0.8 GB) sat above AutoGPT's code. So every commit re-ran those install steps and stored all of it again. On a self-hosted box that builds branch images, a one-line backend change cost 878 MB of Docker disk. **What:** Reorder the image so dependencies sit below the code. The cloud `server` target still runs the same steps in the same order. **How:** - `backend/Dockerfile` splits the `server` stage where the code starts: - `server-base`: packages, Python, Node, Prisma engines, Chromium and the dependency venv. - `server`: `FROM server-base` plus the unchanged code steps. - The appliance builds `FROM server-base`, adds its own dependencies, then the frontend, then AutoGPT's code. - The code is copied from the `server` image with its venv dropped, so it is exactly the files the backend image ships. The appliance then re-runs `poetry install --only-root` to create the entry points. - `IMAGE_VERSION`/`VCS_REF` and the OCI labels move below the last layer, so the per-build arguments no longer invalidate cached `RUN` steps. **Side effect on the image scans:** Trivy treats every layer below the last `CMD` in the image history as a base image and skips secret scanning there. The `server` stage's `CMD ["rest"]` used to sit in the appliance's history, so Trivy never scanned the backend's dependencies or code for secrets. It now does, and two fixes follow: - One pre-existing false positive: yt-dlp's Shahid extractor embeds that site's public client key. `autogpt_platform/single-container/.trivyignore.yaml` ignores that one rule in that one file, in all four scan steps. - The report verifier required a `Type` on every Trivy result, but Trivy never writes one for secret results. It now accepts clean secret results without `Type`, and any secret finding still fails. ### Changes 🏗️ - `autogpt_platform/backend/Dockerfile`: split `server` into `server-base` and `server`. No steps changed. - `autogpt_platform/single-container/Dockerfile`: - Builds on `server-base`, with a new `backend-code` stage. - The frontend, backend code and entry points go last; the build arguments and labels go at the end. - Two cleanup commands (the `config.json` and `.next/cache` symlinks) moved next to the files they touch. No other command changed. - `autogpt_platform/single-container/docker-bake.hcl`: new `backend-server-base` target, passed in as the `autogpt-backend-base` context. - `.github/scripts/single_container_cache.py`: the new target reads the registry cache like the other two. - `autogpt_platform/single-container/.trivyignore.yaml` (new) and `.github/workflows/platform-single-container-docker.yml`: one ignore entry (rule `aws-access-key-id` in `yt_dlp/extractor/shahid.py`), used by the CI scans and the release digest scans. - `.github/scripts/verify_single_container_reports.py`: secret results need only `Target` and `Class`. - Tests: - `test_dockerfile_layers.py` (new): `server-base` holds only dependencies; the appliance copies everything the `server` target adds; the build arguments come after every layer; the bake wiring is in place. - `test_ci_scan_ignores.py` (new): every Trivy step uses the ignore file. - `test_ci_registry_cache.py`: every bake target reads the validation cache. - `test_ci_report_verifier.py`: a clean secret result passes without `Type`; a secret finding fails; a package result without `Type` still fails. **CI caching:** - The e2e GHCR cache from #14892 and #14877 is keyed by compose target (`backend-server`, `backend-migrate`). Those targets are unchanged and every existing step keeps its cache key. - The single-container registry cache rebuilds the moved appliance layers once after merge. - PR builds don't use the registry cache. **Measured** on the same builder, building `dev` and this branch, then each again with a one-line backend change: | | `dev` | this PR | |---|---|---| | New Docker disk per commit | 877.7 MB | 45.5 MB | | Rebuild after a one-line change | 72 s | 38 s | | Image size | 3.52 GB | 3.52 GB | ### Agents and large language models used - Claude Code with Claude Opus 5.5 ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] Built `dev` and this branch with the single-container bake file on the same builder, then each again with a one-line backend change, and compared new data per commit and rebuild time (table above) - [x] Compared every file in the old and new single-container images (path, type, mode, owner, size, symlink target, sha256) and their config. They are identical apart from the edited `backend/Dockerfile` and timestamps in the apt/dpkg logs and the ldconfig cache, which also differ between two builds of unchanged `dev` - [x] Compared the backend `server` target before and after: the first 14 of 17 layers are reused, and every file is identical except the edited `Dockerfile` it ships - [x] Reproduced the CI secret finding with Trivy 0.70.0 and fresh caches: `dev`'s image has 0 findings, this image has 1 (`shahid.py`, in the venv layer) - [x] Checked the ignore entry: with the real entry both CI scans exit 0; a decoy entry for a different rule still exits 1 - [x] Ran the verifier on those real reports: it passes with the ignore entry and fails with `Secrets=1` without it - [x] Ran the single-container unit suite on Linux: 172 passed (163 existing plus 9 new). The layer-order and scan-ignore tests fail on deliberate regressions - [x] Ran `.github/scripts/platform-single-container-smoke.sh` against the new image: all phases passed (boot, contracts, 7 s clean stop, replacement, FalkorDB recovery, watchdog restart) - [x] Ran pre-commit on the changed files <details> <summary>Example test plan</summary> - [ ] Create from scratch and execute an agent with at least 3 blocks - [ ] Import an agent from file upload, and confirm it executes correctly - [ ] Upload agent to marketplace - [ ] Import an agent from marketplace and confirm it executes correctly - [ ] Edit an agent from monitor, and confirm it executes correctly </details> #### For configuration changes: - [x] `.env.default` is updated or already compatible with my changes - [x] `docker-compose.yml` is updated or already compatible with my changes - [x] I have included a list of my configuration changes in the PR description (under **Changes**) <details> <summary>Examples of configuration changes</summary> - Changing ports - Adding new services that need to communicate with each other - Secrets or environment variable changes - New or infrastructure changes such as databases </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 (Claude Code) <[email protected]>
This commit is contained in:
1 parent
a1a2ea56f7
commit
6b97e95ffd
11 files changed
+303
-34
No files matched your search
@@ -20,7 +20,7 @@ def cache_settings(event: str, ref: str, image: str, arch: str) -> list[str]:
|
||||
export = shared
|
||||
settings = [
|
||||
f"{target}.cache-from=type=registry,ref={cache_ref}"
|
||||
for target in ("backend-server", "single-container")
|
||||
for target in ("backend-server-base", "backend-server", "single-container")
|
||||
for cache_ref in imports
|
||||
]
|
||||
if export:
|
||||
|
||||
@@ -116,7 +116,11 @@ def verify_trivy(path: Path, expected_image: str) -> None:
|
||||
for result in results:
|
||||
if not isinstance(result, dict):
|
||||
raise ValueError(f"{path.name} has an invalid result entry")
|
||||
for key in ("Target", "Class", "Type"):
|
||||
# Trivy writes secret results with a Target and Class but no Type.
|
||||
identity = ("Target", "Class", "Type")
|
||||
if result.get("Class") == "secret":
|
||||
identity = ("Target", "Class")
|
||||
for key in identity:
|
||||
if not isinstance(result.get(key), str) or not result[key]:
|
||||
raise ValueError(f"{path.name} result is missing {key}")
|
||||
for key in TRIVY_FINDING_KEYS:
|
||||
|
||||
@@ -206,6 +206,7 @@ jobs:
|
||||
severity: CRITICAL
|
||||
exit-code: "1"
|
||||
timeout: 30m
|
||||
trivyignores: autogpt_platform/single-container/.trivyignore.yaml
|
||||
|
||||
- name: Scan image filesystem for embedded secrets
|
||||
id: secret_scan
|
||||
@@ -219,6 +220,7 @@ jobs:
|
||||
severity: HIGH,CRITICAL
|
||||
exit-code: "1"
|
||||
timeout: 30m
|
||||
trivyignores: autogpt_platform/single-container/.trivyignore.yaml
|
||||
|
||||
- name: Signal concurrent scans complete
|
||||
if: ${{ !cancelled() }}
|
||||
@@ -376,6 +378,7 @@ jobs:
|
||||
severity: CRITICAL
|
||||
exit-code: "1"
|
||||
timeout: 30m
|
||||
trivyignores: autogpt_platform/single-container/.trivyignore.yaml
|
||||
|
||||
- name: Scan the exact pushed digest for embedded secrets
|
||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||
@@ -388,6 +391,7 @@ jobs:
|
||||
severity: HIGH,CRITICAL
|
||||
exit-code: "1"
|
||||
timeout: 30m
|
||||
trivyignores: autogpt_platform/single-container/.trivyignore.yaml
|
||||
|
||||
- name: Export verified digest
|
||||
shell: bash
|
||||
|
||||
@@ -99,7 +99,7 @@ COPY docs /docs
|
||||
RUN find /docs -type f ! -name '*.md' ! -name '*.mdx' -delete \
|
||||
&& find /docs -type d -empty -delete
|
||||
|
||||
FROM debian:13-slim AS server
|
||||
FROM debian:13-slim AS server-base
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -159,6 +159,10 @@ WORKDIR /app/autogpt_platform/backend
|
||||
COPY --from=builder /app/autogpt_platform/backend/.venv ./.venv
|
||||
ENV PATH="/app/autogpt_platform/backend/.venv/bin:$PATH"
|
||||
|
||||
# Dependencies end here; AutoGPT code starts below. The single-container
|
||||
# image builds on server-base so its own dependencies sit under the code.
|
||||
FROM server-base AS server
|
||||
|
||||
# Copy dependency files + autogpt_libs (path dependency)
|
||||
COPY autogpt_platform/autogpt_libs /app/autogpt_platform/autogpt_libs
|
||||
COPY autogpt_platform/backend/poetry.lock autogpt_platform/backend/pyproject.toml ./
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Findings Trivy reports in the single-container image that are not AutoGPT
|
||||
# secrets. Keep every entry to one rule in one third-party file.
|
||||
secrets:
|
||||
- id: aws-access-key-id
|
||||
paths:
|
||||
- "**/site-packages/yt_dlp/extractor/shahid.py"
|
||||
statement: >-
|
||||
yt-dlp's Shahid extractor embeds the site's public client key. Trivy only
|
||||
reports it since the appliance stopped building on the finished backend
|
||||
server image, whose CMD made Trivy treat every backend layer as a base
|
||||
image layer and skip it during secret scanning.
|
||||
@@ -48,12 +48,19 @@ FROM rabbitmq:4.1.8@sha256:b921426eabfb39e201c60f0623f6b45a3160cf97b2dc3af2f5340
|
||||
FROM falkordb/falkordb-server:v4.20.1@sha256:8b676c2e72271f5d06541136eb214b0db50180636cc86f7ee38d63287e9b8d13 AS falkordb
|
||||
|
||||
# autogpt-backend is supplied by Docker Bake from the existing backend
|
||||
# Dockerfile's server target. This keeps the backend image contract independent
|
||||
# from the opt-in single-container distribution.
|
||||
FROM autogpt-backend AS single-container
|
||||
# Dockerfile's server target. The appliance takes only AutoGPT's code from it
|
||||
# (what the server target adds on top of server-base), so drop the dependency
|
||||
# venv that server-base already provides.
|
||||
FROM autogpt-backend AS backend-code
|
||||
|
||||
ARG IMAGE_VERSION=dev
|
||||
ARG VCS_REF=unknown
|
||||
RUN rm -rf /app/autogpt_platform/backend/.venv
|
||||
|
||||
# autogpt-backend-base is supplied by Docker Bake from the backend Dockerfile's
|
||||
# server-base target: the backend image before AutoGPT's code is added. The
|
||||
# appliance stacks its own dependencies on it and adds the code last, so a new
|
||||
# commit only rebuilds and re-stores the code layers. This keeps the backend
|
||||
# image contract independent from the opt-in single-container distribution.
|
||||
FROM autogpt-backend-base AS single-container
|
||||
|
||||
# The normal backend image stores generated Prisma engines under root's cache.
|
||||
# Appliance services run unprivileged, so relocate those inherited engines to a
|
||||
@@ -73,13 +80,6 @@ RUN install -d -m 0755 /opt/prisma-python \
|
||||
&& ln -s "${prisma_node_modules}" /opt/prisma-python/binaries/node_modules \
|
||||
&& ln -s "${query_engine}" /opt/prisma-python/query-engine
|
||||
|
||||
LABEL org.opencontainers.image.title="AutoGPT Platform single-container" \
|
||||
org.opencontainers.image.description="Experimental single-node AutoGPT Platform distribution" \
|
||||
org.opencontainers.image.source="https://github.com/Significant-Gravitas/AutoGPT" \
|
||||
org.opencontainers.image.licenses="LicenseRef-PolyForm-Shield-1.0.0 AND SSPL-1.0" \
|
||||
org.opencontainers.image.version="${IMAGE_VERSION}" \
|
||||
org.opencontainers.image.revision="${VCS_REF}"
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Block service auto-start during package installation; Supervisor owns every
|
||||
@@ -133,7 +133,7 @@ RUN printf '#!/bin/sh\nexit 101\n' > /usr/sbin/policy-rc.d \
|
||||
&& rm -rf /var/lib/apt/lists/* /var/lib/postgresql/15/main
|
||||
|
||||
# Node 24 is required by the frontend. Backend Prisma and agent-browser also
|
||||
# use this binary through PATH; their npm packages remain from the server stage.
|
||||
# use this binary through PATH; their npm packages remain from server-base.
|
||||
COPY --from=frontend /usr/local/bin/node /usr/local/bin/node
|
||||
COPY --from=frontend /usr/local/LICENSE /usr/share/licenses/nodejs/LICENSE
|
||||
RUN rm -rf /usr/lib/node_modules/npm
|
||||
@@ -148,19 +148,6 @@ COPY --from=rabbitmq /opt/rabbitmq /opt/rabbitmq
|
||||
COPY --from=falkordb /usr/local/bin/redis-server /opt/falkordb/redis-server
|
||||
COPY --from=falkordb /var/lib/falkordb/bin/falkordb.so /opt/falkordb/falkordb.so
|
||||
|
||||
# Next standalone server and immutable assets.
|
||||
COPY --from=frontend /app/.next/standalone /app/frontend
|
||||
COPY --from=frontend /app/.next/static /app/frontend/.next/static
|
||||
COPY --from=frontend /app/public /app/frontend/public
|
||||
|
||||
# Next's standalone trace over-includes Orval's build-only esbuild dependency.
|
||||
# It is never used by the production server and must not ship as runtime code.
|
||||
RUN rm -rf \
|
||||
/app/frontend/node_modules/.pnpm/esbuild@* \
|
||||
/app/frontend/node_modules/.pnpm/@esbuild+linux-*@* \
|
||||
&& find /app/frontend/node_modules -type l \
|
||||
\( -name esbuild -o -path '*/node_modules/@esbuild/linux-*' \) -delete
|
||||
|
||||
# Runtime orchestration, proxy, health tooling, and database compatibility shim.
|
||||
COPY autogpt_platform/single-container/bootstrap.sh \
|
||||
autogpt_platform/single-container/common.sh \
|
||||
@@ -207,10 +194,6 @@ RUN groupadd --system --gid 10001 autogpt \
|
||||
&& find /opt/autogpt/single-container -type f \( -name '*.sh' -o -name 'entrypoint' -o -name 'autogpt-*' \) -exec chmod 0755 {} + \
|
||||
&& ln -sf /opt/autogpt/single-container/healthcheck.sh /usr/local/bin/autogpt-healthcheck \
|
||||
&& ln -sf /opt/autogpt/single-container/promote-admin.sh /usr/local/bin/autogpt-admin \
|
||||
&& rm -f /app/autogpt_platform/backend/config.json \
|
||||
&& ln -s /data/config/backend.json /app/autogpt_platform/backend/config.json \
|
||||
&& rm -rf /app/frontend/.next/cache \
|
||||
&& ln -s /data/cache/next /app/frontend/.next/cache \
|
||||
&& site_packages="$(/app/autogpt_platform/backend/.venv/bin/python -c \
|
||||
'import sysconfig; print(sysconfig.get_paths()["purelib"])')" \
|
||||
&& rm -f \
|
||||
@@ -219,6 +202,35 @@ RUN groupadd --system --gid 10001 autogpt \
|
||||
&& test ! -e "${site_packages}/ldclient/testing/selfsigned.key" \
|
||||
&& test ! -e "${site_packages}/ldclient/testing/selfsigned.pem"
|
||||
|
||||
# Everything above changes only when dependencies do. Everything below changes
|
||||
# with AutoGPT's code, so it goes last and a new commit reuses the layers above.
|
||||
|
||||
# Next standalone server and immutable assets.
|
||||
COPY --from=frontend /app/.next/standalone /app/frontend
|
||||
COPY --from=frontend /app/.next/static /app/frontend/.next/static
|
||||
COPY --from=frontend /app/public /app/frontend/public
|
||||
|
||||
# Next's standalone trace over-includes Orval's build-only esbuild dependency.
|
||||
# It is never used by the production server and must not ship as runtime code.
|
||||
RUN rm -rf \
|
||||
/app/frontend/node_modules/.pnpm/esbuild@* \
|
||||
/app/frontend/node_modules/.pnpm/@esbuild+linux-*@* \
|
||||
&& find /app/frontend/node_modules -type l \
|
||||
\( -name esbuild -o -path '*/node_modules/@esbuild/linux-*' \) -delete \
|
||||
&& rm -rf /app/frontend/.next/cache \
|
||||
&& ln -s /data/cache/next /app/frontend/.next/cache
|
||||
|
||||
# AutoGPT's backend code and docs, then its entry points (rest, executor, ...),
|
||||
# installed the same way the backend server target does on top of server-base.
|
||||
WORKDIR /app/autogpt_platform/backend
|
||||
COPY --from=backend-code /app/autogpt_platform/autogpt_libs /app/autogpt_platform/autogpt_libs
|
||||
COPY --from=backend-code /app/autogpt_platform/backend /app/autogpt_platform/backend
|
||||
COPY --from=backend-code /app/docs /app/docs
|
||||
RUN POETRY_VIRTUALENVS_CREATE=true POETRY_VIRTUALENVS_IN_PROJECT=true \
|
||||
poetry install --no-ansi --only-root \
|
||||
&& rm -f /app/autogpt_platform/backend/config.json \
|
||||
&& ln -s /data/config/backend.json /app/autogpt_platform/backend/config.json
|
||||
|
||||
ENV PATH="/opt/rabbitmq/sbin:/opt/erlang/bin:/opt/openssl/bin:/usr/lib/postgresql/15/bin:/app/autogpt_platform/backend/.venv/bin:${PATH}" \
|
||||
ERLANG_INSTALL_PATH_PREFIX=/opt/erlang \
|
||||
OPENSSL_INSTALL_PATH_PREFIX=/opt/openssl \
|
||||
@@ -240,6 +252,18 @@ ENV PATH="/opt/rabbitmq/sbin:/opt/erlang/bin:/opt/openssl/bin:/usr/lib/postgresq
|
||||
FORCE_FLAG_GRAPHITI_MEMORY=true \
|
||||
AUTOGPT_PUBLIC_URL=http://localhost:3000
|
||||
|
||||
# Build arguments change on every build, so they are declared only after the
|
||||
# last layer-producing step to keep them out of the layer cache keys.
|
||||
ARG IMAGE_VERSION=dev
|
||||
ARG VCS_REF=unknown
|
||||
|
||||
LABEL org.opencontainers.image.title="AutoGPT Platform single-container" \
|
||||
org.opencontainers.image.description="Experimental single-node AutoGPT Platform distribution" \
|
||||
org.opencontainers.image.source="https://github.com/Significant-Gravitas/AutoGPT" \
|
||||
org.opencontainers.image.licenses="LicenseRef-PolyForm-Shield-1.0.0 AND SSPL-1.0" \
|
||||
org.opencontainers.image.version="${IMAGE_VERSION}" \
|
||||
org.opencontainers.image.revision="${VCS_REF}"
|
||||
|
||||
WORKDIR /app/autogpt_platform/backend
|
||||
|
||||
VOLUME ["/data"]
|
||||
|
||||
@@ -2,6 +2,12 @@ group "default" {
|
||||
targets = ["single-container"]
|
||||
}
|
||||
|
||||
target "backend-server-base" {
|
||||
context = "."
|
||||
dockerfile = "autogpt_platform/backend/Dockerfile"
|
||||
target = "server-base"
|
||||
}
|
||||
|
||||
target "backend-server" {
|
||||
context = "."
|
||||
dockerfile = "autogpt_platform/backend/Dockerfile"
|
||||
@@ -13,7 +19,8 @@ target "single-container" {
|
||||
dockerfile = "autogpt_platform/single-container/Dockerfile"
|
||||
target = "single-container"
|
||||
contexts = {
|
||||
autogpt-backend = "target:backend-server"
|
||||
autogpt-backend-base = "target:backend-server-base"
|
||||
autogpt-backend = "target:backend-server"
|
||||
}
|
||||
tags = ["autogpt-platform:single-container-dev"]
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import importlib.util
|
||||
import re
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
@@ -47,6 +48,18 @@ class RegistryCacheTests(unittest.TestCase):
|
||||
self.settings("workflow_dispatch", "refs/heads/two"),
|
||||
)
|
||||
|
||||
def test_every_bake_target_reads_the_validation_cache(self):
|
||||
bake = (
|
||||
SCRIPT.parents[2] / "autogpt_platform/single-container/docker-bake.hcl"
|
||||
).read_text(encoding="utf-8")
|
||||
targets = re.findall(r'^target "([^"]+)"', bake, re.MULTILINE)
|
||||
self.assertIn("backend-server-base", targets)
|
||||
settings = self.settings("push")
|
||||
for target in targets:
|
||||
self.assertIn(
|
||||
f"{target}.cache-from=type=registry,ref={IMAGE}:v4-amd64", settings
|
||||
)
|
||||
|
||||
def test_validation_cache_reads_are_architecture_scoped(self):
|
||||
settings = self.settings("release", "refs/tags/v1")
|
||||
self.assertTrue(all("cache-from" in s and ":v4-amd64" in s for s in settings))
|
||||
|
||||
@@ -198,6 +198,55 @@ class ReportVerifierTests(unittest.TestCase):
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("Vulnerabilities=1", result.stdout)
|
||||
|
||||
def add_result(self, name, result):
|
||||
path = self.report_dir / name
|
||||
report = json.loads(path.read_text(encoding="utf-8"))
|
||||
report["Results"].append(result)
|
||||
path.write_text(json.dumps(report), encoding="utf-8")
|
||||
|
||||
def test_clean_secret_results_need_no_type(self):
|
||||
# Trivy keeps a secret result for a file whose findings were all
|
||||
# filtered out by severity or an ignore entry, and never gives it a Type.
|
||||
for name in ("trivy-critical.json", "trivy-secrets.json"):
|
||||
self.add_result(
|
||||
name,
|
||||
{
|
||||
"Target": "/app/vendor/extractor.py",
|
||||
"Class": "secret",
|
||||
"Secrets": [],
|
||||
},
|
||||
)
|
||||
|
||||
result = self.run_verifier()
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
def test_secret_findings_are_rejected(self):
|
||||
self.add_result(
|
||||
"trivy-secrets.json",
|
||||
{
|
||||
"Target": "/app/leak.py",
|
||||
"Class": "secret",
|
||||
"Secrets": [{"RuleID": "aws-access-key-id"}],
|
||||
},
|
||||
)
|
||||
|
||||
result = self.run_verifier()
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("Secrets=1", result.stdout)
|
||||
|
||||
def test_package_result_without_type_is_rejected(self):
|
||||
self.add_result(
|
||||
"trivy-critical.json",
|
||||
{"Target": "Python", "Class": "lang-pkgs", "Vulnerabilities": []},
|
||||
)
|
||||
|
||||
result = self.run_verifier()
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("result is missing Type", result.stdout)
|
||||
|
||||
def test_non_trivy_json_is_rejected(self):
|
||||
(self.report_dir / "trivy-secrets.json").write_text("{}", encoding="utf-8")
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
WORKFLOW_PATH = (
|
||||
REPO_ROOT / ".github" / "workflows" / "platform-single-container-docker.yml"
|
||||
)
|
||||
IGNORE_FILE = "autogpt_platform/single-container/.trivyignore.yaml"
|
||||
|
||||
|
||||
class ScanIgnoreTest(unittest.TestCase):
|
||||
def test_every_trivy_scan_uses_the_shared_ignore_file(self) -> None:
|
||||
# The CI build and the release digest are scanned by separate steps; an
|
||||
# entry that only one of them honours fails the other on a false positive.
|
||||
steps = WORKFLOW_PATH.read_text(encoding="utf-8").split("- name: ")[1:]
|
||||
scans = [step for step in steps if "aquasecurity/trivy-action@" in step]
|
||||
self.assertGreaterEqual(len(scans), 4)
|
||||
for step in scans:
|
||||
self.assertIn(f"trivyignores: {IGNORE_FILE}", step, step.splitlines()[0])
|
||||
self.assertTrue((REPO_ROOT / IGNORE_FILE).is_file())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,127 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unittest
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
ASSET_DIR = Path(__file__).resolve().parents[1]
|
||||
APPLIANCE_DOCKERFILE = ASSET_DIR / "Dockerfile"
|
||||
BACKEND_DOCKERFILE = ASSET_DIR.parent / "backend" / "Dockerfile"
|
||||
BAKE_PATH = ASSET_DIR / "docker-bake.hcl"
|
||||
|
||||
SERVER_BASE = "FROM debian:13-slim AS server-base"
|
||||
SERVER = "FROM server-base AS server"
|
||||
APPLIANCE = "FROM autogpt-backend-base AS single-container"
|
||||
LAYER_STEPS = {"ADD", "COPY", "RUN"}
|
||||
|
||||
|
||||
def instructions(path: Path) -> list[str]:
|
||||
"""Return a Dockerfile's instructions with continuation lines joined."""
|
||||
result: list[str] = []
|
||||
current = ""
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue
|
||||
current = f"{current} {stripped}" if current else stripped
|
||||
if current.endswith("\\"):
|
||||
current = current[:-1].rstrip()
|
||||
continue
|
||||
result.append(re.sub(r"\s+", " ", current))
|
||||
current = ""
|
||||
return result
|
||||
|
||||
|
||||
def stage(lines: list[str], header: str) -> list[str]:
|
||||
start = lines.index(header) + 1
|
||||
end = next(
|
||||
(i for i in range(start, len(lines)) if lines[i].startswith("FROM ")),
|
||||
len(lines),
|
||||
)
|
||||
return lines[start:end]
|
||||
|
||||
|
||||
def run_commands(lines: list[str]) -> set[str]:
|
||||
return {
|
||||
command.strip()
|
||||
for line in lines
|
||||
if line.startswith("RUN ")
|
||||
for command in line.removeprefix("RUN ").split("&&")
|
||||
}
|
||||
|
||||
|
||||
class ApplianceLayerOrderTest(unittest.TestCase):
|
||||
"""The appliance stacks its dependencies under AutoGPT's code.
|
||||
|
||||
It builds on the backend's server-base target instead of the finished
|
||||
server image, so the layers that only change with dependencies are reused
|
||||
by every build, and a new commit only re-stores the code layers on top.
|
||||
"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.backend = instructions(BACKEND_DOCKERFILE)
|
||||
self.appliance = stage(instructions(APPLIANCE_DOCKERFILE), APPLIANCE)
|
||||
|
||||
def test_server_base_holds_dependencies_only(self) -> None:
|
||||
for line in stage(self.backend, SERVER_BASE):
|
||||
if line.startswith(("COPY ", "ADD ")):
|
||||
self.assertTrue(
|
||||
line.startswith("COPY --from=builder "),
|
||||
f"server-base must not copy AutoGPT code: {line}",
|
||||
)
|
||||
|
||||
def test_appliance_adds_everything_the_server_target_adds(self) -> None:
|
||||
copied_from_backend = {
|
||||
parts[-1]
|
||||
for parts in (line.split() for line in self.appliance)
|
||||
if parts[:2] == ["COPY", "--from=backend-code"] and parts[2] == parts[-1]
|
||||
}
|
||||
commands = run_commands(self.appliance)
|
||||
|
||||
workdir = PurePosixPath("/")
|
||||
for line in stage(self.backend, SERVER_BASE):
|
||||
if line.startswith("WORKDIR "):
|
||||
workdir = workdir / line.split()[1]
|
||||
for line in stage(self.backend, SERVER):
|
||||
keyword, *arguments = line.split()
|
||||
if keyword == "WORKDIR":
|
||||
workdir = workdir / arguments[0]
|
||||
elif keyword in {"COPY", "ADD"}:
|
||||
destination = str(workdir / arguments[-1])
|
||||
self.assertTrue(
|
||||
any(
|
||||
destination == copied or destination.startswith(f"{copied}/")
|
||||
for copied in copied_from_backend
|
||||
),
|
||||
f"the appliance never copies {destination} from backend-code",
|
||||
)
|
||||
elif keyword == "RUN":
|
||||
self.assertIn(line.removeprefix("RUN "), commands)
|
||||
|
||||
def test_build_arguments_follow_every_layer(self) -> None:
|
||||
arguments = [
|
||||
i for i, line in enumerate(self.appliance) if line.startswith("ARG ")
|
||||
]
|
||||
self.assertTrue(arguments, "the appliance declares its build arguments")
|
||||
self.assertEqual(
|
||||
[
|
||||
line
|
||||
for line in self.appliance[min(arguments) :]
|
||||
if line.split()[0] in LAYER_STEPS
|
||||
],
|
||||
[],
|
||||
"build arguments change every build and would invalidate later layers",
|
||||
)
|
||||
|
||||
def test_bake_supplies_server_base(self) -> None:
|
||||
bake = BAKE_PATH.read_text(encoding="utf-8")
|
||||
self.assertRegex(
|
||||
bake, r'target "backend-server-base" \{[^}]*target\s*=\s*"server-base"'
|
||||
)
|
||||
self.assertRegex(
|
||||
bake, r'autogpt-backend-base\s*=\s*"target:backend-server-base"'
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user