docker changes #5

Merged
eros merged 1 commits from swengineer/curltastic:docker-changes into main 2026-09-07 19:17:47 -07:00
10 changed files with 274 additions and 4 deletions

22
.dockerignore Normal file
View File

@ -0,0 +1,22 @@
# Allow only the files required by the two builds. No credentials or host dependencies.
**
!Dockerfile
!docker/
!docker/nginx.conf
!frontend/
!frontend/package.json
!frontend/package-lock.json
!frontend/tsconfig.json
!frontend/index.html
!frontend/src/
!frontend/src/**
!frontend/public/
!frontend/public/**
!backend/
!backend/Cargo.toml
!backend/Cargo.lock
!backend/src/
!backend/src/**
**/.env*
**/*.pem
**/*.key

1
.gitignore vendored
View File

@ -11,3 +11,4 @@ e2e/node_modules/
# Misc # Misc
*.log *.log
.DS_Store .DS_Store
.omo/

38
Dockerfile Normal file
View File

@ -0,0 +1,38 @@
# syntax=docker/dockerfile:1
FROM node:22-bookworm-slim AS frontend-build
WORKDIR /app
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
ARG VITE_BASE_PATH=/curltastic/
RUN npm test -- --pool=forks --poolOptions.forks.singleFork \
&& npm run build -- --base="${VITE_BASE_PATH}"
FROM rust:1.96-bookworm AS backend-build
WORKDIR /app
COPY backend/Cargo.toml backend/Cargo.lock ./
COPY backend/src/ ./src/
RUN cargo build --locked --release
FROM debian:bookworm-slim AS backend
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd --gid 10001 curltastic \
&& useradd --uid 10001 --gid 10001 --no-create-home --shell /usr/sbin/nologin curltastic
COPY --from=backend-build /app/target/release/curltastic-backend /usr/local/bin/curltastic-backend
USER 10001:10001
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl --fail --silent --show-error http://127.0.0.1:3000/ || exit 1
ENTRYPOINT ["/usr/local/bin/curltastic-backend"]
FROM nginx:1.28-alpine AS frontend
COPY docker/nginx.conf /etc/nginx/nginx.conf
COPY --from=frontend-build /app/dist/ /usr/share/nginx/html/
USER 101:101
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -q -O /dev/null http://127.0.0.1:8080/healthz || exit 1
ENTRYPOINT ["nginx"]
CMD ["-g", "daemon off;"]

View File

@ -1 +0,0 @@
Product requirements live in `.pm/board.yaml`.

70
docker/README.md Normal file
View File

