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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ docker compose config > /dev/null

Extended documentation in the skill `references/` directories:

- `docker-development/references/ci-testing.md` - Comprehensive CI testing patterns
- `docker-development/references/ci-testing.md` - Retro-born CI testing gotchas
- `docker-via-wsl/references/diagnosis-and-fix.md` - Diagnose and fix a wrong bind mount caused by driving Docker from a Windows shell

## Contributing
Expand Down
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Main entry point loaded by agent frameworks. Contains:

### Reference Docs (`skills/docker-development/references/`)

- **ci-testing.md** — comprehensive CI testing patterns for container images
- **ci-testing.md** — retro-born CI testing gotchas for container images
- **dind-testing-patterns.md** — Docker-in-Docker testing strategies

### Checkpoints (`skills/docker-development/checkpoints.yaml`)
Expand Down
6 changes: 3 additions & 3 deletions skills/docker-development/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,13 @@ Patterns for building, testing, and deploying Docker containers.
### Multi-Stage Build (Node.js)

```dockerfile
FROM node:20-alpine AS builder
FROM node:24-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .

FROM node:20-alpine
FROM node:24-alpine
Comment thread
CybotTM marked this conversation as resolved.
RUN addgroup -g 1001 app && adduser -u 1001 -G app -D app
USER app
COPY --from=builder /app .
Expand All @@ -50,7 +50,7 @@ CMD ["node", "server.js"]
### Multi-Stage Build (Go -- scratch/distroless)

```dockerfile
FROM golang:1.22-alpine AS builder
FROM golang:1.26-alpine AS builder
Comment thread
CybotTM marked this conversation as resolved.
WORKDIR /app
COPY go.* ./
RUN go mod download
Expand Down
305 changes: 9 additions & 296 deletions skills/docker-development/references/ci-testing.md
Original file line number Diff line number Diff line change
@@ -1,157 +1,10 @@
# CI Testing Patterns for Docker Images

Comprehensive patterns for testing Docker images in CI/CD pipelines.
Deeper CI/CD gotchas beyond the basics. For entrypoint bypass, DNS mocking,
compose validation, and secret-scan exclusions, see SKILL.md's Quick
Reference § CI Testing Gotchas.

## The Challenge

Docker images often have:
- **Entrypoint scripts** that run before any command
- **Service dependencies** (databases, caches) that don't exist in CI
- **Network configurations** referencing other containers
- **Required environment variables** that fail validation

These cause CI failures that don't occur in local development.

## Pattern 1: Entrypoint Bypass

### Problem

```dockerfile
# Dockerfile
ENTRYPOINT ["/entrypoint.sh"]
CMD ["php-fpm"]
```

```yaml
# CI - FAILS
- run: docker run --rm myimage php -v
# Output: entrypoint.sh runs, starts services, php -v never executes properly
```

### Solution

```yaml
# Override entrypoint for direct command execution
- run: docker run --rm --entrypoint php myimage -v
- run: docker run --rm --entrypoint php myimage -m # List modules
- run: docker run --rm --entrypoint node myimage --version
- run: docker run --rm --entrypoint /bin/sh myimage -c "cat /etc/os-release"
```

### When to Use

- Verifying installed software versions
- Checking available extensions/modules
- Testing configuration files
- Running diagnostic commands

## Pattern 2: DNS Mocking for Upstream Services

### Problem

```nginx
# nginx.conf
upstream backend {
server app:9000;
}
```

```yaml
# CI - FAILS
- run: docker run --rm nginx-image nginx -t
# Error: host not found in upstream "app:9000"
```

### Solution

```yaml
# Provide fake DNS resolution for upstream hosts
- run: |
docker run --rm \
--add-host app:127.0.0.1 \
--add-host database:127.0.0.1 \
--add-host cache:127.0.0.1 \
nginx-image nginx -t
```

### Multiple Upstreams

```yaml
- name: Test nginx config
run: |
docker run --rm \
--add-host php-fpm:127.0.0.1 \
--add-host mailpit:127.0.0.1 \
--add-host redis:127.0.0.1 \
myapp-nginx nginx -t
```

