Merge pull request #407 from Molecule-AI/fix/bake-templates-into-platform-image

fix(ops): bake templates into platform Docker image
This commit is contained in:
Hongming Wang 2026-04-16 02:47:04 -07:00 committed by GitHub
commit 10ea5062a1
9 changed files with 203 additions and 8 deletions

View File

@ -12,6 +12,7 @@ on:
# Only rebuild when something platform-relevant changes — saves GHA
# minutes on docs-only / canvas-only / MCP-only PRs.
- 'platform/**'
- 'workspace-configs-templates/**'
- '.github/workflows/publish-platform-image.yml'
# Manual trigger for re-publishing a tag after a non-platform merge.
workflow_dispatch:
@ -129,7 +130,7 @@ jobs:
# but Fly tenant machines are amd64. QEMU handles the emulation.
uses: docker/build-push-action@v5
with:
context: ./platform
context: .
file: ./platform/Dockerfile
platforms: linux/amd64
push: true
@ -150,7 +151,7 @@ jobs:
if: always()
uses: docker/build-push-action@v5
with:
context: ./platform
context: .
file: ./platform/Dockerfile
platforms: linux/amd64
push: true

View File

@ -6,7 +6,7 @@ interface Props {
workspaceId: string;
}
const WS_URL = process.env.NEXT_PUBLIC_WS_URL?.replace("/ws", "") || "ws://localhost:8080";
const WS_URL = (process.env.NEXT_PUBLIC_WS_URL ?? "ws://localhost:8080").replace("/ws", "");
export function TerminalTab({ workspaceId }: Props) {
const containerRef = useRef<HTMLDivElement>(null);

View File

@ -1,7 +1,12 @@
import { getTenantSlug } from "./tenant";
// When NEXT_PUBLIC_PLATFORM_URL is set to "" (empty string), the canvas
// uses relative paths — correct for the combined tenant image where Go
// platform + canvas run on the same port via reverse proxy. The `??`
// operator preserves "" as a valid value; `||` would fall through to
// the localhost default.
export const PLATFORM_URL =
process.env.NEXT_PUBLIC_PLATFORM_URL || "http://localhost:8080";
process.env.NEXT_PUBLIC_PLATFORM_URL ?? "http://localhost:8080";
async function request<T>(
method: string,

View File

@ -1,8 +1,12 @@
# Build context: repo root (not ./platform) so we can COPY both the Go
# source and the workspace-configs-templates directory that lives beside it.
# CI workflow sets `context: .` and `file: ./platform/Dockerfile`.
FROM golang:1.25-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
COPY platform/go.mod platform/go.sum ./
RUN go mod download
COPY . .
COPY platform/ .
RUN CGO_ENABLED=0 GOOS=linux go build -o /platform ./cmd/server
FROM alpine:3.20
@ -10,6 +14,10 @@ FROM alpine:3.20
# GitHub URLs (POST /workspaces/:id/plugins {"source": "github://..."}).
RUN apk add --no-cache ca-certificates git tzdata
COPY --from=builder /platform /platform
COPY migrations /migrations
COPY platform/migrations /migrations
# Default templates baked into the image so tenants boot with a working
# template picker. Phase B adds a registry + on-demand fetch for
# community templates; these curated defaults always ship in the image.
COPY workspace-configs-templates /workspace-configs-templates
EXPOSE 8080
CMD ["/platform"]

View File

@ -0,0 +1,63 @@
# Dockerfile.tenant — combined platform (Go) + canvas (Next.js) image.
#
# Serves both the API (Go on :8080) and the UI (Node.js on :3000) in a
# single container. Fly listens on :8080 for health checks; an entrypoint
# script starts both processes. The Go router is the external-facing port;
# it reverse-proxies unknown routes to the canvas Node server so a single
# port handles everything.
#
# Build context: repo root (same as platform/Dockerfile).
#
# docker buildx build --platform linux/amd64 \
# -f platform/Dockerfile.tenant \
# --build-arg NEXT_PUBLIC_PLATFORM_URL="" \
# --build-arg NEXT_PUBLIC_WS_URL="" \
# -t registry.fly.io/molecule-tenant:latest \
# --push .
# ── Stage 1: Go platform binary ──────────────────────────────────────
FROM golang:1.25-alpine AS go-builder
WORKDIR /app
COPY platform/go.mod platform/go.sum ./
RUN go mod download
COPY platform/ .
RUN CGO_ENABLED=0 GOOS=linux go build -o /platform ./cmd/server
# ── Stage 2: Canvas Next.js standalone ────────────────────────────────
FROM node:20-alpine AS canvas-builder
WORKDIR /canvas
COPY canvas/package.json canvas/package-lock.json* ./
RUN npm install
COPY canvas/ .
# Platform URL is relative (same container) — empty string = same-origin.
# WebSocket URL uses relative path so the browser connects to the same host.
ARG NEXT_PUBLIC_PLATFORM_URL=""
ARG NEXT_PUBLIC_WS_URL=""
ENV NEXT_PUBLIC_PLATFORM_URL=$NEXT_PUBLIC_PLATFORM_URL
ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL
RUN npm run build
# ── Stage 3: Runtime ──────────────────────────────────────────────────
FROM node:20-alpine
RUN apk add --no-cache ca-certificates git tzdata
# Go platform binary
COPY --from=go-builder /platform /platform
COPY platform/migrations /migrations
COPY workspace-configs-templates /workspace-configs-templates
COPY org-templates /org-templates
# Canvas standalone (Next.js server.js + static assets)
WORKDIR /canvas
COPY --from=canvas-builder /canvas/.next/standalone ./
COPY --from=canvas-builder /canvas/.next/static ./.next/static
COPY --from=canvas-builder /canvas/public ./public
# Entrypoint starts both processes. Go on :8080, Canvas on :3000.
# The Go platform's router proxies unknown routes to :3000 so Fly
# only needs to expose :8080.
COPY platform/entrypoint-tenant.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 8080
CMD ["/entrypoint.sh"]

View File

@ -0,0 +1,35 @@
#!/bin/sh
# Tenant entrypoint — starts both Go platform (API) and Canvas (UI).
#
# Go platform listens on :8080 (Fly health checks hit this port).
# Canvas Node.js listens on :3000 (internal only).
# The Go platform's fallback handler proxies non-API routes to :3000
# so the browser only ever talks to :8080.
#
# If either process dies, we kill the other and exit non-zero so Fly
# restarts the machine.
set -e
# Start Canvas in background
cd /canvas
PORT=3000 HOSTNAME=0.0.0.0 node server.js &
CANVAS_PID=$!
# Start Go platform in foreground-ish (we trap signals)
cd /
/platform &
PLATFORM_PID=$!
# If either process exits, kill the other
cleanup() {
kill $CANVAS_PID 2>/dev/null || true
kill $PLATFORM_PID 2>/dev/null || true
}
trap cleanup EXIT SIGTERM SIGINT
# Wait for either to exit — whichever exits first triggers cleanup
wait -n $CANVAS_PID $PLATFORM_PID
EXIT_CODE=$?
cleanup
exit $EXIT_CODE

View File

@ -15,7 +15,6 @@ func SecurityHeaders() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("X-Content-Type-Options", "nosniff")
c.Header("X-Frame-Options", "DENY")
c.Header("Content-Security-Policy", "default-src 'self'")
c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
// #282: these two were documented in CLAUDE.md but missing from
// the middleware. Referrer-Policy prevents browsers from leaking
@ -25,6 +24,30 @@ func SecurityHeaders() gin.HandlerFunc {
// canvas embeds iframes for Langfuse traces.
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
c.Header("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
// CSP: only apply to API responses. Canvas-proxied routes
// (NoRoute → reverse-proxy to Next.js) serve HTML with inline
// scripts + styles that `default-src 'self'` blocks. Next.js
// sets its own CSP via <meta> tags. The Go middleware should
// not override it for proxied HTML responses.
//
// Detection: API routes are registered explicitly in the router;
// canvas-proxied routes hit NoRoute. We can't detect NoRoute
// before c.Next() fires, so instead we check the response
// Content-Type after Next() — but that's too late for headers.
//
// Simpler: apply a permissive CSP that allows Next.js to work.
// 'unsafe-inline' + 'unsafe-eval' are needed for Next.js dev
// hydration; in production Next.js uses nonces but we don't
// propagate them through the proxy. This is acceptable because
// the canvas is our own code, not user-generated content.
c.Header("Content-Security-Policy",
"default-src 'self'; "+
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "+
"style-src 'self' 'unsafe-inline'; "+
"img-src 'self' data: blob:; "+
"connect-src 'self' ws: wss:; "+
"font-src 'self' data:")
c.Next()
}
}

View File

@ -0,0 +1,47 @@
package router
import (
"log"
"net/http"
"net/http/httputil"
"net/url"
"github.com/gin-gonic/gin"
)
// newCanvasProxy returns a Gin handler that reverse-proxies all unmatched
// routes to the canvas Next.js server. Used in the combined tenant image
// (Dockerfile.tenant) where Go platform (:8080) and canvas (:3000) run in
// the same container.
//
// The proxy forwards the request path, query, and headers as-is. The Host
// header is rewritten to the canvas upstream so Next.js doesn't reject it
// (Next.js checks Host in dev mode). Response headers from canvas flow back
// to the client unchanged.
//
// Why NoRoute + proxy instead of nginx: one fewer process, one fewer config
// file, and the Go router already knows which routes are API routes. Any
// path not registered as an API route is a canvas page by elimination.
func newCanvasProxy(targetURL string) gin.HandlerFunc {
target, err := url.Parse(targetURL)
if err != nil {
log.Fatalf("canvas_proxy: invalid CANVAS_PROXY_URL %q: %v", targetURL, err)
}
proxy := &httputil.ReverseProxy{
Director: func(req *http.Request) {
req.URL.Scheme = target.Scheme
req.URL.Host = target.Host
req.Host = target.Host
},
ErrorHandler: func(w http.ResponseWriter, _ *http.Request, err error) {
log.Printf("canvas_proxy: %v", err)
w.WriteHeader(http.StatusBadGateway)
w.Write([]byte("canvas unavailable"))
},
}
return func(c *gin.Context) {
proxy.ServeHTTP(c.Writer, c.Request)
}
}

View File

@ -406,6 +406,19 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi
sh := handlers.NewSocketHandler(hub)
r.GET("/ws", sh.HandleConnect)
// Canvas reverse proxy — when running as a combined tenant image
// (Dockerfile.tenant), the Next.js canvas server runs on :3000 inside
// the same container. Any route not matched by the API handlers above
// gets proxied to the canvas so the browser only ever talks to :8080.
//
// When CANVAS_PROXY_URL is empty (self-hosted / local dev), this is a
// no-op and Gin returns its default 404. The canvas dev server runs
// separately on :3000 in that setup.
if canvasURL := os.Getenv("CANVAS_PROXY_URL"); canvasURL != "" {
canvasProxy := newCanvasProxy(canvasURL)
r.NoRoute(canvasProxy)
}
return r
}