forked from eros/curltastic
docker changes
This commit is contained in:
parent
440d383067
commit
0a81308baf
22
.dockerignore
Normal file
22
.dockerignore
Normal 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
1
.gitignore
vendored
@ -11,3 +11,4 @@ e2e/node_modules/
|
|||||||
# Misc
|
# Misc
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
.omo/
|
||||||
|
|||||||
38
Dockerfile
Normal file
38
Dockerfile
Normal 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;"]
|
||||||
@ -1 +0,0 @@
|
|||||||
Product requirements live in `.pm/board.yaml`.
|
|
||||||
70
docker/README.md
Normal file
70
docker/README.md
Normal 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
69
docker/nginx.conf
Normal 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
37
frontend/src/game.test.ts
Normal 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()
|
||||||
|
})
|
||||||
@ -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
31
frontend/src/net.test.ts
Normal 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`)
|
||||||
|
})
|
||||||
@ -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 {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user