## Pattern 3: Docker Compose Validation

### Problem

```yaml
# compose.yml
services:
app:
environment:
- DB_PASSWORD=${DB_PASSWORD:?Required}
```

```yaml
# CI - FAILS
- run: docker compose config
# Error: required variable DB_PASSWORD is missing
```

### Solution

```yaml
- name: Create test environment
run: |
cp .env.example .env
# Replace all CHANGE_ME placeholders
sed -i 's/CHANGE_ME_[A-Z_]*/test_password/g' .env

- name: Validate compose syntax
run: docker compose config > /dev/null
```

### Alternative: Inline Variables

```yaml
- name: Validate compose
env:
DB_PASSWORD: test
REDIS_PASSWORD: test
run: docker compose config > /dev/null
```

## Pattern 4: Health Check Verification

### Test Health Check Command

```yaml
- name: Build image
run: docker build -t myapp:test .

- name: Test health check
run: |
# Start container
docker run -d --name test-container myapp:test

# Wait for health
timeout 60 bash -c 'until docker inspect test-container --format="{{.State.Health.Status}}" | grep -q healthy; do sleep 2; done'

# Verify
docker inspect test-container --format="{{.State.Health.Status}}"

- name: Cleanup
if: always()
run: docker rm -f test-container
```

### Worker/Sidecar Services That Reuse an App Image
## Pattern 1: Worker/Sidecar Services That Reuse an App Image

A compose service that reuses the app image (e.g. a queue worker on the
php-fpm+nginx web image) **inherits the image's baked-in `HEALTHCHECK`**.
Expand Down Expand Up @@ -191,93 +44,9 @@ Prefer letting a worker exit on its own limits (`exec` the daemon as PID 1,
restart policy with backoff) over in-container `while true ... || true`
loops that mask fatal errors from orchestration.

## Pattern 5: Secret Scanning with Exclusions
## Pattern 2: GitLab CI — image entrypoint must be a shell (or be overridden)

### Problem

Documentation references placeholder passwords:

```markdown
<!-- README.md -->
Set `DB_PASSWORD=CHANGE_ME` in your .env file
```

```yaml
# CI - FALSE POSITIVE
- run: git ls-files | xargs grep -l "CHANGE_ME" && exit 1
# Fails on README.md, QUICKSTART.md, etc.
```

### Solution

```yaml
- name: Check for leaked secrets
run: |
# Files that legitimately reference placeholders
EXCLUDE_PATTERN=".env.example|README|QUICKSTART|docs/|Makefile|\.github/"

# Find files with secrets, excluding legitimate references
FOUND=$(git ls-files | xargs grep -l "CHANGE_ME" | grep -vE "$EXCLUDE_PATTERN" || true)

if [ -n "$FOUND" ]; then
echo "Found secrets in:"
echo "$FOUND"
exit 1
fi
echo "No leaked secrets detected"
```

## Pattern 6: Multi-Platform Build Testing

```yaml
- name: Set up QEMU
uses: docker/setup-qemu-action@v3

- name: Set up Buildx
uses: docker/setup-buildx-action@v3

- name: Build multi-platform
uses: docker/build-push-action@v6
with:
platforms: linux/amd64,linux/arm64
load: false # Can't load multi-platform
cache-from: type=gha
cache-to: type=gha,mode=max
```

## Pattern 7: Integration Testing with Service Containers

```yaml
jobs:
test:
services:
database:
image: mariadb:11
env:
MYSQL_ROOT_PASSWORD: test
MYSQL_DATABASE: testdb
options: >-
--health-cmd="healthcheck.sh --connect --innodb_initialized"
--health-interval=10s
--health-timeout=5s
--health-retries=5

steps:
- name: Build app image
run: docker build -t myapp:test .

- name: Run integration tests
run: |
docker run --rm \
--network ${{ job.container.network }} \
-e DB_HOST=database \
-e DB_PASSWORD=test \
myapp:test npm test
```

