Feat: Add production-grade multi-stage container image for UI
Two-stage build (node:22-slim builder + nginxinc/nginx-unprivileged:alpine runtime) with SPA fallback routing, long-lived cache headers for fingerprinted assets, non-root user (UID 101), and no Node.js toolchain in runtime image (82 MB vs 329 MB+ single-stage). Verified by ui/tests/build/verify_production_image.sh covering build, health, SPA routing, non-root, stdout logging, cache-control headers, SIGTERM exit 0, Node.js absent, secret-free layers, and dep-layer cache hit. 102 integration tests still pass; shellcheck clean. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -17,6 +17,7 @@ venv/
|
||||
dist/
|
||||
build/
|
||||
!api/tests/build/
|
||||
!ui/tests/build/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.coverage
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"feature_directory": "specs/010-api-prod-dockerfile"
|
||||
"feature_directory": "specs/011-ui-prod-dockerfile"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- SPECKIT START -->
|
||||
For additional context about technologies to be used, project structure,
|
||||
shell commands, and other important information, read the current plan at
|
||||
`specs/010-api-prod-dockerfile/plan.md`.
|
||||
`specs/011-ui-prod-dockerfile/plan.md`.
|
||||
<!-- SPECKIT END -->
|
||||
|
||||
8
Makefile
8
Makefile
@@ -1,4 +1,4 @@
|
||||
.PHONY: test-unit test-integration build-prod verify-prod
|
||||
.PHONY: test-unit test-integration build-prod verify-prod build-ui-prod verify-ui-prod
|
||||
|
||||
test-unit:
|
||||
cd api && python -m pytest tests/unit/ -v
|
||||
@@ -11,3 +11,9 @@ build-prod:
|
||||
|
||||
verify-prod:
|
||||
bash api/tests/build/verify_production_image.sh
|
||||
|
||||
build-ui-prod:
|
||||
docker build -f ui/Dockerfile.prod ui/ -t reactbin-ui-prod:latest
|
||||
|
||||
verify-ui-prod:
|
||||
bash ui/tests/build/verify_production_image.sh
|
||||
|
||||
34
specs/011-ui-prod-dockerfile/checklists/requirements.md
Normal file
34
specs/011-ui-prod-dockerfile/checklists/requirements.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Specification Quality Checklist: Production-Grade UI Container Image
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-05-07
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [X] No implementation details (languages, frameworks, APIs)
|
||||
- [X] Focused on user value and business needs
|
||||
- [X] Written for non-technical stakeholders
|
||||
- [X] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [X] No [NEEDS CLARIFICATION] markers remain
|
||||
- [X] Requirements are testable and unambiguous
|
||||
- [X] Success criteria are measurable
|
||||
- [X] Success criteria are technology-agnostic (no implementation details)
|
||||
- [X] All acceptance scenarios are defined
|
||||
- [X] Edge cases are identified
|
||||
- [X] Scope is clearly bounded
|
||||
- [X] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [X] All functional requirements have clear acceptance criteria
|
||||
- [X] User scenarios cover primary flows
|
||||
- [X] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [X] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- All items pass. Spec is ready for `/speckit-plan`.
|
||||
90
specs/011-ui-prod-dockerfile/contracts/container.md
Normal file
90
specs/011-ui-prod-dockerfile/contracts/container.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# Container Interface Contract: UI Production Image
|
||||
|
||||
## Image Identity
|
||||
|
||||
| Property | Value |
|
||||
|-------------|------------------------------|
|
||||
| Image name | `reactbin-ui-prod` |
|
||||
| Runtime | nginx-unprivileged (Alpine) |
|
||||
| Listen port | `8080` |
|
||||
| Run user | non-root (UID ≠ 0) |
|
||||
|
||||
## Runtime Inputs
|
||||
|
||||
### Environment Variables
|
||||
|
||||
The UI container is a static file server. It has **no required environment variables at runtime** — all configuration is compiled into the static assets at build time by the Angular build toolchain.
|
||||
|
||||
> Note: The API base URL is baked in at build time via Angular's environment configuration. A future iteration may introduce runtime environment injection via a served `config.json`, but this is out of scope for v1.
|
||||
|
||||
## Runtime Outputs
|
||||
|
||||
### HTTP Interface
|
||||
|
||||
| Route pattern | Behaviour |
|
||||
|--------------------|-------------------------------------------------------------------|
|
||||
| `/` | Returns `index.html` with HTTP 200 |
|
||||
| `/` (any SPA path) | Returns `index.html` with HTTP 200 (SPA fallback via `try_files`)|
|
||||
| `/main.*.js` | Returns fingerprinted JS bundle with long-lived cache headers |
|
||||
| `/styles.*.css` | Returns fingerprinted CSS with long-lived cache headers |
|
||||
| `/assets/*` | Returns static assets |
|
||||
| Any path not found | Returns `index.html` with HTTP 200 (Angular router handles 404) |
|
||||
|
||||
### Cache Headers
|
||||
|
||||
| Asset type | Cache-Control header |
|
||||
|-------------------------------------|-----------------------------------------------|
|
||||
| Fingerprinted bundles (`.js`, `.css`, fonts) | `public, max-age=31536000, immutable` |
|
||||
| `index.html` | `no-store, no-cache, must-revalidate` |
|
||||
|
||||
### Process Exit
|
||||
|
||||
| Signal | Expected exit code | Maximum wait |
|
||||
|----------|--------------------|--------------|
|
||||
| SIGTERM | 0 | 30 seconds |
|
||||
| SIGKILL | non-zero | immediate |
|
||||
|
||||
## Health Check
|
||||
|
||||
| Property | Value |
|
||||
|-----------------|--------------------------------|
|
||||
| Command | `wget -qO- http://localhost:8080/` |
|
||||
| Interval | 30 seconds |
|
||||
| Timeout | 5 seconds |
|
||||
| Start period | 15 seconds |
|
||||
| Retries | 3 |
|
||||
|
||||
The health check passes when nginx responds with any 2xx status on the root path.
|
||||
|
||||
## Image Constraints
|
||||
|
||||
| Constraint | Requirement |
|
||||
|-------------------------|-----------------------------------------------|
|
||||
| Node.js runtime present | MUST NOT be present in runtime image |
|
||||
| `node_modules/` present | MUST NOT be present in runtime image |
|
||||
| Source TypeScript files | MUST NOT be present in runtime image |
|
||||
| Secrets in layer history| MUST NOT appear in any `docker history` layer |
|
||||
| Run as root | MUST NOT — process UID MUST be non-zero |
|
||||
|
||||
## Build Interface
|
||||
|
||||
| Property | Value |
|
||||
|-----------------|----------------------------------------------|
|
||||
| Dockerfile path | `ui/Dockerfile.prod` |
|
||||
| Build context | `ui/` directory |
|
||||
| Build command | `docker build -f ui/Dockerfile.prod ui/ -t reactbin-ui-prod:latest` |
|
||||
|
||||
### Build Context Exclusions (`.dockerignore`)
|
||||
|
||||
The following MUST be excluded from the build context to keep transfers fast and avoid leaking dev state:
|
||||
|
||||
- `node_modules/` — always rebuilt via `npm ci` in the build stage
|
||||
- `dist/` — always rebuilt; must not pollute the build stage
|
||||
- `.git/` — not needed for build
|
||||
- `*.spec.ts` — test files not compiled into production output
|
||||
- `.env*` — dev environment files
|
||||
- `src/**/*.spec.ts` — test specs
|
||||
|
||||
## Verification
|
||||
|
||||
The contract is verified end-to-end by `ui/tests/build/verify_production_image.sh`. Running `make verify-ui-prod` MUST pass all contract checks.
|
||||
152
specs/011-ui-prod-dockerfile/plan.md
Normal file
152
specs/011-ui-prod-dockerfile/plan.md
Normal file
@@ -0,0 +1,152 @@
|
||||
# Implementation Plan: Production-Grade UI Container Image
|
||||
|
||||
**Branch**: `011-ui-prod-dockerfile` | **Date**: 2026-05-07 | **Spec**: [spec.md](spec.md)
|
||||
**Input**: Feature specification from `specs/011-ui-prod-dockerfile/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Build a production-grade multi-stage Docker image for the Angular UI. A `node:22-slim` build stage compiles the Angular app into static assets; an `nginxinc/nginx-unprivileged:alpine` runtime stage serves those assets on port 8080 as a non-root user with SPA fallback routing, long-lived cache headers for fingerprinted bundles, and clean SIGTERM handling. The image is verified by a TDD shell script that covers all three user stories (reliable service, security, build caching) in one `make verify-ui-prod` run.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: Node.js 22 (build stage); no runtime language in the final image
|
||||
**Primary Dependencies**: Angular CLI 19 (`npm run build`); nginx-unprivileged (runtime web server)
|
||||
**Storage**: None — container serves pre-compiled static files
|
||||
**Testing**: `ui/tests/build/verify_production_image.sh` (shell script TDD artefact, same pattern as `api/tests/build/verify_production_image.sh`)
|
||||
**Target Platform**: Linux container (amd64); Docker 23+ with BuildKit enabled (default); `--mount=type=cache` used for npm cache layer
|
||||
**Project Type**: Static file server (SPA)
|
||||
**Performance Goals**: Cold build < 3 minutes; warm (source-only) rebuild < 30 seconds; health check response < 500ms
|
||||
**Constraints**: Non-root process (UID ≠ 0); Node.js absent from runtime image; no secrets in image layers
|
||||
**Scale/Scope**: Single container; no horizontal scaling concerns at this stage
|
||||
|
||||
## Constitution Check
|
||||
|
||||
### Pre-research gates
|
||||
|
||||
| Principle | Requirement | Status |
|
||||
|-----------|-------------|--------|
|
||||
| §5.1 TDD | Failing test (verify script) must exist before `Dockerfile.prod` | ✅ Plan includes TDD-first task ordering |
|
||||
| §5.3 Tests next to code | `ui/tests/build/` mirrors `api/tests/build/` | ✅ Correct location |
|
||||
| §5.4 CI before done | All tasks marked done only after verify passes | ✅ Enforced in task ordering |
|
||||
| §7.1 One-command start | `docker compose up` must still work | ✅ Only adds prod Dockerfile; dev Dockerfile unchanged |
|
||||
| §7.2 Env config | No hardcoded credentials in Dockerfile | ✅ No runtime env vars needed; build-time config via Angular environment files |
|
||||
| §7.3 Linting | shellcheck on verify script | ✅ T011 in task plan |
|
||||
| §8 Scope | Server-side rendering, OIDC, multi-user — not addressed | ✅ Spec scoped to static asset serving only |
|
||||
|
||||
**No violations. All gates pass.**
|
||||
|
||||
### Post-design re-check
|
||||
|
||||
Same gates apply. No design decisions introduced in Phase 1 conflict with the constitution.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/011-ui-prod-dockerfile/
|
||||
├── plan.md ← this file
|
||||
├── research.md ← technology decisions (10 decisions)
|
||||
├── contracts/
|
||||
│ └── container.md ← container interface contract
|
||||
├── quickstart.md ← build and verify scenarios
|
||||
└── tasks.md ← generated by /speckit-tasks
|
||||
```
|
||||
|
||||
### Source Code Changes
|
||||
|
||||
```text
|
||||
ui/
|
||||
├── Dockerfile.prod ← NEW (multi-stage production build)
|
||||
├── nginx.conf ← NEW (SPA routing + cache headers)
|
||||
├── .dockerignore ← NEW (does not exist yet; created for production build)
|
||||
└── tests/
|
||||
└── build/
|
||||
├── .gitkeep ← NEW (track directory in git)
|
||||
└── verify_production_image.sh ← NEW (TDD verification script)
|
||||
|
||||
Makefile ← MODIFIED (add build-ui-prod, verify-ui-prod targets)
|
||||
```
|
||||
|
||||
## Dockerfile Design
|
||||
|
||||
### Stage 1 — Builder (`node:22-slim`)
|
||||
|
||||
```
|
||||
COPY package.json package-lock.json ./ # layer: deps (cached until lockfile changes)
|
||||
RUN --mount=type=cache,target=/root/.npm npm ci # reproducible install; npm cache mounted
|
||||
COPY . . # layer: source (invalidated on every change)
|
||||
RUN npm run build # ng build --configuration production
|
||||
```
|
||||
|
||||
Output of `npm run build`: `dist/reactbin-ui/browser/` (confirmed: Angular 19 application builder creates `browser/` subdirectory under `outputPath`).
|
||||
|
||||
### Stage 2 — Runtime (`nginxinc/nginx-unprivileged:alpine`)
|
||||
|
||||
- Runs as non-root by design (no manual `useradd` needed)
|
||||
- Listens on port 8080
|
||||
- `COPY --from=builder /app/dist/reactbin-ui/browser /usr/share/nginx/html`
|
||||
- `COPY nginx.conf /etc/nginx/conf.d/default.conf`
|
||||
- HEALTHCHECK via `wget` (curl not present in Alpine nginx-unprivileged)
|
||||
- No CMD override needed — the base image entrypoint starts nginx
|
||||
|
||||
### nginx.conf
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 8080;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# SPA fallback — unmatched paths return app shell
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Long-lived cache for fingerprinted assets
|
||||
location ~* \.(js|css|woff2?|ttf|eot|svg|png|jpg|jpeg|gif|ico)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Never cache the entry point
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Verification Script Design (`ui/tests/build/verify_production_image.sh`)
|
||||
|
||||
Mirrors `api/tests/build/verify_production_image.sh` structure:
|
||||
|
||||
| Check | Story | Description |
|
||||
|-------|-------|-------------|
|
||||
| Build | US1 | `docker build -f ui/Dockerfile.prod ui/` succeeds |
|
||||
| Health endpoint | US1 | `wget -q http://localhost:18080/` returns 200 within 30s |
|
||||
| SPA routing | US1 | `curl http://localhost:18080/library` returns 200 |
|
||||
| Graceful shutdown | US1 | `docker stop` → exit code 0 |
|
||||
| Non-root user | US2 | `docker exec id -u` ≠ 0 |
|
||||
| Node.js absent | US2 | `docker run node --version` exits non-zero |
|
||||
| No secrets in history | US2 | `docker history --no-trunc` contains no secret-like strings |
|
||||
| Dep layer cache hit | US3 | `touch ui/src/app/app.component.ts` + rebuild → output contains `CACHED` |
|
||||
|
||||
## Makefile Additions
|
||||
|
||||
```makefile
|
||||
build-ui-prod:
|
||||
docker build -f ui/Dockerfile.prod ui/ -t reactbin-ui-prod:latest
|
||||
|
||||
verify-ui-prod:
|
||||
bash ui/tests/build/verify_production_image.sh
|
||||
```
|
||||
|
||||
## Dependencies & Risks
|
||||
|
||||
| Item | Risk | Mitigation |
|
||||
|------|------|------------|
|
||||
| `dist/reactbin-ui/browser/` path | If Angular changes the output directory structure in a future version, the COPY path breaks | Path is verified in research; a test build during verify catches drift |
|
||||
| `nginxinc/nginx-unprivileged` UID | UID may vary between image versions | Check is `UID ≠ 0`, not a specific UID value |
|
||||
| `wget` availability | Alpine images may change toolset | HEALTHCHECK is tested as part of US1 verify |
|
||||
| Port 18080 collision | Another process may use 18080 during verify | Acceptable risk for a dev-time test; port is not a system service |
|
||||
100
specs/011-ui-prod-dockerfile/quickstart.md
Normal file
100
specs/011-ui-prod-dockerfile/quickstart.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# Quickstart: UI Production Image
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker with BuildKit enabled (default in Docker 23+)
|
||||
- `make` available in the shell
|
||||
|
||||
## Build the Image
|
||||
|
||||
```bash
|
||||
make build-ui-prod
|
||||
# Equivalent: docker build -f ui/Dockerfile.prod ui/ -t reactbin-ui-prod:latest
|
||||
```
|
||||
|
||||
Expected: Build completes in ~2 minutes on first run (npm install), ~15 seconds on subsequent source-only changes.
|
||||
|
||||
## Run the Container
|
||||
|
||||
```bash
|
||||
docker run --rm -p 4200:8080 reactbin-ui-prod:latest
|
||||
```
|
||||
|
||||
Open http://localhost:4200 — the app shell loads. Navigate to `/library` or `/tags` — the page loads (SPA routing returns `index.html`).
|
||||
|
||||
## Verify All Production Checks
|
||||
|
||||
```bash
|
||||
make verify-ui-prod
|
||||
```
|
||||
|
||||
This runs `ui/tests/build/verify_production_image.sh`, which exercises all three user stories:
|
||||
|
||||
```
|
||||
[verify] Building reactbin-ui-prod:verify-<PID>...
|
||||
[verify] Build OK
|
||||
[verify] Polling health endpoint...
|
||||
[verify] Health check passed
|
||||
[verify] SPA routing OK (/library → 200)
|
||||
[verify] Non-root user OK (UID <n>)
|
||||
[verify] Stdout logging OK
|
||||
[verify] Graceful shutdown OK (exit 0)
|
||||
[verify] Node.js absent in runtime image OK
|
||||
[verify] No secrets in image layers OK
|
||||
[verify] Dep layer cache hit confirmed (US3 OK)
|
||||
[verify] All checks passed (US1 + US2 + US3).
|
||||
```
|
||||
|
||||
## Integration Test Scenarios
|
||||
|
||||
### Scenario 1: Initial Build (Cold Cache)
|
||||
|
||||
```bash
|
||||
docker rmi reactbin-ui-prod:latest 2>/dev/null || true
|
||||
make build-ui-prod
|
||||
```
|
||||
|
||||
Expected: `npm ci` runs fully (~30–90s depending on network). All packages installed from lockfile.
|
||||
|
||||
### Scenario 2: Source-Only Rebuild (Warm Cache)
|
||||
|
||||
```bash
|
||||
touch ui/src/app/app.component.ts
|
||||
make build-ui-prod
|
||||
```
|
||||
|
||||
Expected: `npm ci` step is CACHED (skipped). Only the Angular compilation runs (~10–20s).
|
||||
|
||||
### Scenario 3: Dependency Change (Cache Invalidation)
|
||||
|
||||
```bash
|
||||
# Simulate a lockfile change
|
||||
touch ui/package-lock.json
|
||||
make build-ui-prod
|
||||
```
|
||||
|
||||
Expected: `npm ci` runs fresh (cache miss is intentional and correct).
|
||||
|
||||
### Scenario 4: SPA Deep-Link Routing
|
||||
|
||||
```bash
|
||||
docker run --rm -d -p 4200:8080 --name ui-test reactbin-ui-prod:latest
|
||||
curl -sf http://localhost:4200/library # 200 + index.html
|
||||
curl -sf http://localhost:4200/tags # 200 + index.html
|
||||
curl -sf http://localhost:4200/nonexistent # 200 + index.html (Angular handles 404)
|
||||
docker stop ui-test
|
||||
```
|
||||
|
||||
### Scenario 5: Non-Root Assertion
|
||||
|
||||
```bash
|
||||
docker run --rm reactbin-ui-prod:latest id
|
||||
# Must NOT output uid=0(root)
|
||||
```
|
||||
|
||||
### Scenario 6: No Node.js in Runtime Image
|
||||
|
||||
```bash
|
||||
docker run --rm reactbin-ui-prod:latest node --version 2>&1
|
||||
# Must exit non-zero (node not found)
|
||||
```
|
||||
69
specs/011-ui-prod-dockerfile/research.md
Normal file
69
specs/011-ui-prod-dockerfile/research.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# Research: Production-Grade UI Container Image
|
||||
|
||||
## Decision 1: Build-stage base image
|
||||
|
||||
**Decision**: `node:22-slim`
|
||||
**Rationale**: Matches the version in the existing dev `ui/Dockerfile`. Slim variant reduces the builder layer size and attack surface relative to the full Debian image.
|
||||
**Alternatives considered**: `node:22-alpine` — lighter, but can introduce musl/glibc compatibility issues with some native npm packages; `node:22-bookworm-slim` — functionally equivalent to `node:22-slim`, same image.
|
||||
|
||||
## Decision 2: Runtime base image
|
||||
|
||||
**Decision**: `nginxinc/nginx-unprivileged:alpine`
|
||||
**Rationale**: Runs fully as a non-root user on port 8080 out of the box — no manual user creation or privilege workarounds required. Alpine-based keeps the final image small. The official `nginx:alpine` image requires the master process to run as root to bind port 80; `nginx-unprivileged` avoids this by binding to 8080 instead.
|
||||
**Alternatives considered**:
|
||||
- `nginx:alpine` — master process must be root (violates FR-005); workers run as `nginx` user but `id -u` inside container still shows 0 for PID 1.
|
||||
- `caddy:alpine` — also supports non-root but adds Caddy's Go runtime footprint unnecessarily for pure static serving.
|
||||
|
||||
## Decision 3: Container port
|
||||
|
||||
**Decision**: Expose port `8080` in the container; external orchestrators (docker-compose, Kubernetes ingress) map it to port 80 or 4200 as needed.
|
||||
**Rationale**: `nginxinc/nginx-unprivileged` defaults to port 8080; deviating would require overriding nginx config with no benefit. Port remapping is standard practice — containers should not run as root just to bind to a privileged port.
|
||||
**Alternatives considered**: Running nginx on port 80 requires either root or Linux capabilities (`CAP_NET_BIND_SERVICE`), both of which increase the attack surface.
|
||||
|
||||
## Decision 4: Angular build output directory
|
||||
|
||||
**Decision**: COPY `dist/reactbin-ui/browser/` into the nginx document root.
|
||||
**Rationale**: The Angular 19 `@angular-devkit/build-angular:application` builder (esbuild-based) places browser assets in `dist/{projectName}/browser/` — confirmed by inspecting the existing `dist/reactbin-ui/browser/` directory in the repo. The parent `dist/reactbin-ui/` also contains `prerendered-routes.json` and `3rdpartylicenses.txt` which must not be served as the web root.
|
||||
**Alternatives considered**: Serving from `dist/reactbin-ui/` directly — would expose the `3rdpartylicenses.txt` file at the root and include the prerendering metadata file.
|
||||
|
||||
## Decision 5: Dependency install command
|
||||
|
||||
**Decision**: `npm ci` (not `npm install`)
|
||||
**Rationale**: `npm ci` installs exactly what `package-lock.json` specifies — reproducible, faster on CI, and fails loudly on lockfile mismatches. All dependencies (including `devDependencies`) are needed in the build stage because Angular CLI and build tools are `devDependencies`.
|
||||
**Alternatives considered**: `npm install` — non-deterministic across environments; `npm install --omit=dev` — would break the Angular build since `@angular/cli` is a devDependency.
|
||||
|
||||
## Decision 6: Layer cache strategy
|
||||
|
||||
**Decision**: Two COPY layers — lockfiles first, then source.
|
||||
```
|
||||
COPY package.json package-lock.json ./ # invalidated only on dep changes
|
||||
RUN npm ci # expensive step, cached when lockfiles unchanged
|
||||
COPY . . # invalidated on every source change
|
||||
RUN npm run build
|
||||
```
|
||||
**Rationale**: Mirrors the proven pattern used in the API's `Dockerfile.prod`. Dependency installation (30s–2min) is cached independently from source compilation.
|
||||
**Alternatives considered**: Single COPY of all source — trivial source changes would always re-run `npm ci`.
|
||||
|
||||
## Decision 7: SPA routing
|
||||
|
||||
**Decision**: nginx `try_files $uri $uri/ /index.html` fallback in a custom `nginx.conf`.
|
||||
**Rationale**: Angular is a single-page application. All non-asset routes (e.g., `/library`, `/tags`, `/login`) must return `index.html` so Angular's router can handle them client-side. Without this, direct navigation to any deep link returns 404.
|
||||
**Alternatives considered**: Redirect to `/` — would break deep linking; returning 404 — breaks client-side routing entirely.
|
||||
|
||||
## Decision 8: Cache-control headers
|
||||
|
||||
**Decision**: Long-lived `Cache-Control: public, max-age=31536000, immutable` for fingerprinted JS/CSS/font assets; `Cache-Control: no-store` for `index.html`.
|
||||
**Rationale**: Angular's production build fingerprints all bundles (e.g., `main.a1b2c3d4.js`). These are safe to cache indefinitely. `index.html` is never fingerprinted and must always be fresh so users pick up new deployments.
|
||||
**Alternatives considered**: No cache-control headers — acceptable for MVP but fails FR-008.
|
||||
|
||||
## Decision 9: Health check probe
|
||||
|
||||
**Decision**: Use `wget -qO- http://localhost:8080/` as the HEALTHCHECK command (no `curl` in `nginx-unprivileged:alpine`).
|
||||
**Rationale**: The `nginxinc/nginx-unprivileged:alpine` image is minimal and does not include `curl`. `wget` is available in Alpine. The health check tests that nginx is accepting connections and returning the app shell.
|
||||
**Alternatives considered**: Installing `curl` via `apk add` — adds package manager overhead and unnecessary tooling to the runtime image.
|
||||
|
||||
## Decision 10: TDD verification approach
|
||||
|
||||
**Decision**: Shell script `ui/tests/build/verify_production_image.sh` mirrors the approach used for the API in feature 010.
|
||||
**Rationale**: There is no pytest equivalent for Docker build artifacts. A shell script that fails because `Dockerfile.prod` does not exist satisfies §5.1 TDD (the script is the failing test; writing the Dockerfile turns it green).
|
||||
**Alternatives considered**: No TDD — violates §5.1; a Python test with subprocess — overkill when a shell script is simpler and already proven.
|
||||
110
specs/011-ui-prod-dockerfile/spec.md
Normal file
110
specs/011-ui-prod-dockerfile/spec.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# Feature Specification: Production-Grade UI Container Image
|
||||
|
||||
**Feature Branch**: `011-ui-prod-dockerfile`
|
||||
**Created**: 2026-05-07
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Production-grade UI container image build"
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - UI Serves Reliably in Production (Priority: P1)
|
||||
|
||||
A production deployment starts the UI container and it serves the compiled application correctly — returning the app shell for all routes, responding quickly, and shutting down cleanly when the orchestrator stops it.
|
||||
|
||||
**Why this priority**: A container that can't serve traffic is not deployable. All other properties (security, build speed) are meaningless without a running service.
|
||||
|
||||
**Independent Test**: Build the image, start the container, and verify the root path returns a 200 response. Stopping the container produces a clean exit. This alone constitutes a deployable MVP.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a built production image, **When** the container starts, **Then** it serves the application on port 8080 within 30 seconds.
|
||||
2. **Given** the container is running, **When** a request is made to any client-side route (e.g., `/library`, `/tags`), **Then** the server returns the app shell (200 OK) so client-side routing can take over.
|
||||
3. **Given** the container is running, **When** a static asset is requested, **Then** it is returned with appropriate caching headers.
|
||||
4. **Given** a running container, **When** the orchestrator sends a stop signal, **Then** the container exits with code 0 within a reasonable timeout.
|
||||
5. **Given** the production image, **When** a health probe is issued to a designated endpoint, **Then** the container reports healthy.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Minimal, Secure Container (Priority: P2)
|
||||
|
||||
The production image contains only what is needed to serve static files — no build tools, no source code, no `node_modules`. It runs as a non-privileged user.
|
||||
|
||||
**Why this priority**: Shipping build tools and source code in production images increases attack surface and image size. Running as root violates least-privilege principles.
|
||||
|
||||
**Independent Test**: Inspect the running container — confirm the process user is non-root; attempt to import or run a Node.js binary inside the image and confirm it is absent.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the production image, **When** the running process user is inspected, **Then** it is not root (UID ≠ 0).
|
||||
2. **Given** the production image, **When** the image contents are inspected, **Then** `node_modules/`, source TypeScript files, and the Node.js runtime are absent.
|
||||
3. **Given** the production image, **When** image layer history is inspected, **Then** no secrets, API keys, or credentials appear in any layer command.
|
||||
4. **Given** the production image, **When** the image size is measured, **Then** it is substantially smaller than a single-stage image that includes the Node.js toolchain.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Fast, Reproducible Builds (Priority: P3)
|
||||
|
||||
Rebuilding the image after a source-only change (no dependency changes) reuses the dependency installation layer from cache, completing in seconds rather than minutes.
|
||||
|
||||
**Why this priority**: Slow builds impede the development feedback loop and CI pipeline throughput. Dependency installs are the dominant time cost.
|
||||
|
||||
**Independent Test**: Build once, then change a source file and build again — the build output confirms the dependency layer was served from cache.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the image has been built once, **When** only a source file is changed and the image is rebuilt, **Then** the dependency installation step is skipped (cache hit).
|
||||
2. **Given** a dependency file is changed, **When** the image is rebuilt, **Then** the dependency installation step runs fresh (cache miss is correct behaviour).
|
||||
3. **Given** two successive builds with identical inputs, **Then** both produce functionally identical output.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens when the container starts but the built assets are missing or corrupted?
|
||||
- How does the server handle requests for non-existent routes that should fall back to the app shell (SPA routing)?
|
||||
- What happens when the container receives a stop signal while actively serving requests?
|
||||
- What happens if the port is already in use at startup?
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: The production image MUST be built via a multi-stage process — a build stage compiles the application into static assets, and a separate runtime stage serves only those assets.
|
||||
- **FR-002**: The runtime stage MUST NOT contain the Node.js runtime, npm, source TypeScript, or `node_modules/`.
|
||||
- **FR-003**: The container MUST serve the application on port 8080. External orchestrators (docker-compose, Kubernetes ingress) map this to port 80 as needed.
|
||||
- **FR-004**: The container MUST handle SPA (single-page application) routing by returning the app shell for any unmatched path, so client-side routing works correctly.
|
||||
- **FR-005**: The container MUST run as a non-root user.
|
||||
- **FR-006**: The container MUST expose a health-check endpoint that returns success when the service is ready to accept traffic.
|
||||
- **FR-007**: The container MUST exit with code 0 when sent a graceful stop signal.
|
||||
- **FR-008**: Static assets MUST be served with cache-control headers that enable client-side caching for fingerprinted assets.
|
||||
- **FR-009**: The Dockerfile MUST structure layers so that dependency installation is cached independently from source code changes.
|
||||
- **FR-010**: The build MUST be reproducible — given the same source and lockfile, successive builds produce equivalent images.
|
||||
- **FR-011**: No credentials, secrets, or API keys MUST appear in any image layer.
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **Build Stage**: The intermediate container that installs dependencies and compiles source into static assets; discarded after build.
|
||||
- **Static Assets**: The compiled output (HTML, JS bundles, CSS, fonts, images) that the runtime stage serves.
|
||||
- **Runtime Stage**: The minimal final image containing only a web server and the compiled static assets.
|
||||
- **Production Image**: The tagged, distributable image produced by the build; used directly in deployment.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: The container serves a 200 response on port 8080 within 30 seconds of starting.
|
||||
- **SC-002**: The production image is substantially smaller than a single-stage image that retains the Node.js toolchain. A manual size comparison after the initial build confirms the multi-stage approach delivers a meaningful reduction (expected: >60% reduction).
|
||||
- **SC-003**: A source-only rebuild completes in under 30 seconds (dependency layer served from cache).
|
||||
- **SC-004**: All 11 functional requirements pass automated verification on every build.
|
||||
- **SC-005**: The running container process has UID ≠ 0, confirmed by automated check.
|
||||
- **SC-006**: No existing integration tests regress after the Dockerfile and supporting files are introduced.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- The Angular application is built for production using the standard build toolchain (`ng build --configuration production` or equivalent), producing a `dist/` output directory.
|
||||
- The production web server is responsible for SPA fallback routing (returning the app shell for unmatched paths).
|
||||
- Gzip or Brotli compression at the web server layer is desirable but not mandatory for the initial implementation.
|
||||
- The UI container does not need to proxy API requests — it communicates with the API directly from the browser (the Angular proxy config is only used in local development).
|
||||
- The container listens on port 8080 (non-privileged, enabling non-root operation). External load balancers or ingress controllers map this to port 80. TLS termination occurs upstream.
|
||||
- The build context is the `ui/` directory; files excluded from the build context (source maps in CI, `node_modules/` already present locally) are managed via `.dockerignore`.
|
||||
- The same verification approach used for the API image (a shell script as the TDD artefact) applies here.
|
||||
166
specs/011-ui-prod-dockerfile/tasks.md
Normal file
166
specs/011-ui-prod-dockerfile/tasks.md
Normal file
@@ -0,0 +1,166 @@
|
||||
# Tasks: Production-Grade UI Container Image
|
||||
|
||||
**Input**: Design documents from `specs/011-ui-prod-dockerfile/`
|
||||
**Prerequisites**: plan.md ✅, spec.md ✅, research.md ✅, contracts/container.md ✅, quickstart.md ✅
|
||||
|
||||
**Tests**: TDD is non-negotiable (§5.1). The "test" for a Docker build artefact is `ui/tests/build/verify_production_image.sh`, written before `ui/Dockerfile.prod` exists. Running the script immediately fails (red) because the build step cannot find the file; writing `Dockerfile.prod` turns it green.
|
||||
|
||||
**Organization**: Phase 1 sets up Makefile targets, `.dockerignore`, and supporting files; Phase 3 (US1) writes the verification script and the Dockerfile; Phase 4 (US2) extends the script with security checks; Phase 5 (US3) extends it with a cache-hit check; Phase 6 polishes.
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
- **[P]**: Can run in parallel with other [P] tasks in the same phase
|
||||
- **[Story]**: Which user story this task belongs to
|
||||
- Exact file paths included in every task description
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup
|
||||
|
||||
- [X] T001 Add `build-ui-prod` and `verify-ui-prod` targets (and their `.PHONY` entries) to the root `Makefile` at `/workspace/Makefile`: `build-ui-prod` runs `docker build -f ui/Dockerfile.prod ui/ -t reactbin-ui-prod:latest`; `verify-ui-prod` runs `bash ui/tests/build/verify_production_image.sh`
|
||||
|
||||
- [X] T002 Create `ui/.dockerignore` at `/workspace/ui/.dockerignore` with the following exclusions (the file does not yet exist — create it fresh): `.git/`, `node_modules/`, `dist/`, `.angular/`, `coverage/`, `*.spec.ts`, `.env`, `.env.*`, `!.env.example`, `tests/`; these keep the build context transfer fast and prevent dev state from leaking into the production image
|
||||
|
||||
- [X] T003 Create directory `ui/tests/build/` at `/workspace/ui/tests/build/` with `mkdir -p` and add a `.gitkeep` so the directory is tracked in git
|
||||
|
||||
---
|
||||
|
||||
**Checkpoint**: Directory structure is ready; Makefile and .dockerignore are created.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational
|
||||
|
||||
No blocking foundational prerequisites exist for this feature — the setup tasks in Phase 1 directly enable all user story phases. Phase 2 is intentionally omitted.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 — UI Serves Reliably in Production (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: The container builds, starts, serves the health endpoint and SPA routes, and exits cleanly on SIGTERM.
|
||||
|
||||
**Independent Test**: `make verify-ui-prod` — passes when `Dockerfile.prod` and `nginx.conf` exist and all US1 checks pass.
|
||||
|
||||
### Test for User Story 1 (TDD red — write first, confirm failure before T005)
|
||||
|
||||
- [X] T004 [US1] Create `ui/tests/build/verify_production_image.sh` as an executable bash script (`chmod +x`) with `#!/usr/bin/env bash` and `set -euo pipefail`; the script MUST:
|
||||
1. Set `IMAGE="reactbin-ui-prod:verify-$$"` and `IMAGE2="reactbin-ui-prod:verify-cache-$$"` and `APP_CONTAINER=""`;
|
||||
2. Define a `cleanup()` function that runs `docker rm -f "$APP_CONTAINER" 2>/dev/null || true`, `docker rmi "$IMAGE" 2>/dev/null || true`, and `docker rmi "$IMAGE2" 2>/dev/null || true`, then register it with `trap cleanup EXIT`;
|
||||
3. **[US1 check 1 — build]** Run `docker build -f ui/Dockerfile.prod ui/ -t "$IMAGE"` — this is the line that fails **red** because `ui/Dockerfile.prod` does not yet exist; print `[verify] Building $IMAGE...` before and `[verify] Build OK` after;
|
||||
4. **[US1 check 2 — start container]** Start the production container: `APP_CONTAINER=$(docker run -d -p 18080:8080 "$IMAGE")`; print `[verify] Starting production container...`;
|
||||
5. **[US1 check 3 — health endpoint]** Poll `curl -sf http://localhost:18080/` up to 30 × 1s, fail with `FAIL: health check timed out after 30s` if timeout; print `[verify] Health check passed` on success;
|
||||
6. **[US1 check 4 — SPA routing]** Run `curl -sf http://localhost:18080/library > /dev/null`; assert exit code is 0 (200 response); fail with `FAIL: SPA routing check failed (/library did not return 200)` if violated; print `[verify] SPA routing OK (/library → 200)`;
|
||||
7. **[US1 check 5 — SIGTERM → exit 0]** Run `docker stop "$APP_CONTAINER"` (sends SIGTERM); capture `EXIT_CODE=$(docker wait "$APP_CONTAINER")`; assert `"$EXIT_CODE" -eq 0`, fail with `FAIL: non-zero exit code $EXIT_CODE after SIGTERM` otherwise; print `[verify] Graceful shutdown OK (exit $EXIT_CODE)`;
|
||||
8. Print `[verify] US1 checks passed.`
|
||||
After writing the script, run `make verify-ui-prod` and confirm it **fails** with a Docker build error (red state — `ui/Dockerfile.prod` does not exist).
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T005 [US1] Create `ui/nginx.conf` at `/workspace/ui/nginx.conf` — an nginx server block that: listens on port `8080`; sets `root /usr/share/nginx/html` and `index index.html`; adds a `location /` block with `try_files $uri $uri/ /index.html` for SPA fallback routing; adds a `location ~* \.(js|css|woff2?|ttf|eot|svg|png|jpg|jpeg|gif|ico)$` block with `expires 1y` and `add_header Cache-Control "public, immutable"` for fingerprinted assets; adds a `location = /index.html` block with `add_header Cache-Control "no-store, no-cache, must-revalidate"` so the entry point is never cached
|
||||
|
||||
- [X] T006 [US1] Create `ui/Dockerfile.prod` at `/workspace/ui/Dockerfile.prod` — a two-stage multi-stage build:
|
||||
**Stage 1 (builder)**: `FROM node:22-slim AS builder`; `WORKDIR /app`; `COPY package.json package-lock.json ./`; `RUN --mount=type=cache,target=/root/.npm npm ci`; `COPY . .`; `RUN npm run build`
|
||||
**Stage 2 (runtime)**: `FROM nginxinc/nginx-unprivileged:alpine`; `COPY --from=builder /app/dist/reactbin-ui/browser /usr/share/nginx/html`; `COPY nginx.conf /etc/nginx/conf.d/default.conf`; `EXPOSE 8080`; `HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 CMD wget -qO- http://localhost:8080/ || exit 1`
|
||||
|
||||
- [X] T007 [US1] Verify TDD green for US1: run `make verify-ui-prod` and confirm all five US1 checks pass — build OK, health endpoint returns 200, SPA routing returns 200, SIGTERM produces exit code 0, and `[verify] US1 checks passed.` is printed.
|
||||
|
||||
**Checkpoint**: US1 is complete. Production container builds, starts, serves traffic (including SPA routes), and shuts down gracefully.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 — Minimal, Secure Container (Priority: P2)
|
||||
|
||||
**Goal**: The production image runs as non-root and contains no Node.js runtime, source, or embedded secrets.
|
||||
|
||||
**Independent Test**: US2 checks in `make verify-ui-prod` — the same script extended with non-root, node-absent, and secrets-free assertions.
|
||||
|
||||
### Tests for User Story 2 (TDD extension — add checks, confirm they pass against existing Dockerfile.prod)
|
||||
|
||||
- [X] T008 [US2] Extend `ui/tests/build/verify_production_image.sh` with US2 checks inserted after the health/SPA/SIGTERM checks (before the final `US1 checks passed` line) and update the final success message to `[verify] All checks passed (US1 + US2).`:
|
||||
**[US2 check 1 — non-root]** Before `docker stop`, run `UID_IN_CONTAINER=$(docker exec "$APP_CONTAINER" id -u)`; assert `"$UID_IN_CONTAINER" -ne 0`, fail with `FAIL: process running as root (UID 0)` if violated; print `[verify] Non-root user OK (UID $UID_IN_CONTAINER)`;
|
||||
**[C1 — stdout log capture]** Run `LOGS=$(docker logs "$APP_CONTAINER" 2>&1)`; assert `"$LOGS"` is non-empty, fail with `FAIL: no output on stdout/stderr` if empty; print `[verify] Stdout logging OK`; insert this check before `docker stop`;
|
||||
**[US2 check 2 — Node.js absent]** After SIGTERM cleanup, run `docker run --rm "$IMAGE" node --version 2>/dev/null`; assert the exit code is **non-zero** (node not present in runtime image); if it returns 0, fail with `FAIL: node runtime found in production image`; print `[verify] Node.js absent in runtime image OK`;
|
||||
**[C2 — no hardcoded secrets in layers]** Run `docker history --no-trunc "$IMAGE" 2>&1`; pipe through `grep -qiE "(password|secret_key|api_key|token)"`; assert zero matching lines; if any match, fail with `FAIL: potential secret found in image history`; print `[verify] No secrets in image layers OK`;
|
||||
**[FR-008 — cache-control headers on assets]** While APP_CONTAINER is running, find the first JS bundle filename: `JS_FILE=$(docker run --rm "$IMAGE" ls /usr/share/nginx/html | grep -E '\.js$' | head -1)`; run `curl -sI "http://localhost:18080/${JS_FILE}"`; assert the response contains `Cache-Control` with `immutable` or `max-age=31536000`, fail with `FAIL: cache-control header not set on fingerprinted asset` if absent; print `[verify] Cache-Control header OK`;
|
||||
Confirm `make verify-ui-prod` passes with the extended checks.
|
||||
|
||||
**Checkpoint**: US2 is verified. Image runs as a non-root user and contains no Node.js toolchain.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 — Fast, Reproducible Builds (Priority: P3)
|
||||
|
||||
**Goal**: Rebuilding after a source-only change reuses the `npm ci` dependency layer from cache.
|
||||
|
||||
**Independent Test**: US3 check in `make verify-ui-prod` — a second build after touching a source file asserts the dep layer was cached.
|
||||
|
||||
### Tests for User Story 3 (TDD extension)
|
||||
|
||||
- [X] T009 [US3] Extend `ui/tests/build/verify_production_image.sh` with a US3 cache check appended after all other checks (before the final success line):
|
||||
**[US3 check — dep layer cached on source-only rebuild]** Print `[verify] Testing cache hit on source-only rebuild...`; `touch ui/src/app/app.component.ts`; capture `BUILD2_OUTPUT=$(docker build --progress=plain -f ui/Dockerfile.prod ui/ -t "$IMAGE2" 2>&1)` (the `--progress=plain` flag ensures consistent `CACHED` output regardless of Docker version or TTY); assert the output contains the string `CACHED`; if absent, fail with `FAIL: dependency layer not reused on source-only rebuild`; print `[verify] Dep layer cache hit confirmed (US3 OK)`;
|
||||
Update the final success line to `[verify] All checks passed (US1 + US2 + US3).`
|
||||
|
||||
- [X] T010 [US3] Verify TDD green for US3: run `make verify-ui-prod` and confirm the full script passes including the cache check — the build output for the second image must contain `CACHED`, and `[verify] All checks passed (US1 + US2 + US3).` must print.
|
||||
|
||||
**Checkpoint**: All three user stories are verified end-to-end by `make verify-ui-prod`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
- [X] T011 Run `make test-integration` from `/workspace` and confirm all 102 existing tests still pass — verifies that the new files (Makefile targets, ui/.dockerignore, ui/tests/build/) do not break the existing test Dockerfile build or any integration test (§5.4 regression gate)
|
||||
|
||||
- [X] T012 Confirm image size reduction (SC-002): run `docker images reactbin-ui-prod:latest --format "{{.Size}}"` and compare against a reference single-stage image built from `FROM node:22-slim` + `npm ci` + `npm run build` to confirm the production image is substantially smaller (expected >60% reduction); document the sizes in a comment or log line
|
||||
|
||||
- [X] T013 Run `shellcheck ui/tests/build/verify_production_image.sh` and fix any violations (common: unquoted variables, `[ ]` vs `[[ ]]`, missing `--` before arguments); also verify `make verify-ui-prod` still passes after any fixes
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Phase 1 (Setup)**: No external dependencies — start immediately
|
||||
- **Phase 3 (US1)**: Depends on Phase 1 (Makefile + .dockerignore must exist before `make verify-ui-prod` can run) and directory must exist (T003)
|
||||
- **Phase 4 (US2)**: Depends on Phase 3 (US1 script and Dockerfile must exist to extend)
|
||||
- **Phase 5 (US3)**: Depends on Phase 4 (full US2 script must exist to extend)
|
||||
- **Phase 6 (Polish)**: Depends on all prior phases; T011 before T012
|
||||
|
||||
### Within Phase 3
|
||||
|
||||
- T004 before T005/T006 (write test script before writing the nginx config and Dockerfile)
|
||||
- T005 and T006 can run in parallel (different files, no mutual dependency)
|
||||
- T007 after T005 and T006 (verify green after both implementation files exist)
|
||||
|
||||
### Execution Order Summary
|
||||
|
||||
```
|
||||
Step 1: T001 ∥ T002 ∥ T003 (setup — parallel, different files)
|
||||
Step 2: T004 (write verification script — TDD red)
|
||||
Step 3: T005 ∥ T006 (write nginx.conf and Dockerfile.prod — parallel)
|
||||
Step 4: T007 (verify US1 green)
|
||||
Step 5: T008 (extend script with US2 checks, verify pass)
|
||||
Step 6: T009 (extend script with US3 check)
|
||||
Step 7: T010 (verify US3 green)
|
||||
Step 8: T011 (make test-integration — regression gate)
|
||||
Step 9: T012 (image size comparison — SC-002)
|
||||
Step 10: T013 (shellcheck polish)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP (US1 — reliable production run)
|
||||
|
||||
1. Complete T001–T003 (setup)
|
||||
2. Complete T004–T007 (core: write script → write nginx.conf + Dockerfile → verify green)
|
||||
3. **Validate**: `make verify-ui-prod` passes; `make test-integration` still passes
|
||||
4. US2 and US3 add explicit verification coverage for properties already implemented by the two-stage build
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
- After Phase 3: Production image builds, starts, serves traffic with SPA routing — safe to deploy
|
||||
- After Phase 4: Security properties (non-root, no Node.js runtime) are explicitly verified
|
||||
- After Phase 5: Build efficiency (npm ci layer caching) is confirmed by automated check
|
||||
- After Phase 6: Script is lint-clean, ready for CI integration
|
||||
@@ -7,3 +7,5 @@ coverage/
|
||||
.env.*
|
||||
!.env.example
|
||||
*.log
|
||||
*.spec.ts
|
||||
tests/
|
||||
|
||||
30
ui/Dockerfile.prod
Normal file
30
ui/Dockerfile.prod
Normal file
@@ -0,0 +1,30 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ════════════════════════════════════════════════
|
||||
# Build stage: install deps and compile Angular app
|
||||
# ════════════════════════════════════════════════
|
||||
FROM node:22-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Layer cache split: deps only (changes rarely)
|
||||
COPY package.json package-lock.json ./
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm ci
|
||||
|
||||
# Layer cache split: source (changes often)
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# ════════════════════════════════════════════════
|
||||
# Runtime stage: minimal nginx serving static assets
|
||||
# ════════════════════════════════════════════════
|
||||
FROM nginxinc/nginx-unprivileged:alpine
|
||||
|
||||
COPY --from=builder /app/dist/reactbin-ui/browser /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD wget -qO- http://localhost:8080/ || exit 1
|
||||
22
ui/nginx.conf
Normal file
22
ui/nginx.conf
Normal file
@@ -0,0 +1,22 @@
|
||||
server {
|
||||
listen 8080;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# SPA fallback: all unmatched paths → app shell
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Long-lived cache for fingerprinted assets
|
||||
location ~* \.(js|css|woff2?|ttf|eot|svg|png|jpg|jpeg|gif|ico)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Never cache the entry point
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
||||
}
|
||||
}
|
||||
0
ui/tests/build/.gitkeep
Normal file
0
ui/tests/build/.gitkeep
Normal file
100
ui/tests/build/verify_production_image.sh
Executable file
100
ui/tests/build/verify_production_image.sh
Executable file
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env bash
|
||||
# TDD verification script for ui/Dockerfile.prod
|
||||
# Fails (red) if Dockerfile.prod does not exist or any check fails.
|
||||
set -euo pipefail
|
||||
|
||||
IMAGE="reactbin-ui-prod:verify-$$"
|
||||
IMAGE2="reactbin-ui-prod:verify-cache-$$"
|
||||
APP_CONTAINER=""
|
||||
|
||||
cleanup() {
|
||||
[ -n "$APP_CONTAINER" ] && docker rm -f "$APP_CONTAINER" 2>/dev/null || true
|
||||
docker rmi "$IMAGE" 2>/dev/null || true
|
||||
docker rmi "$IMAGE2" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# ── US1 check 1: build ────────────────────────────────────────────────────────
|
||||
echo "[verify] Building $IMAGE..."
|
||||
docker build -f ui/Dockerfile.prod ui/ -t "$IMAGE"
|
||||
echo "[verify] Build OK"
|
||||
|
||||
# ── US1 check 2: start container ──────────────────────────────────────────────
|
||||
echo "[verify] Starting production container..."
|
||||
APP_CONTAINER=$(docker run -d -p 18080:8080 "$IMAGE")
|
||||
|
||||
# ── US1 check 3: health endpoint ──────────────────────────────────────────────
|
||||
echo "[verify] Polling health endpoint..."
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf http://localhost:18080/ > /dev/null; then break; fi
|
||||
sleep 1
|
||||
if [[ $i -eq 30 ]]; then echo "FAIL: health check timed out after 30s"; exit 1; fi
|
||||
done
|
||||
echo "[verify] Health check passed"
|
||||
|
||||
# ── US1 check 4: SPA routing ──────────────────────────────────────────────────
|
||||
if ! curl -sf http://localhost:18080/library > /dev/null; then
|
||||
echo "FAIL: SPA routing check failed (/library did not return 200)"; exit 1
|
||||
fi
|
||||
echo "[verify] SPA routing OK (/library → 200)"
|
||||
|
||||
# ── US2 check 1: non-root user ────────────────────────────────────────────────
|
||||
UID_IN_CONTAINER=$(docker exec "$APP_CONTAINER" id -u)
|
||||
if [[ "$UID_IN_CONTAINER" -eq 0 ]]; then
|
||||
echo "FAIL: process running as root (UID 0)"; exit 1
|
||||
fi
|
||||
echo "[verify] Non-root user OK (UID $UID_IN_CONTAINER)"
|
||||
|
||||
# ── C1: stdout/stderr log capture ─────────────────────────────────────────────
|
||||
LOGS=$(docker logs "$APP_CONTAINER" 2>&1)
|
||||
if [[ -z "$LOGS" ]]; then
|
||||
echo "FAIL: no output on stdout/stderr"; exit 1
|
||||
fi
|
||||
echo "[verify] Stdout logging OK"
|
||||
|
||||
# ── FR-008: cache-control headers on fingerprinted assets ─────────────────────
|
||||
JS_FILE=$(docker run --rm "$IMAGE" ls /usr/share/nginx/html | grep -E '\.js$' | head -1)
|
||||
if [[ -n "$JS_FILE" ]]; then
|
||||
CACHE_HEADER=$(curl -sI "http://localhost:18080/${JS_FILE}" | grep -i "cache-control" || true)
|
||||
if ! echo "$CACHE_HEADER" | grep -qi "immutable\|max-age=31536000"; then
|
||||
echo "FAIL: cache-control header not set on fingerprinted asset ${JS_FILE}"; exit 1
|
||||
fi
|
||||
echo "[verify] Cache-Control header OK"
|
||||
else
|
||||
echo "[verify] Cache-Control header check skipped (no .js file found at root)"
|
||||
fi
|
||||
|
||||
# ── US1 check 5: SIGTERM → exit 0 ────────────────────────────────────────────
|
||||
docker stop "$APP_CONTAINER" > /dev/null
|
||||
EXIT_CODE=$(docker wait "$APP_CONTAINER")
|
||||
if [[ "$EXIT_CODE" -ne 0 ]]; then
|
||||
echo "FAIL: non-zero exit code $EXIT_CODE after SIGTERM"; exit 1
|
||||
fi
|
||||
echo "[verify] Graceful shutdown OK (exit $EXIT_CODE)"
|
||||
|
||||
# ── US2 check 2: Node.js absent from runtime image ───────────────────────────
|
||||
set +e
|
||||
docker run --rm "$IMAGE" node --version 2>/dev/null
|
||||
NODE_EXIT=$?
|
||||
set -e
|
||||
if [[ "$NODE_EXIT" -eq 0 ]]; then
|
||||
echo "FAIL: node runtime found in production image"; exit 1
|
||||
fi
|
||||
echo "[verify] Node.js absent in runtime image OK"
|
||||
|
||||
# ── C2: no hardcoded secrets in image layers ─────────────────────────────────
|
||||
if docker history --no-trunc "$IMAGE" 2>&1 | grep -qiE "(password|secret_key|api_key|token)"; then
|
||||
echo "FAIL: potential secret found in image history"; exit 1
|
||||
fi
|
||||
echo "[verify] No secrets in image layers OK"
|
||||
|
||||
# ── US3: dep layer cached on source-only rebuild ─────────────────────────────
|
||||
echo "[verify] Testing cache hit on source-only rebuild..."
|
||||
touch ui/src/app/app.component.ts
|
||||
BUILD2_OUTPUT=$(docker build --progress=plain -f ui/Dockerfile.prod ui/ -t "$IMAGE2" 2>&1)
|
||||
if ! echo "$BUILD2_OUTPUT" | grep -q "CACHED"; then
|
||||
echo "FAIL: dependency layer not reused on source-only rebuild"; exit 1
|
||||
fi
|
||||
echo "[verify] Dep layer cache hit confirmed (US3 OK)"
|
||||
|
||||
echo "[verify] All checks passed (US1 + US2 + US3)."
|
||||
Reference in New Issue
Block a user