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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>
Co-authored-by: mrcfps <mrc@powerformer.com>
Co-authored-by: lefarcen <935902669@qq.com>
This commit is contained in:
VanJay 2026-05-08 11:51:51 +08:00 committed by GitHub
parent 8930b9650c
commit 369d136d19
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 1373 additions and 93 deletions

20
.dockerignore Normal file
View file

@ -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

View file

@ -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);
}

View file

@ -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));
}

View file

@ -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: {

View file

@ -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();

View file

@ -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 () => {

18
deploy/.env.example Normal file
View file

@ -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

97
deploy/Dockerfile Normal file
View file

@ -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"]

66
deploy/README.md Normal file
View file

@ -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:<digest> 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:<tag>`
- `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.

43
deploy/docker-compose.yml Normal file
View file

@ -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:

658
deploy/scripts/publish-images.sh Executable file
View file

@ -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 <list> default: linux/amd64,linux/arm64
--arch <amd64|arm64> publish a single platform as <tag>-<arch>
--image_tag <tag> default: latest
--registry <registry> default: docker.io
--image_namespace <namespace> default: vanjayak
--image_repository <name> default: open-design
--image <image-ref> override full image ref
--node_base_image <image-ref> default: docker.io/library/node:24-alpine
--runtime_base_image <image-ref> default: docker.io/library/node:24-alpine
--push_strategy <skopeo|buildx> default: skopeo
--preload_base_images <0|1> default: 1
--skopeo_authfile <path> 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 <archive> %s\n' "$image"
printf 'skopeo copy --dest-authfile %s docker-archive:<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}"

View file

@ -0,0 +1,30 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -lt 1 || $# -gt 2 ]]; then
echo "usage: $0 <image-ref> [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"

94
deploy/scripts/verify-image.sh Executable file
View file

@ -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 <image-ref>" >&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}"