mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
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:
parent
8930b9650c
commit
369d136d19
13 changed files with 1373 additions and 93 deletions
20
.dockerignore
Normal file
20
.dockerignore
Normal 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
|
||||
167
apps/daemon/src/origin-validation.ts
Normal file
167
apps/daemon/src/origin-validation.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
18
deploy/.env.example
Normal 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
97
deploy/Dockerfile
Normal 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
66
deploy/README.md
Normal 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
43
deploy/docker-compose.yml
Normal 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
658
deploy/scripts/publish-images.sh
Executable 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}"
|
||||
30
deploy/scripts/verify-image-manifest.sh
Executable file
30
deploy/scripts/verify-image-manifest.sh
Executable 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
94
deploy/scripts/verify-image.sh
Executable 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}"
|
||||
Loading…
Reference in a new issue