From 369d136d19c0014541f84b28d115e6acbb78dc18 Mon Sep 17 00:00:00 2001 From: VanJay Date: Fri, 8 May 2026 11:51:51 +0800 Subject: [PATCH] Add Docker Compose deployment workflow (#65) * Add Docker Compose deployment workflow * Address Docker deployment review feedback Harden publishing inputs and temporary credential handling, and tighten Docker runtime defaults requested by the PR review. Co-Authored-By: Claude Opus 4.7 (1M context) * Fix Docker publish build in CI mode Set CI=true during the image build so pnpm prune can run non-interactively inside Docker. Co-Authored-By: Claude Opus 4.7 (1M context) * Fix Docker runtime dependency layout Use pnpm deploy for the daemon package so the runtime image includes production dependencies where Node resolves them. Co-Authored-By: Claude Opus 4.7 (1M context) * Use legacy pnpm deploy in Docker build Allow pnpm v10 deploy to package the daemon workspace without requiring injected workspace packages. Co-Authored-By: Claude Opus 4.7 (1M context) * Align Docker runtime with Node 24 Use Node 24 for both build and runtime stages and update image verification for the workspace daemon dependency layout. Co-Authored-By: Claude Opus 4.7 (1M context) * Remove legacy OD_HOST Docker binding fallback Use OD_BIND_HOST as the single daemon bind-host setting for Docker deployment and origin validation. * Update Docker image verifier for daemon dist runtime Check the packaged daemon dist entrypoint and allow npm from the Node 24 runtime image while still rejecting build-only tools. * Allow private LAN browser origins for daemon * Share daemon origin validation helpers Move browser origin validation into a shared daemon module so tests exercise the production logic and cover the remaining private LAN edge cases. * Harden Docker Compose port exposure Bind the Compose deployment to localhost by default and pass the published port through to the daemon origin checks so host-port overrides remain same-origin. * Keep deployment hosts out of local-only no-origin checks Require an actual matching Origin before configured deployment origins can satisfy local-only daemon guards, preventing no-Origin remote clients from bypassing those checks. --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: mrcfps Co-authored-by: lefarcen <935902669@qq.com> --- .dockerignore | 20 + apps/daemon/src/origin-validation.ts | 167 +++++ apps/daemon/src/server.ts | 82 +-- apps/daemon/tests/app-config.test.ts | 14 +- apps/daemon/tests/mcp-install-info.test.ts | 21 +- apps/daemon/tests/origin-validation.test.ts | 156 ++++- deploy/.env.example | 18 + deploy/Dockerfile | 97 +++ deploy/README.md | 66 ++ deploy/docker-compose.yml | 43 ++ deploy/scripts/publish-images.sh | 658 ++++++++++++++++++++ deploy/scripts/verify-image-manifest.sh | 30 + deploy/scripts/verify-image.sh | 94 +++ 13 files changed, 1373 insertions(+), 93 deletions(-) create mode 100644 .dockerignore create mode 100644 apps/daemon/src/origin-validation.ts create mode 100644 deploy/.env.example create mode 100644 deploy/Dockerfile create mode 100644 deploy/README.md create mode 100644 deploy/docker-compose.yml create mode 100755 deploy/scripts/publish-images.sh create mode 100755 deploy/scripts/verify-image-manifest.sh create mode 100755 deploy/scripts/verify-image.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..75dc0ba04 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,20 @@ +.git +.github +.DS_Store +.claude-sessions +.cursor +.task +.od +.ocd +node_modules +dist +coverage +*.log +tsconfig.tsbuildinfo +deploy/*.env +deploy/.env +deploy/**/*.tar +deploy/**/*.tgz +deploy/**/*.zip +docs +story diff --git a/apps/daemon/src/origin-validation.ts b/apps/daemon/src/origin-validation.ts new file mode 100644 index 000000000..2b36221da --- /dev/null +++ b/apps/daemon/src/origin-validation.ts @@ -0,0 +1,167 @@ +export interface ParsedHostHeader { + hostname: string; + host: string; + port: string; +} + +export interface RequestWithOriginHeaders { + headers?: { + host?: unknown; + origin?: unknown; + }; +} + +export function configuredAllowedOrigins(env: NodeJS.ProcessEnv = process.env): string[] { + const raw = env.OD_ALLOWED_ORIGINS || ''; + if (!raw.trim()) return []; + return raw + .split(',') + .map((origin) => origin.trim()) + .filter(Boolean) + .map((origin) => { + const parsed = new URL(origin); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error('OD_ALLOWED_ORIGINS only supports http:// and https:// origins'); + } + return parsed.origin; + }); +} + +export function configuredAllowedHosts(origins = configuredAllowedOrigins()): string[] { + return origins.map((origin) => new URL(origin).host); +} + +export function allowedBrowserPorts( + port: number | string | null | undefined, + env: NodeJS.ProcessEnv = process.env, +): number[] { + const ports = []; + const primary = Number(port); + if (primary) ports.push(primary); + const webPort = Number(env.OD_WEB_PORT); + if (webPort && webPort !== primary) ports.push(webPort); + return ports; +} + +export function parseHostHeader(value: unknown): ParsedHostHeader | null { + const raw = String(headerValue(value) || '').trim(); + if (!raw) return null; + try { + const parsed = new URL(`http://${raw}`); + return { hostname: parsed.hostname, host: parsed.host, port: parsed.port || '80' }; + } catch { + return null; + } +} + +export function isPrivateIpv4(hostname: unknown): boolean { + const parts = String(hostname || '').split('.'); + if (parts.length !== 4) return false; + if (!parts.every((part) => /^\d+$/.test(part))) return false; + const octets = parts.map((part) => Number(part)); + if (!octets.every((n) => Number.isInteger(n) && n >= 0 && n <= 255)) return false; + const [a, b] = octets as [number, number, number, number]; + return ( + a === 10 || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) || + (a === 169 && b === 254) + ); +} + +export function isLoopbackOrPrivateLanHost(hostname: unknown): boolean { + const host = String(hostname || '').toLowerCase(); + return ( + host === 'localhost' || + host === '127.0.0.1' || + host === '::1' || + host === '[::1]' || + host === '0.0.0.0' || + host === '::' || + isPrivateIpv4(host) + ); +} + +export function isAllowedBrowserHost( + hostHeader: unknown, + ports: number[], + bindHost: string, + extraAllowedOrigins: string[], +): boolean { + const requestHost = parseHostHeader(hostHeader); + if (!requestHost) return false; + + const loopbackHosts = ['127.0.0.1', 'localhost', '[::1]']; + const explicitHosts = new Set([ + ...ports.flatMap((p) => [ + ...loopbackHosts.map((h) => `${h}:${p}`), + `${bindHost}:${p}`, + ]), + ...configuredAllowedHosts(extraAllowedOrigins), + ]); + if (explicitHosts.has(requestHost.host)) return true; + + if (!ports.map(String).includes(requestHost.port)) return false; + return isLoopbackOrPrivateLanHost(requestHost.hostname); +} + +export function isAllowedBrowserOrigin( + origin: unknown, + hostHeader: unknown, + ports: number[], + bindHost: string, + extraAllowedOrigins: string[], +): boolean { + if (extraAllowedOrigins.includes(String(origin))) return true; + + let parsedOrigin; + try { + parsedOrigin = new URL(String(origin)); + } catch { + return false; + } + if (parsedOrigin.protocol !== 'http:' && parsedOrigin.protocol !== 'https:') return false; + + const requestHost = parseHostHeader(hostHeader); + if (!requestHost) return false; + + const schemes = ['http', 'https']; + const loopbackHosts = ['127.0.0.1', 'localhost', '[::1]']; + const explicitOrigins = new Set( + ports.flatMap((p) => [ + ...schemes.flatMap((s) => loopbackHosts.map((h) => `${s}://${h}:${p}`)), + ...schemes.map((s) => `${s}://${bindHost}:${p}`), + ]), + ); + if (explicitOrigins.has(String(origin))) return true; + + const originPort = parsedOrigin.port || (parsedOrigin.protocol === 'https:' ? '443' : '80'); + if (!ports.map(String).includes(originPort)) return false; + if (parsedOrigin.hostname !== requestHost.hostname) return false; + return isLoopbackOrPrivateLanHost(parsedOrigin.hostname); +} + +export function isLocalSameOrigin( + req: RequestWithOriginHeaders, + port: number | string | null | undefined, + env: NodeJS.ProcessEnv = process.env, +): boolean { + const host = String(headerValue(req.headers?.host) || ''); + const origin = headerValue(req.headers?.origin); + const ports = allowedBrowserPorts(port, env); + const bindHost = env.OD_BIND_HOST || '127.0.0.1'; + const extraAllowedOrigins = configuredAllowedOrigins(env); + + const localHostAllowed = isAllowedBrowserHost(host, ports, bindHost, []); + if (origin == null || origin === '') return localHostAllowed; + if (!isAllowedBrowserHost(host, ports, bindHost, extraAllowedOrigins)) return false; + return isAllowedBrowserOrigin(origin, host, ports, bindHost, extraAllowedOrigins); +} + +function headerValue(value: unknown): string | undefined { + if (Array.isArray(value)) { + const first = value[0]; + return first == null ? undefined : String(first); + } + return value == null ? undefined : String(value); +} diff --git a/apps/daemon/src/server.ts b/apps/daemon/src/server.ts index cf48e032f..694fecd9c 100644 --- a/apps/daemon/src/server.ts +++ b/apps/daemon/src/server.ts @@ -164,6 +164,12 @@ import { VERCEL_PROVIDER_ID, writeDeployConfig, } from './deploy.js'; +import { + allowedBrowserPorts, + configuredAllowedOrigins, + isAllowedBrowserOrigin, + isLocalSameOrigin, +} from './origin-validation.js'; /** @typedef {import('@open-design/contracts').ApiErrorCode} ApiErrorCode */ /** @typedef {import('@open-design/contracts').ApiError} ApiError */ @@ -1517,36 +1523,13 @@ export function createSseResponse( export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST || '127.0.0.1', returnServer = false } = {}) { let resolvedPort = port; + const extraAllowedOrigins = configuredAllowedOrigins(); const app = express(); app.use(express.json({ limit: '4mb' })); - // Build the set of allowed browser origins for the current bind config. - // Shared by the global origin middleware and isLocalSameOrigin() so - // both use the same policy (loopback + explicit bind host, HTTP + HTTPS, - // OD_WEB_PORT support). - function buildAllowedOrigins() { - const ports = [resolvedPort]; - const webPort = Number(process.env.OD_WEB_PORT); - if (webPort && webPort !== resolvedPort) ports.push(webPort); - const schemes = ['http', 'https']; - const loopbackHosts = ['127.0.0.1', 'localhost', '[::1]']; - return new Set( - ports.flatMap((p) => [ - ...schemes.flatMap((s) => loopbackHosts.map((h) => `${s}://${h}:${p}`)), - // When bound to a specific non-loopback address (e.g. Tailscale, - // LAN IP, or 0.0.0.0), allow browser requests from that address - // too so the documented --host escape hatch remains usable. - ...schemes.map((s) => `${s}://${host}:${p}`), - ]), - ); - } - - // Portless loopback origins (e.g. http://127.0.0.1 without a port). // Chrome may strip the port from the Origin header on same-origin GET - // requests. Only used as a fallback for safe, idempotent GET requests; - // mutating routes (POST/PUT/PATCH/DELETE) always require an exact - // port-match via buildAllowedOrigins() or isLocalSameOrigin() to - // prevent local CSRF from a page on the default port (80). + // requests. Only use this as a fallback for safe, idempotent GET requests; + // mutating routes always require an exact origin/host match. function isPortlessLoopbackOrigin(origin) { return /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])$/.test(origin); } @@ -1585,14 +1568,8 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST return res.status(403).json({ error: 'Server initializing' }); } - if (!buildAllowedOrigins().has(String(origin))) { - // Fallback: Chrome may strip the port from the Origin header on - // same-origin requests (e.g. http://127.0.0.1 instead of - // http://127.0.0.1:6313). Allow portless loopback origins only - // for GET requests, which are idempotent and safe from CSRF. - // Mutating methods (POST/PUT/PATCH/DELETE) always require an - // exact port-match to prevent a page on the default port (80) - // from triggering state-changing operations. + const ports = allowedBrowserPorts(resolvedPort); + if (!isAllowedBrowserOrigin(origin, req.headers.host, ports, host, extraAllowedOrigins)) { if (req.method !== 'GET' || !isPortlessLoopbackOrigin(String(origin))) { return res.status(403).json({ error: 'Cross-origin requests are not allowed' }); } @@ -5727,40 +5704,3 @@ export function rewriteSkillAssetUrls(html: string, skillId: string): string { }, ); } - -export function isLocalSameOrigin(req, port) { - // Accepts http + https, loopback hosts, OD_WEB_PORT, and the explicit - // bind host — matching the global origin middleware policy exactly. - const host = String(req.headers.host || ''); - const origin = req.headers.origin; - - // Build allowed set inline (same logic as buildAllowedOrigins in - // startServer, but self-contained so the exported helper works - // without closing over server-scoped variables). - const ports = [port]; - const webPort = Number(process.env.OD_WEB_PORT); - if (webPort && webPort !== port) ports.push(webPort); - const bindHost = process.env.OD_BIND_HOST || '127.0.0.1'; - const loopbackHosts = ['127.0.0.1', 'localhost', '[::1]']; - const allowedHosts = new Set( - ports.flatMap((p) => [ - ...loopbackHosts.map((h) => `${h}:${p}`), - `${bindHost}:${p}`, - ]), - ); - - // Reject unknown Host first (DNS rebinding / Host header attack) - if (!allowedHosts.has(host)) return false; - - // Non-browser client with valid Host → allow - if (origin == null || origin === '') return true; - - const schemes = ['http', 'https']; - const allowedOrigins = new Set( - ports.flatMap((p) => [ - ...schemes.flatMap((s) => loopbackHosts.map((h) => `${s}://${h}:${p}`)), - ...schemes.map((s) => `${s}://${bindHost}:${p}`), - ]), - ); - return allowedOrigins.has(String(origin)); -} diff --git a/apps/daemon/tests/app-config.test.ts b/apps/daemon/tests/app-config.test.ts index 6eb2b11d6..2715592ed 100644 --- a/apps/daemon/tests/app-config.test.ts +++ b/apps/daemon/tests/app-config.test.ts @@ -14,7 +14,7 @@ import { } from 'vitest'; import { readAppConfig, writeAppConfig } from '../src/app-config.js'; -import { isLocalSameOrigin } from '../src/server.js'; +import { isLocalSameOrigin } from '../src/origin-validation.js'; describe('app-config', () => { let dataDir: string; @@ -420,6 +420,18 @@ describe('app-config origin guard', () => { expect(res.status).toBe(403); }); + it('rejects no-Origin requests that only match configured deployment hosts', async () => { + process.env.OD_ALLOWED_ORIGINS = 'https://od.example.com'; + try { + const res = await httpRequest(`${baseUrl}/api/app-config`, { + headers: { Host: 'od.example.com' }, + }); + expect(res.status).toBe(403); + } finally { + delete process.env.OD_ALLOWED_ORIGINS; + } + }); + it('still rejects non-loopback Origin', async () => { const res = await httpRequest(`${baseUrl}/api/app-config`, { headers: { diff --git a/apps/daemon/tests/mcp-install-info.test.ts b/apps/daemon/tests/mcp-install-info.test.ts index be1210aee..a5711c48e 100644 --- a/apps/daemon/tests/mcp-install-info.test.ts +++ b/apps/daemon/tests/mcp-install-info.test.ts @@ -4,9 +4,9 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import express from 'express'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { SIDECAR_DEFAULTS, SIDECAR_ENV } from '@open-design/sidecar-proto'; -import { isLocalSameOrigin } from '../src/server.js'; +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { isLocalSameOrigin } from '../src/origin-validation.js'; import { buildMcpInstallPayload } from '../src/mcp-install-info.js'; // The install-info endpoint is a self-contained handler that resolves @@ -152,6 +152,11 @@ describe('GET /api/mcp/install-info', () => { }), ); + afterEach(() => { + delete process.env.OD_ALLOWED_ORIGINS; + delete process.env.OD_BIND_HOST; + }); + it('non-sidecar launch bakes --daemon-url so custom ports keep working', async () => { const { port } = nonSidecar; const res = await fetch(`http://127.0.0.1:${port}/api/mcp/install-info`); @@ -202,6 +207,18 @@ describe('GET /api/mcp/install-info', () => { expect(res.status).toBe(200); }); + it('accepts explicitly configured deployment origins', async () => { + const { port } = nonSidecar; + process.env.OD_ALLOWED_ORIGINS = `https://od.example.com,http://203.0.113.10:${port}`; + const res = await fetch(`http://127.0.0.1:${port}/api/mcp/install-info`, { + headers: { + Host: 'od.example.com', + Origin: 'https://od.example.com', + }, + }); + expect(res.status).toBe(200); + }); + it('caches the payload across rapid calls', async () => { const { port, app } = nonSidecar; const before = (app as any)._resolveCalls(); diff --git a/apps/daemon/tests/origin-validation.test.ts b/apps/daemon/tests/origin-validation.test.ts index 8fcd0138d..23454a3b9 100644 --- a/apps/daemon/tests/origin-validation.test.ts +++ b/apps/daemon/tests/origin-validation.test.ts @@ -2,15 +2,14 @@ import http from 'node:http'; import express from 'express'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { + allowedBrowserPorts, + configuredAllowedOrigins, + isAllowedBrowserOrigin, + isLocalSameOrigin, +} from '../src/origin-validation.js'; -/** - * Replicate the origin validation middleware from server.ts exactly - * as it appears in the real daemon, so we test the actual logic - * including OD_WEB_PORT, Origin: null scoping, and non-loopback host. - */ function createOriginMiddleware(resolvedPort, host = '127.0.0.1') { - // Routes that serve content to sandboxed iframes (Origin: null) for - // read-only purposes. const _NULL_ORIGIN_SAFE_GET_RE = /^\/projects\/[^/]+\/raw\/|^\/codex-pets\/[^/]+\/spritesheet$/; return (req, res, next) => { @@ -27,18 +26,9 @@ function createOriginMiddleware(resolvedPort, host = '127.0.0.1') { if (!resolvedPort) { return res.status(403).json({ error: 'Server initializing' }); } - const ports = [resolvedPort]; - const webPort = Number(process.env.OD_WEB_PORT); - if (webPort && webPort !== resolvedPort) ports.push(webPort); - const schemes = ['http', 'https']; - const loopbackHosts = ['127.0.0.1', 'localhost', '[::1]']; - const allowedOrigins = new Set( - ports.flatMap((p) => [ - ...schemes.flatMap((s) => loopbackHosts.map((h) => `${s}://${h}:${p}`)), - ...schemes.map((s) => `${s}://${host}:${p}`), - ]), - ); - if (!allowedOrigins.has(String(origin))) { + const ports = allowedBrowserPorts(resolvedPort); + const extraAllowedOrigins = configuredAllowedOrigins(); + if (!isAllowedBrowserOrigin(origin, req.headers.host, ports, host, extraAllowedOrigins)) { return res.status(403).json({ error: 'Cross-origin requests are not allowed' }); } next(); @@ -51,6 +41,12 @@ function makeTestApp(port, host = '127.0.0.1') { app.use('/api', createOriginMiddleware(port, host)); app.get('/api/health', (_req, res) => res.json({ ok: true })); app.get('/api/projects', (_req, res) => res.json({ projects: [] })); + app.post('/api/active', (req, res) => { + if (!isLocalSameOrigin(req, port)) { + return res.status(403).json({ error: 'cross-origin request rejected' }); + } + res.json({ active: true }); + }); app.get('/api/projects/:id/raw/:name', (req, res) => { // Mimics the real raw-file route that sets CORS for Origin: null if (req.headers.origin === 'null') { @@ -147,6 +143,116 @@ describe('daemon origin validation middleware', () => { expect(res.status).toBe(200); }); + it('allows same-origin requests from a private LAN address', async () => { + const lanHost = `192.168.18.16:${port}`; + const res = await request(port, 'POST', '/api/projects', { + origin: `http://${lanHost}`, + headers: { + Host: lanHost, + 'content-type': 'application/json', + }, + }); + expect(res.status).toBe(200); + }); + + it.each([ + '10.0.5.12', + '172.16.0.1', + '172.31.255.254', + '169.254.10.20', + ])('allows same-origin requests from private LAN range %s', async (host) => { + const lanHost = `${host}:${port}`; + const res = await request(port, 'POST', '/api/projects', { + origin: `http://${lanHost}`, + headers: { + Host: lanHost, + 'content-type': 'application/json', + }, + }); + expect(res.status).toBe(200); + }); + + it.each([ + '172.15.255.255', + '172.32.0.1', + '192.168.1.256', + ])('blocks non-private or malformed LAN-like address %s', async (host) => { + const lanHost = `${host}:${port}`; + const res = await request(port, 'POST', '/api/projects', { + origin: `http://${lanHost}`, + headers: { + Host: lanHost, + 'content-type': 'application/json', + }, + }); + expect(res.status).toBe(403); + }); + + it('allows local guarded routes from a matching private LAN origin', async () => { + const lanHost = `192.168.18.16:${port}`; + const res = await request(port, 'POST', '/api/active', { + origin: `http://${lanHost}`, + headers: { + Host: lanHost, + 'content-type': 'application/json', + }, + }); + expect(res.status).toBe(200); + }); + + it('blocks private LAN origins when the request host differs', async () => { + const res = await request(port, 'POST', '/api/projects', { + origin: `http://192.168.18.16:${port}`, + headers: { + Host: `192.168.18.17:${port}`, + 'content-type': 'application/json', + }, + }); + expect(res.status).toBe(403); + }); + + it('blocks local guarded routes when the private LAN host differs', async () => { + const res = await request(port, 'POST', '/api/active', { + origin: `http://192.168.18.16:${port}`, + headers: { + Host: `192.168.18.17:${port}`, + 'content-type': 'application/json', + }, + }); + expect(res.status).toBe(403); + }); + + it('blocks local guarded routes without Origin when Host only matches a configured deployment origin', async () => { + process.env.OD_ALLOWED_ORIGINS = 'https://od.example.com'; + try { + const res = await request(port, 'POST', '/api/active', { + headers: { + Host: 'od.example.com', + 'content-type': 'application/json', + }, + }); + expect(res.status).toBe(403); + } finally { + delete process.env.OD_ALLOWED_ORIGINS; + } + }); + + it('allows local guarded routes from a matching configured deployment origin', async () => { + process.env.OD_ALLOWED_ORIGINS = 'https://od.example.com'; + try { + const res = await request(port, 'POST', '/api/active', { + origin: 'https://od.example.com', + headers: { + Host: 'od.example.com', + 'content-type': 'application/json', + }, + }); + expect(res.status).toBe(200); + } finally { + delete process.env.OD_ALLOWED_ORIGINS; + } + }); + // --- Origin: null (sandboxed iframe previews) --- it('allows Origin: null for GET raw-file preview routes', async () => { @@ -188,6 +294,18 @@ describe('daemon origin validation middleware', () => { expect(res.status).toBe(403); }); + it('allows explicitly configured deployment origins', async () => { + process.env.OD_ALLOWED_ORIGINS = `https://od.example.com,http://203.0.113.10:${port}`; + try { + const res = await request(port, 'GET', '/api/projects', { + origin: 'https://od.example.com', + }); + expect(res.status).toBe(200); + } finally { + delete process.env.OD_ALLOWED_ORIGINS; + } + }); + // --- Cross-origin rejection --- it('blocks cross-origin requests from external domains', async () => { diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 000000000..1fc754b03 --- /dev/null +++ b/deploy/.env.example @@ -0,0 +1,18 @@ +# Image published by deploy/scripts/publish-images.sh. +OPEN_DESIGN_IMAGE=docker.io/vanjayak/open-design:latest + +# Host port exposed on 127.0.0.1 by docker compose. +# Keep Compose bound to localhost; use an authenticated reverse proxy, SSH tunnel, +# or VPN before exposing Open Design remotely. +OPEN_DESIGN_PORT=7456 + +# Comma-separated browser origins allowed to call /api when deployed behind a +# domain, public IP, or reverse proxy, e.g. http://203.0.113.10:7456,https://od.example.com. +OPEN_DESIGN_ALLOWED_ORIGINS= + +# Container memory limit. The idle service has been verified around 18-22 MiB. +# Raise this for large exports, concurrent agent runs, or heavy upload workflows. +OPEN_DESIGN_MEM_LIMIT=384m + +# Node.js heap cap inside the container. +NODE_OPTIONS=--max-old-space-size=192 diff --git a/deploy/Dockerfile b/deploy/Dockerfile new file mode 100644 index 000000000..1c4bb3c9c --- /dev/null +++ b/deploy/Dockerfile @@ -0,0 +1,97 @@ +ARG NODE_IMAGE=docker.io/library/node:24-alpine +ARG RUNTIME_IMAGE=docker.io/library/node:24-alpine + +FROM ${NODE_IMAGE} AS build + +ARG HTTP_PROXY +ARG HTTPS_PROXY +ARG http_proxy +ARG https_proxy +ARG no_proxy +ARG NO_PROXY + +ENV HTTP_PROXY=${HTTP_PROXY} +ENV HTTPS_PROXY=${HTTPS_PROXY} +ENV http_proxy=${http_proxy} +ENV https_proxy=${https_proxy} +ENV no_proxy=${no_proxy} +ENV NO_PROXY=${NO_PROXY} +ENV CI=true + +RUN apk add --no-cache python3 make g++ + +WORKDIR /app +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY scripts/postinstall.mjs ./scripts/postinstall.mjs +COPY packages ./packages +COPY tools ./tools +COPY apps/daemon/package.json ./apps/daemon/package.json +COPY apps/web/package.json ./apps/web/package.json +COPY e2e/package.json ./e2e/package.json +RUN corepack enable && \ + corepack prepare pnpm@10.33.2 --activate && \ + pnpm install --frozen-lockfile + +COPY apps ./apps +RUN pnpm --filter @open-design/daemon build && \ + pnpm --filter @open-design/web build && \ + pnpm --filter @open-design/daemon deploy --legacy --prod /app/deploy/daemon && \ + pnpm store prune && \ + rm -rf \ + /root/.cache \ + /root/.local/share/pnpm/store \ + /app/deploy/daemon/node_modules/.cache \ + /app/deploy/daemon/node_modules/@types \ + /app/deploy/daemon/node_modules/.pnpm/@types+* \ + /app/deploy/daemon/node_modules/.pnpm/better-sqlite3@*/node_modules/better-sqlite3/deps \ + /app/deploy/daemon/node_modules/.pnpm/better-sqlite3@*/node_modules/better-sqlite3/src && \ + find /app/deploy/daemon/node_modules -type d \( \ + -name test -o \ + -name tests -o \ + -name "__tests__" -o \ + -name docs -o \ + -name doc -o \ + -name example -o \ + -name examples -o \ + -name ".github" \ + \) -prune -exec rm -rf '{}' + && \ + find /app/deploy/daemon/node_modules -type f \( \ + -name "*.md" -o \ + -name "*.markdown" -o \ + -name "*.d.ts" -o \ + -name "*.d.cts" -o \ + -name "*.d.mts" -o \ + -name "*.map" -o \ + -name "*.tsbuildinfo" -o \ + -name "binding.gyp" \ + \) -delete + +FROM ${RUNTIME_IMAGE} + +RUN apk add --no-cache tini && \ + addgroup -S -g 1001 open-design && \ + adduser -S -D -H -u 1001 -G open-design open-design + +WORKDIR /app +COPY --from=build --chown=open-design:open-design /app/deploy/daemon ./apps/daemon +COPY --from=build --chown=open-design:open-design /app/apps/web/out ./apps/web/out +COPY --chown=open-design:open-design skills ./skills +COPY --chown=open-design:open-design design-systems ./design-systems +COPY --chown=open-design:open-design craft ./craft +COPY --chown=open-design:open-design prompt-templates ./prompt-templates +COPY --chown=open-design:open-design assets/frames ./assets/frames +COPY --chown=open-design:open-design assets/community-pets ./assets/community-pets + +RUN mkdir -p /app/.od && \ + chown -R open-design:open-design /app + +ENV NODE_ENV=production +ENV NODE_OPTIONS=--max-old-space-size=192 +ENV OD_BIND_HOST=0.0.0.0 +ENV OD_PORT=7456 + +EXPOSE 7456 + +USER open-design +ENTRYPOINT ["/sbin/tini", "--"] +CMD ["node", "apps/daemon/dist/cli.js", "--no-open"] diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 000000000..d4dcde156 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,66 @@ +# Docker deployment + +This deployment ships Open Design as a single Alpine-based runtime image. The +daemon serves both the API and the built Next.js static export, so there is no +separate nginx container. + +## Local compose + +```bash +cd deploy +OPEN_DESIGN_IMAGE=docker.io/vanjayak/open-design:latest docker compose pull +OPEN_DESIGN_IMAGE=docker.io/vanjayak/open-design:latest docker compose up -d --no-build +``` + +Defaults: + +- Host port: `127.0.0.1:7456` (`OPEN_DESIGN_PORT=8080` to publish on `127.0.0.1:8080`) +- Runtime data volume: `open_design_data` mounted at `/app/.od` +- Node heap cap: `--max-old-space-size=192` +- Compose memory cap: `384m` (`OPEN_DESIGN_MEM_LIMIT=256m` to override) + +Do not publish the daemon directly on a public or shared LAN interface. The API is +unauthenticated for non-browser clients, so remote deployments should keep Compose +bound to localhost and put an authenticated reverse proxy, SSH tunnel, or VPN in +front of it. + +When exposing the service through an authenticated public IP, domain, or reverse +proxy, set `OPEN_DESIGN_ALLOWED_ORIGINS` to the browser origins that should be +allowed to call `/api`: + +```bash +OPEN_DESIGN_ALLOWED_ORIGINS=https://od.example.com,http://203.0.113.10:7456 docker compose up -d --no-build +``` + +Pin a specific published image with a digest instead of the mutable `latest` tag: + +```bash +OPEN_DESIGN_IMAGE=docker.io/vanjayak/open-design@sha256: docker compose up -d --no-build +``` +The image intentionally does not bundle Claude/Codex/Gemini CLI binaries. Keep +those outside the image, or build a separate private runtime layer if a server +deployment needs local code-agent CLIs installed in the container. + +## Publish to Docker Hub + +```bash +deploy/scripts/publish-images.sh --image_tag latest +``` + +Useful overrides: + +```bash +IMAGE_NAMESPACE=your-dockerhub-user deploy/scripts/publish-images.sh --arch arm64 +deploy/scripts/publish-images.sh --image docker.io/your-user/open-design:0.1.0 +``` + +The script defaults to: + +- `docker.io/vanjayak/open-design:` +- `linux/amd64,linux/arm64` +- `skopeo` push strategy with Docker credentials read from `~/.docker/config.json` +- preloading base images through `skopeo` to reduce Docker Hub pull flakiness + +If `127.0.0.1:7890` is available and no proxy is already set, the script uses it +for registry access and passes `host.docker.internal:7890` into Docker builds. The +host-gateway alias is only added for builds that need this local proxy mapping. diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 000000000..26fcd4178 --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,43 @@ +name: open-design + +services: + open-design: + container_name: open-design + image: ${OPEN_DESIGN_IMAGE:-docker.io/vanjayak/open-design:latest} + build: + context: .. + dockerfile: deploy/Dockerfile + restart: always + environment: + NODE_ENV: production + NODE_OPTIONS: ${NODE_OPTIONS:---max-old-space-size=192} + OD_BIND_HOST: 0.0.0.0 + OD_ALLOWED_ORIGINS: ${OPEN_DESIGN_ALLOWED_ORIGINS:-} + OD_PORT: 7456 + OD_WEB_PORT: ${OPEN_DESIGN_PORT:-7456} + ports: + - "127.0.0.1:${OPEN_DESIGN_PORT:-7456}:7456" + volumes: + - open_design_data:/app/.od + read_only: true + tmpfs: + - /tmp + security_opt: + - no-new-privileges:true + mem_limit: ${OPEN_DESIGN_MEM_LIMIT:-384m} + pids_limit: 256 + healthcheck: + test: + [ + "CMD", + "node", + "-e", + "fetch('http://127.0.0.1:7456/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + ] + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s + +volumes: + open_design_data: diff --git a/deploy/scripts/publish-images.sh b/deploy/scripts/publish-images.sh new file mode 100755 index 000000000..b1b6df4e6 --- /dev/null +++ b/deploy/scripts/publish-images.sh @@ -0,0 +1,658 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +PLATFORMS="${PLATFORMS:-linux/amd64,linux/arm64}" +IMAGE_TAG="${IMAGE_TAG:-latest}" +REGISTRY="${REGISTRY:-docker.io}" +IMAGE_NAMESPACE="${IMAGE_NAMESPACE:-vanjayak}" +IMAGE_REPOSITORY="${IMAGE_REPOSITORY:-open-design}" +NODE_BASE_IMAGE="${NODE_BASE_IMAGE:-docker.io/library/node:24-alpine}" +RUNTIME_BASE_IMAGE="${RUNTIME_BASE_IMAGE:-docker.io/library/node:24-alpine}" +PUSH_STRATEGY="${PUSH_STRATEGY:-skopeo}" +PRELOAD_BASE_IMAGES="${PRELOAD_BASE_IMAGES:-1}" +DRY_RUN="${DRY_RUN:-0}" +INSPECT_AFTER_PUSH="${INSPECT_AFTER_PUSH:-1}" +SKOPEO_AUTHFILE="${SKOPEO_AUTHFILE:-$HOME/.docker/config.json}" +EFFECTIVE_SKOPEO_AUTHFILE="$SKOPEO_AUTHFILE" +TEMP_SKOPEO_AUTHFILE="" +TEMP_AUTH_ROOT="" +TEMP_ROOT="" +IMAGE="${IMAGE:-}" + +HTTP_PROXY="${HTTP_PROXY:-${http_proxy:-}}" +HTTPS_PROXY="${HTTPS_PROXY:-${https_proxy:-${HTTP_PROXY:-}}}" +NO_PROXY="${NO_PROXY:-${no_proxy:-}}" +BUILD_HTTP_PROXY="" +BUILD_HTTPS_PROXY="" +BUILD_NO_PROXY="" + +cleanup_temp_artifacts() { + if [[ -n "$TEMP_SKOPEO_AUTHFILE" && -f "$TEMP_SKOPEO_AUTHFILE" ]]; then + rm -f "$TEMP_SKOPEO_AUTHFILE" + fi + + if [[ -n "$TEMP_AUTH_ROOT" && -d "$TEMP_AUTH_ROOT" ]]; then + case "$TEMP_AUTH_ROOT" in + /dev/shm/publish-auth.*|"${TMPDIR:-/tmp}"/publish-auth.*|/tmp/publish-auth.*) + rm -rf "$TEMP_AUTH_ROOT" + ;; + esac + fi + + if [[ -n "$TEMP_ROOT" && -d "$TEMP_ROOT" ]]; then + case "$TEMP_ROOT" in + "${TMPDIR:-/tmp}"/publish-images.*|/tmp/publish-images.*) + rm -rf "$TEMP_ROOT" + ;; + esac + fi +} + +ensure_temp_root() { + if [[ -n "$TEMP_ROOT" ]]; then + return 0 + fi + + TEMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/publish-images.XXXXXX")" + chmod 700 "$TEMP_ROOT" +} + +ensure_temp_auth_root() { + if [[ -n "$TEMP_AUTH_ROOT" ]]; then + return 0 + fi + + if [[ -d /dev/shm && -w /dev/shm ]]; then + TEMP_AUTH_ROOT="$(mktemp -d /dev/shm/publish-auth.XXXXXX)" + else + TEMP_AUTH_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/publish-auth.XXXXXX")" + fi + chmod 700 "$TEMP_AUTH_ROOT" +} + +make_temp_dir() { + local target_var="$1" + local template="$2" + local temp_dir + + ensure_temp_root + temp_dir="$(mktemp -d "$TEMP_ROOT/${template}.XXXXXX")" + chmod 700 "$temp_dir" + printf -v "$target_var" '%s' "$temp_dir" +} + +make_temp_auth_file() { + local target_var="$1" + local template="$2" + local temp_file + + ensure_temp_auth_root + temp_file="$(mktemp "$TEMP_AUTH_ROOT/${template}.XXXXXX")" + chmod 600 "$temp_file" + printf -v "$target_var" '%s' "$temp_file" +} + +trap cleanup_temp_artifacts EXIT + +usage() { + cat <<'EOF' +Usage: publish-images.sh [options] + +Options: + --platforms default: linux/amd64,linux/arm64 + --arch publish a single platform as - + --image_tag default: latest + --registry default: docker.io + --image_namespace default: vanjayak + --image_repository default: open-design + --image override full image ref + --node_base_image default: docker.io/library/node:24-alpine + --runtime_base_image default: docker.io/library/node:24-alpine + --push_strategy default: skopeo + --preload_base_images <0|1> default: 1 + --skopeo_authfile default: ~/.docker/config.json + --inspect_after_push <0|1> default: 1 + --dry_run + -h, --help + +Examples: + deploy/scripts/publish-images.sh --arch arm64 + deploy/scripts/publish-images.sh --image_tag 0.1.0 +EOF +} + +log() { + printf '[publish-images] %s\n' "$*" >&2 +} + +die() { + printf '[publish-images] ERROR: %s\n' "$*" >&2 + exit 1 +} + +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +detect_proxy_if_available() { + if [[ -n "$HTTP_PROXY" || -n "$HTTPS_PROXY" ]]; then + return 0 + fi + + if command_exists nc && nc -vz -w 2 127.0.0.1 7890 >/dev/null 2>&1; then + HTTP_PROXY="http://127.0.0.1:7890" + HTTPS_PROXY="http://127.0.0.1:7890" + NO_PROXY="${NO_PROXY:-kugou.net,tmeoa.com}" + export http_proxy="$HTTP_PROXY" HTTP_PROXY + export https_proxy="$HTTPS_PROXY" HTTPS_PROXY + export no_proxy="$NO_PROXY" NO_PROXY + log "using local proxy $HTTP_PROXY for registry and build network access" + fi +} + +normalize_proxy_for_build() { + local proxy_url="${1:-}" + local scheme + local host + local port="" + + if [[ -z "$proxy_url" ]]; then + printf '%s' "" + return 0 + fi + + if [[ ! "$proxy_url" =~ ^(https?)://([a-zA-Z0-9._-]+)(:([0-9]+))?$ ]]; then + die "proxy URL must be http(s)://host[:port] with no path, query, or credentials: $proxy_url" + fi + + scheme="${BASH_REMATCH[1]}" + host="${BASH_REMATCH[2]}" + port="${BASH_REMATCH[3]:-}" + + if [[ -n "$port" ]]; then + local port_number="${port#:}" + if (( 10#$port_number < 1 || 10#$port_number > 65535 )); then + die "proxy URL port out of range: $proxy_url" + fi + fi + + case "$host" in + localhost|127.0.0.1) + host="host.docker.internal" + ;; + esac + + printf '%s://%s%s' "$scheme" "$host" "$port" +} + +build_proxy_requires_host_gateway() { + [[ "$BUILD_HTTP_PROXY" == *'://host.docker.internal'* || "$BUILD_HTTPS_PROXY" == *'://host.docker.internal'* ]] +} + +should_use_preloaded_base_images() { + [[ "$PRELOAD_BASE_IMAGES" == "1" && "$PUSH_STRATEGY" == "skopeo" ]] +} + +validate_image_name_component() { + local label="$1" + local value="$2" + + [[ -n "$value" ]] || die "$label must not be empty" + [[ "$value" =~ ^[a-z0-9]+([._-][a-z0-9]+)*$ ]] || die "$label must use lowercase Docker name components only: $value" +} + +validate_image_ref_parts() { + validate_image_name_component "image namespace" "$IMAGE_NAMESPACE" + validate_image_name_component "image repository" "$IMAGE_REPOSITORY" +} + +normalize_arch_to_platform() { + case "$1" in + amd64|x86_64) + printf 'linux/amd64' + ;; + arm64|aarch64) + printf 'linux/arm64' + ;; + *) + return 1 + ;; + esac +} + +platform_to_arch() { + case "$1" in + linux/amd64) + printf 'amd64' + ;; + linux/arm64) + printf 'arm64' + ;; + *) + return 1 + ;; + esac +} + +image_with_arch_suffix() { + local image="$1" + local platform="$2" + local arch + local repo + local tag + + arch="$(platform_to_arch "$platform")" || die "unsupported platform '$platform'" + repo="${image%:*}" + tag="${image##*:}" + printf '%s:%s-%s' "$repo" "$tag" "$arch" +} + +image_for_single_arch() { + local image="$1" + local platform="$2" + local arch + local repo + local tag + + arch="$(platform_to_arch "$platform")" || die "unsupported platform '$platform'" + repo="${image%:*}" + tag="${image##*:}" + case "$tag" in + *-amd64|*-arm64) + printf '%s' "$image" + ;; + *) + printf '%s:%s-%s' "$repo" "$tag" "$arch" + ;; + esac +} + +node_local_base_image() { + local platform="$1" + local arch + arch="$(platform_to_arch "$platform")" || die "unsupported platform '$platform'" + printf 'open-design-base-node:24-alpine-%s' "$arch" +} + +runtime_local_base_image() { + local platform="$1" + local arch + arch="$(platform_to_arch "$platform")" || die "unsupported platform '$platform'" + printf 'open-design-runtime-base:24-alpine-%s' "$arch" +} + +node_image_for_platform() { + local platform="$1" + if should_use_preloaded_base_images; then + node_local_base_image "$platform" + else + printf '%s' "$NODE_BASE_IMAGE" + fi +} + +runtime_image_for_platform() { + local platform="$1" + if should_use_preloaded_base_images; then + runtime_local_base_image "$platform" + else + printf '%s' "$RUNTIME_BASE_IMAGE" + fi +} + +docker_image_exists() { + docker image inspect "$1" >/dev/null 2>&1 +} + +registry_auth_key() { + case "$REGISTRY" in + docker.io) + printf 'https://index.docker.io/v1/' + ;; + *) + printf '%s' "$REGISTRY" + ;; + esac +} + +ensure_skopeo() { + command_exists skopeo || die "'skopeo' is required when PUSH_STRATEGY=skopeo" + [[ -f "$SKOPEO_AUTHFILE" ]] || die "skopeo authfile not found: $SKOPEO_AUTHFILE" + EFFECTIVE_SKOPEO_AUTHFILE="$SKOPEO_AUTHFILE" + + local creds_store="" + if command_exists jq; then + creds_store="$(jq -r '.credsStore // empty' "$SKOPEO_AUTHFILE" 2>/dev/null || true)" + fi + + if [[ -n "$creds_store" ]]; then + local helper_bin="docker-credential-$creds_store" + local registry_key + local creds_json + local username + local secret + local auth + + command_exists jq || die "'jq' is required to translate Docker credential helpers into a skopeo authfile" + command_exists "$helper_bin" || die "docker credential helper not found: $helper_bin" + + registry_key="$(registry_auth_key)" + creds_json="$(printf '%s' "$registry_key" | "$helper_bin" get)" + username="$(printf '%s' "$creds_json" | jq -r '.Username // empty')" + secret="$(printf '%s' "$creds_json" | jq -r '.Secret // empty')" + + [[ -n "$username" ]] || die "failed to resolve Docker registry username from $helper_bin" + [[ -n "$secret" ]] || die "failed to resolve Docker registry secret from $helper_bin" + + auth="$(printf '%s:%s' "$username" "$secret" | base64 | tr -d '\n')" + make_temp_auth_file TEMP_SKOPEO_AUTHFILE skopeo-auth + jq -n --arg registry_key "$registry_key" --arg auth "$auth" \ + '{auths: {($registry_key): {auth: $auth}}}' >"$TEMP_SKOPEO_AUTHFILE" + EFFECTIVE_SKOPEO_AUTHFILE="$TEMP_SKOPEO_AUTHFILE" + fi +} + +skopeo_inspect_raw() { + local image="$1" + skopeo inspect --authfile "$EFFECTIVE_SKOPEO_AUTHFILE" --raw "docker://${image}" >/dev/null +} + +skopeo_copy_to_registry() { + local archive_path="$1" + local image="$2" + skopeo copy --dest-authfile "$EFFECTIVE_SKOPEO_AUTHFILE" "docker-archive:$archive_path" "docker://$image" +} + +preload_base_image() { + local platform="$1" + local source_image="$2" + local destination_image="$3" + local arch + local archive_dir + local archive_path + + arch="$(platform_to_arch "$platform")" || die "unsupported platform '$platform'" + make_temp_dir archive_dir publish-base + archive_path="$archive_dir/image.tar" + + if docker_image_exists "$destination_image"; then + docker image rm "$destination_image" >/dev/null 2>&1 || true + fi + + if ! skopeo copy \ + --override-os linux \ + --override-arch "$arch" \ + "docker://$source_image" \ + "docker-archive:$archive_path:$destination_image"; then + die "failed to preload base image '$source_image' for $platform" + fi + + if ! docker load -i "$archive_path" >/dev/null; then + die "failed to docker load preloaded base image '$destination_image'" + fi +} + +ensure_base_images_preloaded() { + local platform="$1" + + should_use_preloaded_base_images || return 0 + [[ "$DRY_RUN" == "1" ]] && return 0 + + preload_base_image "$platform" "$NODE_BASE_IMAGE" "$(node_local_base_image "$platform")" + preload_base_image "$platform" "$RUNTIME_BASE_IMAGE" "$(runtime_local_base_image "$platform")" +} + +inspect_remote_image() { + local image="$1" + [[ "$INSPECT_AFTER_PUSH" == "1" ]] || return 0 + + if [[ "$PUSH_STRATEGY" == "skopeo" ]]; then + skopeo_inspect_raw "$image" + else + docker buildx imagetools inspect "$image" >/dev/null + fi +} + +push_local_image_with_skopeo() { + local image="$1" + local archive_dir + local archive_path + + make_temp_dir archive_dir publish-image + archive_path="$archive_dir/image.tar" + + docker save -o "$archive_path" "$image" + skopeo_copy_to_registry "$archive_path" "$image" +} + +print_build_cmd() { + local image="$1" + local platform="$2" + shift 2 + local args=("$@") + local host_arg="" + + if build_proxy_requires_host_gateway; then + host_arg=' --add-host host.docker.internal=host-gateway' + fi + + if [[ "$PUSH_STRATEGY" == "skopeo" ]]; then + printf 'docker buildx build --platform %s%s -t %s %s --load %s\n' \ + "$platform" "$host_arg" "$image" "${args[*]}" "$ROOT_DIR" + printf 'docker save -o %s\n' "$image" + printf 'skopeo copy --dest-authfile %s docker-archive: docker://%s\n' \ + "$EFFECTIVE_SKOPEO_AUTHFILE" "$image" + return 0 + fi + + printf 'docker buildx build --platform %s%s -t %s %s --push %s\n' \ + "$platform" "$host_arg" "$image" "${args[*]}" "$ROOT_DIR" +} + +run_build() { + local image="$1" + local platform="$2" + shift 2 + local args=("$@") + local host_args=() + + if build_proxy_requires_host_gateway; then + host_args=(--add-host "host.docker.internal=host-gateway") + fi + + if [[ "$DRY_RUN" == "1" ]]; then + print_build_cmd "$image" "$platform" "${args[@]}" + return 0 + fi + + if [[ "$PUSH_STRATEGY" == "skopeo" ]]; then + docker buildx build \ + --platform "$platform" \ + "${host_args[@]}" \ + -t "$image" \ + "${args[@]}" \ + --load \ + "$ROOT_DIR" + push_local_image_with_skopeo "$image" + else + docker buildx build \ + --platform "$platform" \ + "${host_args[@]}" \ + -t "$image" \ + "${args[@]}" \ + --push \ + "$ROOT_DIR" + fi + + inspect_remote_image "$image" +} + +merge_manifest_for_image() { + local final_image="$1" + shift + local source_images=("$@") + + if [[ "$DRY_RUN" == "1" ]]; then + printf 'docker buildx imagetools create --tag %s %s\n' "$final_image" "${source_images[*]}" + if [[ "$PUSH_STRATEGY" == "skopeo" ]]; then + printf 'skopeo inspect --authfile %s --raw docker://%s\n' "$EFFECTIVE_SKOPEO_AUTHFILE" "$final_image" + else + printf 'docker buildx imagetools inspect %s\n' "$final_image" + fi + return 0 + fi + + docker buildx imagetools create --tag "$final_image" "${source_images[@]}" + inspect_remote_image "$final_image" +} + +refresh_image_ref() { + if [[ -z "$IMAGE" ]]; then + validate_image_ref_parts + IMAGE="${REGISTRY}/${IMAGE_NAMESPACE}/${IMAGE_REPOSITORY}:${IMAGE_TAG}" + fi +} + +SINGLE_ARCH="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --platforms) + PLATFORMS="$2" + shift 2 + ;; + --arch) + SINGLE_ARCH="$2" + shift 2 + ;; + --image_tag) + IMAGE_TAG="$2" + IMAGE="" + shift 2 + ;; + --registry) + REGISTRY="$2" + IMAGE="" + shift 2 + ;; + --image_namespace) + IMAGE_NAMESPACE="$2" + IMAGE="" + shift 2 + ;; + --image_repository) + IMAGE_REPOSITORY="$2" + IMAGE="" + shift 2 + ;; + --image) + IMAGE="$2" + shift 2 + ;; + --node_base_image) + NODE_BASE_IMAGE="$2" + shift 2 + ;; + --runtime_base_image) + RUNTIME_BASE_IMAGE="$2" + shift 2 + ;; + --push_strategy) + PUSH_STRATEGY="$2" + shift 2 + ;; + --preload_base_images) + PRELOAD_BASE_IMAGES="$2" + shift 2 + ;; + --skopeo_authfile) + SKOPEO_AUTHFILE="$2" + EFFECTIVE_SKOPEO_AUTHFILE="$SKOPEO_AUTHFILE" + shift 2 + ;; + --inspect_after_push) + INSPECT_AFTER_PUSH="$2" + shift 2 + ;; + --dry_run) + DRY_RUN=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + die "unknown option: $1" + ;; + esac +done + +if [[ -n "$SINGLE_ARCH" ]]; then + PLATFORMS="$(normalize_arch_to_platform "$SINGLE_ARCH")" || die "unsupported arch '$SINGLE_ARCH' (use amd64 or arm64)" +fi + +case "$PUSH_STRATEGY" in + skopeo|buildx) + ;; + *) + die "unsupported push strategy: $PUSH_STRATEGY" + ;; +esac + +refresh_image_ref +detect_proxy_if_available + +BUILD_HTTP_PROXY="$(normalize_proxy_for_build "$HTTP_PROXY")" +BUILD_HTTPS_PROXY="$(normalize_proxy_for_build "$HTTPS_PROXY")" +BUILD_NO_PROXY="${NO_PROXY}" + +build_args=( + --build-arg "HTTP_PROXY=${BUILD_HTTP_PROXY}" + --build-arg "HTTPS_PROXY=${BUILD_HTTPS_PROXY}" + --build-arg "http_proxy=${BUILD_HTTP_PROXY}" + --build-arg "https_proxy=${BUILD_HTTPS_PROXY}" + --build-arg "no_proxy=${BUILD_NO_PROXY}" + --build-arg "NO_PROXY=${BUILD_NO_PROXY}" +) + +if [[ "$DRY_RUN" != "1" ]]; then + docker buildx inspect --bootstrap >/dev/null + if [[ "$PUSH_STRATEGY" == "skopeo" ]]; then + ensure_skopeo + fi +fi + +IFS=',' read -r -a platform_list <<<"$PLATFORMS" +platform_total="${#platform_list[@]}" +image_sources=() + +for platform in "${platform_list[@]}"; do + ensure_base_images_preloaded "$platform" + + image_for_platform="$IMAGE" + if [[ "$platform_total" -gt 1 ]]; then + image_for_platform="$(image_with_arch_suffix "$IMAGE" "$platform")" + elif [[ -n "$SINGLE_ARCH" ]]; then + image_for_platform="$(image_for_single_arch "$IMAGE" "$platform")" + fi + + run_build "$image_for_platform" "$platform" \ + -f "$ROOT_DIR/deploy/Dockerfile" \ + "${build_args[@]}" \ + --build-arg "NODE_IMAGE=$(node_image_for_platform "$platform")" \ + --build-arg "RUNTIME_IMAGE=$(runtime_image_for_platform "$platform")" + + image_sources+=("$image_for_platform") +done + +if [[ "$platform_total" -gt 1 ]]; then + merge_manifest_for_image "$IMAGE" "${image_sources[@]}" +fi + +published_image="$IMAGE" +if [[ "$platform_total" -eq 1 && -n "$SINGLE_ARCH" ]]; then + published_image="${image_sources[0]}" +fi + +log "image: ${published_image}" diff --git a/deploy/scripts/verify-image-manifest.sh b/deploy/scripts/verify-image-manifest.sh new file mode 100755 index 000000000..146e349b3 --- /dev/null +++ b/deploy/scripts/verify-image-manifest.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -lt 1 || $# -gt 2 ]]; then + echo "usage: $0 [expected-platforms]" >&2 + exit 64 +fi + +IMAGE_REF="$1" +EXPECTED_PLATFORMS="${2:-linux/amd64,linux/arm64}" + +inspect_output="$(skopeo inspect --raw "docker://${IMAGE_REF}")" +printf '%s\n' "$inspect_output" + +missing=0 +IFS=',' read -r -a expected <<<"$EXPECTED_PLATFORMS" +for platform in "${expected[@]}"; do + os="${platform%/*}" + arch="${platform#*/}" + if ! jq -e --arg os "$os" --arg arch "$arch" ' + (.mediaType == "application/vnd.docker.distribution.manifest.list.v2+json" or + .mediaType == "application/vnd.oci.image.index.v1+json") and + any(.manifests[]?; .platform.os == $os and .platform.architecture == $arch) + ' >/dev/null <<<"$inspect_output"; then + echo "missing platform in manifest: $platform" >&2 + missing=1 + fi +done + +exit "$missing" diff --git a/deploy/scripts/verify-image.sh b/deploy/scripts/verify-image.sh new file mode 100755 index 000000000..2f493c17f --- /dev/null +++ b/deploy/scripts/verify-image.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +set -euo pipefail + +IMAGE_REF="${1:-}" +ARCHIVE_CONTAINER_ID="" +CONTAINER_ID="" + +cleanup() { + if [[ -n "$ARCHIVE_CONTAINER_ID" ]]; then + docker rm -f "$ARCHIVE_CONTAINER_ID" >/dev/null 2>&1 || true + fi + if [[ -n "$CONTAINER_ID" ]]; then + docker rm -f "$CONTAINER_ID" >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT + +if [[ -z "$IMAGE_REF" ]]; then + echo "usage: $0 " >&2 + exit 64 +fi + +ARCHIVE_CONTAINER_ID="$(docker create "$IMAGE_REF")" +archive_listing="$(docker export "$ARCHIVE_CONTAINER_ID" | tar -tf -)" +docker rm "$ARCHIVE_CONTAINER_ID" >/dev/null +ARCHIVE_CONTAINER_ID="" + +for required_path in \ + "app/apps/daemon/dist/cli.js" \ + "app/apps/web/out/index.html" \ + "app/apps/daemon/node_modules/express" \ + "app/apps/daemon/node_modules/better-sqlite3" \ + "app/skills" \ + "app/design-systems" \ + "app/assets/frames" +do + if ! grep -Eq "^${required_path}(/|$)" <<<"$archive_listing"; then + echo "missing expected runtime path: $required_path" >&2 + exit 1 + fi +done + +for forbidden_path in \ + "app/apps/web/src" \ + "app/docs" \ + "app/story" \ + "app/apps/daemon/node_modules/typescript" \ + "app/apps/daemon/node_modules/vite" \ + "app/apps/daemon/node_modules/@types" \ + "app/apps/daemon/node_modules/.pnpm/@types\\+" \ + "app/apps/daemon/node_modules/.pnpm/better-sqlite3@.*/node_modules/better-sqlite3/deps" \ + "app/apps/daemon/node_modules/.pnpm/better-sqlite3@.*/node_modules/better-sqlite3/src" \ + "app/apps/daemon/node_modules/.cache" +do + if grep -Eq "^${forbidden_path}(/|$)" <<<"$archive_listing"; then + echo "unexpected build-only content found in runtime image: $forbidden_path" >&2 + exit 1 + fi +done + +runtime_tools="$(docker run --rm --entrypoint sh "$IMAGE_REF" -lc 'for tool in python3 g++ make pnpm; do if command -v "$tool" >/dev/null 2>&1; then echo "$tool"; fi; done')" +if [[ -n "$runtime_tools" ]]; then + echo "unexpected build tools found in runtime image:" >&2 + echo "$runtime_tools" >&2 + exit 1 +fi + +node_major="$(docker run --rm --entrypoint node "$IMAGE_REF" -p 'process.versions.node.split(`.`)[0]')" +if [[ "$node_major" != "24" ]]; then + echo "unexpected runtime node major: $node_major" >&2 + exit 1 +fi + +CONTAINER_ID="$(docker run -d -p 127.0.0.1::7456 "$IMAGE_REF")" +runtime_port="$(docker port "$CONTAINER_ID" 7456/tcp | awk -F: '{print $2}')" +health_code="" + +for _ in $(seq 1 20); do + health_code="$(curl -o /dev/null -s -w '%{http_code}' "http://127.0.0.1:${runtime_port}/api/health" || true)" + if [[ "$health_code" == "200" ]]; then + break + fi + sleep 1 +done + +if [[ "$health_code" != "200" ]]; then + echo "unexpected health status: $health_code" >&2 + docker logs "$CONTAINER_ID" >&2 || true + exit 1 +fi + +rss_bytes="$(docker stats --no-stream --format '{{.MemUsage}}' "$CONTAINER_ID" | awk '{print $1}')" +echo "open-design runtime image verified: $IMAGE_REF" +echo "container memory sample: ${rss_bytes}"