@ -0,0 +1,70 @@
# Production images
Build from the repository root (no source mounts or runtime package installs):
```sh
docker build --target frontend -t curltastic-frontend .
docker build --target backend -t curltastic-backend .
```
| Target | Listener | Runtime user | Healthcheck |
| --- | --- | --- | --- |
| `frontend` (default final target) | HTTP `8080` | `101:101` | `GET /healthz` |
| `backend` | HTTP/WebSocket `3000` | `10001:10001` | `GET /` |
The frontend build argument `VITE_BASE_PATH` defaults to `/curltastic/` and is
passed to `vite build --base`. There are no required runtime environment
variables, credentials, writable application directories, or persistent volumes.
The Rust server keeps rooms in memory; rebuilding/restarting it loses games.
## Compose integration contract
- Publish/route only frontend port **8080**. Explicitly set Traefik's load-balancer
port to 8080; do not infer it from the upstream nginx image metadata.
- Traefik routes `/curltastic/` and strips `/curltastic` before forwarding.
- Attach frontend to Traefik's external `web` network and a private application
network. Put backend only on that private network, with the service/DNS name
**`curltastic-backend`**. Do not publish backend port 3000 or enable Traefik on it.
- The browser requests public `/curltastic/ws` using its existing host, port and
TLS scheme. nginx receives `/ws` and proxies the upgrade and query string to
`curltastic-backend:3000/ws`. It also proxies `POST /room`, exposed publicly as
`/curltastic/room`. The current frontend creates room IDs locally and does not
make HTTP room requests; there is no separate frontend HTTP API URL to set.
- Docker DNS is re-resolved every five seconds so backend replacement during
Compose watch does not leave nginx using a stale container address.
- Recommended runtime settings: `read_only: true`, `cap_drop: [ALL]`,
`security_opt: [no-new-privileges:true]`. Give frontend a writable `/tmp` tmpfs
(mode 1777) for nginx PID/temp files. Backend needs no writable mount.
- Never mount source code, a Docker socket, or host credentials. The build context
is allowlisted by `.dockerignore`; host dependencies and `.env*` are excluded.
- Use Compose watch `action: rebuild`: frontend changes rebuild the `frontend`
target, backend changes rebuild the `backend` target. Ignore node_modules,
dist, target, .git and other generated files. Watch Dockerfile for both services
and `docker/nginx.conf` for frontend.
The nginx listener expects already-stripped paths; a raw host-port request to
`/curltastic/` does not simulate Traefik. Test the public route through a
prefix-stripping test proxy, or request `/`, `/assets/...`, `/ws`, `/room` directly
when testing nginx alone.
## Development and verification
Local `npm run dev` still defaults to `/` and connects to the page hostname on
backend port 3000. Existing `VITE_WS_URL` overrides are preserved for custom local
setups; Vite variables are build-time settings, not runtime container settings.
Room navigation and share links retain the page path.
```sh
(cd frontend && npm ci && npm test -- --pool=forks --poolOptions.forks.singleFork)
(cd frontend && npm run build -- --base=/curltastic/)
(cd backend && cargo test --locked)
```
The frontend Docker build runs its tests and TypeScript checking before producing
static assets. The backend build uses the committed Cargo lockfile.
Verification at implementation: frontend 26 tests passed, Rust 22 tests passed,
and the production subpath frontend build passed. Both Docker build attempts were
denied by the execution approval layer; image builds, nginx syntax, and live
HTTP/WebSocket proxy behavior therefore remain unverified. No servers were
started and no deployment was performed.

69
docker/nginx.conf Normal file
View File

@ -0,0 +1,69 @@
worker_processes auto;
pid /tmp/nginx.pid;
error_log /dev/stderr warn;
events { worker_connections 1024; }
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
access_log /dev/stdout;
server_tokens off;
sendfile on;
client_body_temp_path /tmp/client_temp;
proxy_temp_path /tmp/proxy_temp;
fastcgi_temp_path /tmp/fastcgi_temp;
uwsgi_temp_path /tmp/uwsgi_temp;
scgi_temp_path /tmp/scgi_temp;
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# Re-resolve the private service after Compose watch replaces its container.
resolver 127.0.0.11 valid=5s ipv6=off;
server {
listen 8080;
root /usr/share/nginx/html;
index index.html;
client_max_body_size 16k;
set $curltastic_backend curltastic-backend:3000;
location = /healthz {
access_log off;
default_type text/plain;
return 200 "ok\n";
}
# Traefik removes /curltastic before these requests reach nginx.
location = /ws {
proxy_pass http://$curltastic_backend$request_uri;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
}
location = /room {
proxy_pass http://$curltastic_backend$request_uri;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Connection "";
}
location /assets/ {
try_files $uri =404;
add_header Cache-Control "public, max-age=31536000, immutable";
}
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache";
}
}
}

37
frontend/src/game.test.ts Normal file
View File

