Skip to content

Commit 33284d2

Browse files
Bake app code into image; pin dev to :main, prod by digest (Fixes #169) (#171)
* build: add .dockerignore to keep node_modules/.venv/.git out of image context * build: bake application code into image (COPY . /app); drop runtime git clone dependency * ci: build image on push to main + release tags; tag :main/:sha/:vX.Y.Z; cron refreshes deps via no-cache * docs: rewrite rollout workflow for baked-image model (dev :main, prod digest-pinned) * build: fix .dockerignore excluding the tiles/ runtime package; trim non-runtime dirs; clarify cron cache comment * docs: add baked-images deployment-pinning implementation plan --------- Co-authored-by: Carl Boettiger <cboettig@berkeley.edu>
1 parent 51d8fda commit 33284d2

5 files changed

Lines changed: 594 additions & 32 deletions

File tree

.dockerignore

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
.git
2+
.github
3+
.venv
4+
node_modules
5+
**/__pycache__
6+
*.pyc
7+
.pytest_cache
8+
.worktrees
9+
.claude
10+
.roo
11+
.continue
12+
.vscode
13+
benchmarks
14+
docs
15+
examples
16+
memory
17+
tests

.github/workflows/docker.yml

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,7 @@ name: Build Docker image
33
on:
44
push:
55
branches: [main]
6-
paths:
7-
- Dockerfile
8-
- .github/workflows/docker.yml
6+
tags: ['v*']
97
schedule:
108
- cron: '0 6 * * 1' # Mondays 06:00 UTC — refresh base image + unpinned deps
119
workflow_dispatch:
@@ -26,24 +24,57 @@ jobs:
2624
username: ${{ github.actor }}
2725
password: ${{ secrets.GITHUB_TOKEN }}
2826

27+
- name: Set up Buildx
28+
uses: docker/setup-buildx-action@v3
29+
2930
- name: Compute image tags
3031
id: tags
3132
run: |
32-
# Always tag :latest. Only tag :<sha> on push events so scheduled
33-
# rebuilds don't mutate an immutable content-addressed tag.
34-
tags="ghcr.io/boettiger-lab/mcp-data-server:latest"
35-
if [ "${{ github.event_name }}" = "push" ]; then
36-
tags="$tags"$'\n'"ghcr.io/boettiger-lab/mcp-data-server:${{ github.sha }}"
37-
fi
33+
repo=ghcr.io/boettiger-lab/mcp-data-server
34+
case "${{ github.event_name }}" in
35+
push)
36+
if [ "${{ github.ref_type }}" = "tag" ]; then
37+
# Release tag (vX.Y.Z): immutable version tag + immutable sha.
38+
tags="$repo:${{ github.ref_name }}"$'\n'"$repo:${{ github.sha }}"
39+
else
40+
# Push to main: moving dev tag + immutable sha.
41+
tags="$repo:main"$'\n'"$repo:${{ github.sha }}"
42+
fi
43+
;;
44+
*)
45+
# schedule / workflow_dispatch: dep+base refresh. Move ONLY the dev
46+
# tag — never mint a :sha or :vX.Y.Z from a non-push event.
47+
tags="$repo:main"
48+
;;
49+
esac
3850
{
3951
echo "tags<<EOF"
4052
echo "$tags"
4153
echo "EOF"
4254
} >> "$GITHUB_OUTPUT"
4355
4456
- name: Build and push
57+
id: build
4558
uses: docker/build-push-action@v6
4659
with:
4760
context: .
4861
push: true
4962
tags: ${{ steps.tags.outputs.tags }}
63+
# Pushes reuse the cached deps layer (fast code-only builds). The weekly
64+
# cron sets no-cache + pull so it re-resolves unpinned deps and pulls a
65+
# fresh base image (security patches); cache-to still warms the cache.
66+
# (When no-cache=true, cache-from is ignored by design; cache-to is kept
67+
# so the next code-push still gets a warm cache.)
68+
cache-from: type=gha
69+
cache-to: type=gha,mode=max
70+
no-cache: ${{ github.event_name == 'schedule' }}
71+
pull: ${{ github.event_name == 'schedule' }}
72+
73+
- name: Report image digest
74+
run: |
75+
{
76+
echo "### Image digest"
77+
echo '```'
78+
echo "${{ steps.build.outputs.digest }}"
79+
echo '```'
80+
} >> "$GITHUB_STEP_SUMMARY"

AGENTS.md

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -50,26 +50,44 @@ This is intentional and load-bearing. Several things depend on it:
5050

5151
### Rollout workflow
5252