## Pattern 8: GitLab CI — image entrypoint must be a shell (or be overridden)

Unlike a test-time `--entrypoint` bypass (Pattern 1), GitLab **runs every job's `script:` via `sh -c`**. If the image used as a job `image:` has a non-shell `ENTRYPOINT ["mytool"]`, the runner effectively runs `mytool sh -c '…'` → **`No such command 'sh'`**, and the job fails before the script runs.
Unlike a test-time `--entrypoint` bypass, GitLab **runs every job's `script:` via `sh -c`**. If the image used as a job `image:` has a non-shell `ENTRYPOINT ["mytool"]`, the runner effectively runs `mytool sh -c '…'` → **`No such command 'sh'`**, and the job fails before the script runs.

```yaml
job:
Expand All @@ -290,7 +59,7 @@ job:

A CLI image also meant for `docker run mytool …` can keep `ENTRYPOINT ["mytool"]`, but **document** that GitLab consumers must set `entrypoint: [""]`. If the image is *primarily* a CI image, prefer no tool entrypoint (use `CMD`).

## Pattern 9: Restricted runner egress — bundle external assets at build time
## Pattern 3: Restricted runner egress — bundle external assets at build time

CI runners (especially internal/self-hosted) often have **no outbound internet**. An image that fetches something at *runtime* (`page.add_script_tag(url="https://cdn…/axe.min.js")`, `curl https://…` in the entrypoint, a remote `pip`/`npm` install) works locally but fails in CI.

Expand All @@ -312,7 +81,7 @@ ENV AXE_PATH=/opt/axe-core/axe.min.js

…and have the app prefer the local file (CDN as a dev-only fallback).

## Pattern 10: Test the *built image*, not just the editable dev install
## Pattern 4: Test the *built image*, not just the editable dev install

A non-editable install in the image (`pip install .`, `npm install <tarball>`) does not behave like the editable/dev checkout your tests ran against. Classic failure: **data files resolved by walking from `__file__`** (`Path(__file__).resolve().parents[2]/"data"/…`) don't exist under `site-packages`, so the tool can't find its catalog/config inside the container even though `pytest` was green.

Expand All @@ -325,7 +94,7 @@ A non-editable install in the image (`pip install .`, `npm install <tarball>`) d
- run: docker run --rm app:test render fixture.json /tmp/out # real command, end-to-end
```

## Pattern 11: Bake targets must inherit `docker-metadata-action`
## Pattern 5: Bake targets must inherit `docker-metadata-action`

### Problem

Expand Down Expand Up @@ -380,62 +149,6 @@ docker buildx bake --print
docker buildx bake -f docker-bake.hcl -f /tmp/metadata-bake.json --print
```

## Complete CI Workflow Example

```yaml
name: Docker CI

on: [push, pull_request]

jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Setup test environment
run: |
cp .env.example .env
sed -i 's/CHANGE_ME_[A-Z_]*/ci_test_value/g' .env

- name: Validate compose
run: docker compose config > /dev/null

build-and-test:
runs-on: ubuntu-latest
needs: validate
steps:
- uses: actions/checkout@v4

- uses: docker/setup-buildx-action@v3

- name: Build images
run: docker compose build

- name: Test PHP version
run: docker run --rm --entrypoint php myapp:latest -v | grep "8.4"

- name: Test nginx config
run: docker run --rm --add-host app:127.0.0.1 myapp-nginx nginx -t

- name: Start stack
run: |
cp .env.example .env
sed -i 's/CHANGE_ME_[A-Z_]*/ci_test/g' .env
docker compose up -d

- name: Wait for healthy
run: |
timeout 120 bash -c 'until docker compose ps | grep -q healthy; do sleep 5; done'

- name: Test endpoint
run: curl -f http://localhost/ || exit 1

- name: Cleanup
if: always()
run: docker compose down -v
```

## Local boot-test pitfalls

When smoke/boot-testing an image by hand (not in the CI matrix):
Expand Down
Loading