File size: 6,898 Bytes
6abfd9d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
# ═══════════════════════════════════════════════════════════
# GuestFlow API - Production Dockerfile
# Multi-stage build for optimized production image
# ═══════════════════════════════════════════════════════════

# ─── Stage 1: Pruner ───────────────────────────────────────
FROM node:20-slim AS pruner
RUN apt-get update && apt-get install -y --no-install-recommends libc6-dev sqlite3 libsqlite3-dev g++ make python3 && rm -rf /var/lib/apt/lists/*
WORKDIR /app
RUN npm install -g turbo@2.8.12
COPY . .
RUN turbo prune --scope=@micprotocol/api --docker

# ─── Stage 2: Dependencies ─────────────────────────────────
FROM node:20-slim AS deps
RUN apt-get update && apt-get install -y --no-install-recommends libc6-dev sqlite3 libsqlite3-dev g++ make python3 openssl && rm -rf /var/lib/apt/lists/*
WORKDIR /app

# Copy pruned package.json and lockfile
COPY --from=pruner /app/out/json/ .

# Install pnpm
RUN npm install -g pnpm@10.30.3

# Install dependencies
RUN pnpm install --frozen-lockfile

# ─── Stage 3: Builder ──────────────────────────────────────
FROM node:20-slim AS builder
RUN apt-get update && apt-get install -y --no-install-recommends libc6-dev sqlite3 libsqlite3-dev g++ make python3 openssl && rm -rf /var/lib/apt/lists/*
WORKDIR /app

# Install pnpm
RUN npm install -g pnpm@10.30.3

# Copy dependencies from deps stage (includes all node_modules, lockfile, and package.json files)
COPY --from=deps /app /app

# Copy pruned source files on top of it
COPY --from=pruner /app/out/full/ .

# Generate Prisma client
RUN cd apps/api && npx prisma generate

# Build application
RUN NODE_OPTIONS="--max-old-space-size=4096" pnpm build --filter @micprotocol/api

# Prune devDependencies to keep only production dependencies
RUN CI=true pnpm install --prod --frozen-lockfile

# ─── Stage 4: Production ─────────────────────────────────
FROM node:20-slim AS runner
WORKDIR /app

ENV NODE_ENV=production
ENV PORT=4000
ENV MEILISEARCH_URL=http://localhost:7700
ENV MEILISEARCH_API_KEY=masterKey

RUN apt-get update && apt-get install -y --no-install-recommends libc6-dev openssl libgcc1 tini ffmpeg sqlite3 libsqlite3-dev netcat-openbsd python3 && rm -rf /var/lib/apt/lists/*

# Copy Meilisearch binary from the official image
COPY --from=getmeili/meilisearch:v1.9.0 /bin/meilisearch /usr/local/bin/meilisearch

# Create non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nestjs

# Copy entire pruned application and dependencies
COPY --from=builder --chown=nestjs:nodejs /app /app

# ─── Python clip-scoring engine ──────────────────────────
# The BullMQ clip-scoring processor spawns
# `python3 services/clip-scorer/pipeline.py` via execFile
# (apps/api/src/queue/clip-scoring.processor.ts), so this image must
# ship python3, the ML stack, and the pipeline source. torch is pinned
# to the CPU wheel index: CUDA is unnecessary for the 224px ViT-B/32
# inference and would add ~2GB of NVIDIA runtime deps.
# The venv is pinned to the system python: without it uv probes the
# newest CPython it can download (3.15 here), for which torchvision has
# no wheels β€” the clip-scoring stack then can't resolve. torch AND
# torchvision must land in one resolution pass from the CPU index:
# open-clip-torch declares both as deps, and a PyPI-resolved torchvision
# breaks open_clip import with "operator torchvision::nms does not
# exist" (same failure class as the standalone clip-scorer image).
# Build with the monorepo root as context (docker-compose does exactly
# that for the `api` and `worker` services) so services/clip-scorer is
# in scope β€” turbo prune does not carry it (not a pnpm workspace).
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
COPY --chown=nestjs:nodejs services/clip-scorer/ /app/services/clip-scorer/
ENV PATH="/opt/venv/bin:$PATH" \
    HF_HOME=/app/.hf-cache
RUN uv venv --python /usr/bin/python3 /opt/venv \
    && VIRTUAL_ENV=/opt/venv uv pip install --index-url https://download.pytorch.org/whl/cpu "torch>=2.0.0" "torchvision>=0.15.0" \
    && VIRTUAL_ENV=/opt/venv uv pip install "open-clip-torch>=2.20.0" "transformers>=4.30.0" "Pillow>=10.0.0" "soundfile>=0.12.0" "pyloudnorm>=0.1.0" "numpy>=1.24.0"
# Pre-baked CLIP weights come from the standalone clip-scorer image
# (its build already downloaded the model) so this build does NOT need
# Hub access. Placed after the venv layer so upgrading torch does not
# force a weight re-copy (and vice versa).
COPY --from=localhost/micprotocol-clip-scorer:audit-verify \
     /root/.cache/huggingface/hub/ \
     /app/.hf-cache/hub/
# Offline verification: fail the build if the baked cache ever stops
# matching what open_clip 3.x expects to find. BART (transcript NLI) is
# intentionally not baked β€” it is a ~1.6GB download and the pipeline
# falls back to the heuristic scorer when it cannot download at runtime.
RUN HF_HUB_OFFLINE=1 python3 -c "import open_clip; open_clip.create_model_and_transforms('ViT-B-32', pretrained='laion2b_s34b_b79k')"

# Create data directory for Meilisearch and set ownership
RUN mkdir -p /app/meili_data && chown -R nestjs:nodejs /app

# Switch to non-root user
USER nestjs

# Create startup script
RUN echo '#!/bin/sh' > /app/start.sh && \
    echo 'MEILI_HOST=$(echo ${MEILISEARCH_HOST:-localhost} | sed "s|http://||;s|https://||")' >> /app/start.sh && \
    echo 'MEILI_PORT=$(echo $MEILI_HOST | cut -d: -f2)' >> /app/start.sh && \
    echo '[ -z "$MEILI_PORT" ] && MEILI_PORT=7700' >> /app/start.sh && \
    echo 'MEILI_HOST=$(echo $MEILI_HOST | cut -d: -f1)' >> /app/start.sh && \
    echo 'echo "Waiting for Meilisearch at $MEILI_HOST:$MEILI_PORT..."' >> /app/start.sh && \
    echo 'while ! nc -z $MEILI_HOST $MEILI_PORT; do' >> /app/start.sh && \
    echo '  sleep 0.5' >> /app/start.sh && \
    echo 'done' >> /app/start.sh && \
    echo 'echo "Meilisearch is ready!"' >> /app/start.sh && \
    echo 'node /app/apps/api/dist/main.js' >> /app/start.sh && \
    chmod +x /app/start.sh

# Expose ports
EXPOSE 4000
EXPOSE 7700

# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
    CMD node -e "require('http').get('http://localhost:4000/health', (r) => r.statusCode === 200 ? process.exit(0) : process.exit(1))"

# Start application
ENTRYPOINT ["tini", "--"]
CMD ["/app/start.sh"]