53-
**Merge to `main` → redeploy dev only:**
53+
Application code is **baked into the image** (`COPY . /app` in the `Dockerfile`); pods no
54+
longer `git clone` at startup. `docker.yml` builds on every push to `main` and on `vX.Y.Z`
55+
release tags. The image is the unit of release.
56+
57+
**Tags CI produces:**
58+
- `:main` — moving; rebuilt on every push to `main` and by the weekly cron. **dev** tracks this.
59+
- `:<git-sha>` — immutable; one per commit.
60+
- `:vX.Y.Z` — immutable; built on release tags. **prod** pins this (by digest, below).
61+
62+
**Merge to `main` → redeploy dev:**
5463
```
5564
kubectl apply -f k8s/dev-deployment.yaml
5665
kubectl rollout restart deployment/dev-duckdb-mcp -n biodiversity
5766
```
58-
59-
**Tag a release → redeploy prod:**
67+
dev pins `:main` with `imagePullPolicy: Always`, so the restart pulls the freshest build.
68+
**Wait for the `docker.yml` run on your merge to go green first** — rolling before the
69+
image is pushed gives `ImagePullBackOff`.
70+
71+
**Tag a release → redeploy prod (promote by digest):**
72+
1. `git tag vX.Y.Z && git push origin vX.Y.Z`, then wait for `docker.yml` to build `:vX.Y.Z`.
73+
2. Read the digest from the build run's job summary, or:
74+
`docker buildx imagetools inspect ghcr.io/boettiger-lab/mcp-data-server:vX.Y.Z --format '{{.Manifest.Digest}}'`
75+
3. Set `image: ghcr.io/boettiger-lab/mcp-data-server:vX.Y.Z@sha256:<digest>` in
76+
`k8s/deployment.yaml` (separate commit).
77+
4. `kubectl apply -f k8s/deployment.yaml`
78+
5. `kubectl rollout restart deployment/duckdb-mcp -n biodiversity`
79+
80+
prod pins an immutable `:vX.Y.Z@sha256:…` (tag for humans, digest enforced — if they ever
81+
disagree, the digest wins). **Never apply prod while the manifest points at an image CI
82+
hasn't built yet** — the rollout stalls on `ImagePullBackOff`. `kubectl apply` must precede
83+
`rollout restart`; a git push alone does not update the cluster.
84+
85+
Verify all prod replicas converge on a single digest after rollout:
6086
```
61-
kubectl apply -f k8s/deployment.yaml
62-
kubectl rollout restart deployment/duckdb-mcp -n biodiversity
87+
kubectl -n biodiversity get pods -l app=duckdb-mcp \
88+
-o custom-columns='NAME:.metadata.name,IMAGE:.status.containerStatuses[0].imageID'
6389
```
6490

65-
Prod clones a pinned tag, so a release is two steps: **first** bump the `--branch vX.Y.Z`
66-
pin in `k8s/deployment.yaml` to the new tag (separate commit, as in #151), **then** apply.
67-
Never `kubectl apply -f k8s/deployment.yaml` to prod while the pin still points at the
68-
previous tag — `deployment.yaml` changes (e.g. new `/healthz` probes) can reference code
69-
the pinned tag doesn't yet have, and the rollout will stall on failing probes.
70-
71-
`kubectl apply` must precede `rollout restart` — a git push alone does not update the cluster.
72-
7391
---
7492

7593
This project uses two distinct, asynchronous AI agent processes. Do not confuse them.

Dockerfile

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,25 @@
11
FROM python:3.12-slim
22

3+
# ca-certificates for HTTPS to S3 / STAC. git is intentionally NOT installed:
4+
# code is baked into the image (COPY below), not cloned at pod startup.
35
RUN apt-get update \
4-
&& apt-get install -y --no-install-recommends git ca-certificates \
6+
&& apt-get install -y --no-install-recommends ca-certificates \
57
&& rm -rf /var/lib/apt/lists/*
68

7-
RUN pip install --no-cache-dir \
8-
mcp \
9-
duckdb \
10-
pandas \
11-
uvicorn \
12-
tabulate \
13-
pystac \
14-
requests
9+
WORKDIR /app
10+
11+
# Dependency layer — re-resolves ONLY when requirements.txt changes, so code-only
12+
# rebuilds reuse it (with GHA cache, those finish in seconds). The weekly cron
13+
# builds with --no-cache so this layer actually re-resolves the unpinned deps.
14+
COPY requirements.txt .
15+
RUN pip install --no-cache-dir -r requirements.txt
1516

1617
# Pre-install DuckDB extensions so pods start offline-capable.
1718
# Uses the default extension directory (~/.duckdb/extensions/) which the runtime
1819
# will find automatically since the container runs as root.
1920
RUN python -c "import duckdb; c = duckdb.connect(); c.sql('INSTALL httpfs; INSTALL spatial; INSTALL h3 FROM community')"
2021

21-
WORKDIR /app
22+
# Application code last, so a code change rebuilds only this cheap layer.
23+
COPY . /app
24+
25+
CMD ["python", "server.py"]

0 commit comments

Comments
 (0)