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:
Nicholas TindleandClaude Opus 5.5 authored and GitHub committed 2026-09-28 21:12:32 +00:00
1 parent a1a2ea56f7
commit 6b97e95ffd
11 files changed
+303 -34

No files matched your search

+1 -1
View File
@@ -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
+5 -1
View File
@@ -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.
+54 -30
View File
@@ -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()