@ -0,0 +1,37 @@
import { afterEach, expect, it, vi } from 'vitest'
import { startGame } from './game'
import { connect } from './net'
import { createHud } from './hud'
vi.mock('./net', () => ({ connect: vi.fn(), sendThrow: vi.fn() }))
vi.mock('./renderer', () => ({ createRenderer: () => ({ draw: vi.fn() }) }))
vi.mock('./hud', () => ({
createHud: vi.fn(),
createVelocitySelector: () => ({ setEnabled: vi.fn() }),
createCurlSelector: () => ({ setEnabled: vi.fn() }),
}))
afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks() })
it.each(['', '?room=INVITE'])('keeps room navigation and share links under the app path (%s)', (search) => {
const element = { addEventListener: vi.fn(), value: 'team1' }
const setShareLink = vi.fn()
vi.mocked(createHud).mockReturnValue({
root: element, velocityControl: element, curlSelector: element,
throwButton: element, teamSelect: element,
setShareLink, setTeam: vi.fn(), update: vi.fn(),
} as unknown as ReturnType<typeof createHud>)
vi.stubGlobal('document', { querySelector: () => ({
querySelector: () => element, appendChild: vi.fn(),
}) })
const location = new URL(`https://example.test/curltastic/${search}`)
const replaceState = vi.fn()
vi.stubGlobal('window', { location, history: { replaceState } })
vi.stubGlobal('localStorage', { getItem: () => null })
vi.stubGlobal('requestAnimationFrame', vi.fn())
startGame()
const room = vi.mocked(connect).mock.calls[0][0]
expect(setShareLink).toHaveBeenCalledWith(`https://example.test/curltastic/?room=${room}`)
if (!search) expect(replaceState).toHaveBeenCalledWith({}, '', `/curltastic/?room=${room}`)
else expect(replaceState).not.toHaveBeenCalled()
})

View File

@ -27,10 +27,10 @@ export function startGame(): void {
let room = params.get('room') let room = params.get('room')
if (!room) { if (!room) {
room = generateRoomCode() room = generateRoomCode()
window.history.replaceState({}, '', `/?room=${room}`) window.history.replaceState({}, '', `${window.location.pathname}?room=${room}`)
} }
const shareLink = `${window.location.origin}/?room=${room}` const shareLink = `${window.location.origin}${window.location.pathname}?room=${room}`
hud.setShareLink(shareLink) hud.setShareLink(shareLink)
const model = new GameModel() const model = new GameModel()

31
frontend/src/net.test.ts Normal file
View File

@ -0,0 +1,31 @@
import { afterEach, expect, it, vi } from 'vitest'
const callbacks = {
onJoined: vi.fn(), onWaiting: vi.fn(), onGameState: vi.fn(),
onTrajectories: vi.fn(), onGameOver: vi.fn(), onError: vi.fn(), onClose: vi.fn(),
}
afterEach(() => {
vi.unstubAllGlobals()
vi.unstubAllEnvs()
vi.resetModules()
})
it.each([
[false, 'https://example.test:8443/curltastic/', '/curltastic/', '', 'wss://example.test:8443/curltastic/ws'],
[false, 'http://localhost:4001/curltastic/', '/curltastic/', '', 'ws://localhost:4001/curltastic/ws'],
[false, 'https://example.test/', '/', '', 'wss://example.test/ws'],
[true, 'http://localhost:5173/', '/', '', 'ws://localhost:3000/ws'],
[true, 'https://example.test:5173/', '/', '', 'wss://example.test:3000/ws'],
[false, 'https://example.test/curltastic/', '/curltastic/', 'wss://custom.test/ws', 'wss://custom.test/ws'],
])('resolves the WebSocket endpoint (dev=%s, page=%s)', async (dev, page, base, override, expected) => {
vi.stubEnv('DEV', dev)
vi.stubEnv('BASE_URL', base)
vi.stubEnv('VITE_WS_URL', override)
vi.stubGlobal('window', { location: new URL(page) })
const WebSocket = vi.fn()
vi.stubGlobal('WebSocket', WebSocket)
const { connect } = await import('./net')
connect('A&B C', callbacks)
expect(WebSocket).toHaveBeenCalledWith(`${expected}?room=A%26B%20C`)
})

View File

@ -10,7 +10,10 @@ const WS_URL = import.meta.env.VITE_WS_URL
function resolveWsUrl(): string { function resolveWsUrl(): string {
if (WS_URL) return WS_URL if (WS_URL) return WS_URL
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
if (import.meta.env.DEV) {
return `${protocol}//${window.location.hostname}:3000/ws` return `${protocol}//${window.location.hostname}:3000/ws`
}
return `${protocol}//${window.location.host}${import.meta.env.BASE_URL}ws`
} }
export interface NetCallbacks { export interface NetCallbacks {