feat(dbal): run real C++ CLI binary from Query Console

- Build metabuilder-cli C++ binary into DBAL frontend Docker image
  via multi-stage build (conan-deps → cli-builder → nextjs → ubuntu runner)
- New /api/cli route executes the real C++ binary via child_process
- CLI mode runs actual metabuilder-cli commands (dbal, auth, user, tenant, package)
- Admin token forwarded via DBAL_ADMIN_TOKEN env var
- Ubuntu 24.04 runtime for glibc 2.38 compatibility with Conan builds
- Added standalone CLI Dockerfile at frontends/cli/Dockerfile

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-18 23:20:28 +00:00
parent e51c7be9ba
commit 23d495dec9
5 changed files with 212 additions and 30 deletions

View File

@@ -749,8 +749,8 @@ services:
# DBAL Frontend - Daemon overview + query console
dbal-frontend:
build:
context: ../frontends/dbal
dockerfile: Dockerfile
context: ..
dockerfile: frontends/dbal/Dockerfile
args:
DBAL_DAEMON_URL: http://dbal:8080
container_name: metabuilder-dbal-frontend

31
frontends/cli/Dockerfile Normal file
View File

@@ -0,0 +1,31 @@
# Build the metabuilder-cli C++ binary
# Context: monorepo root (..)
# Requires: metabuilder/base-conan-deps:latest base image
ARG BASE_REGISTRY=metabuilder
FROM ${BASE_REGISTRY}/base-conan-deps:latest AS builder
# Copy CLI source
COPY frontends/cli/ /app/cli/
# Install Conan deps and build
WORKDIR /app/cli
RUN conan install . \
--output-folder=build \
--build=missing \
-s build_type=Release \
-c tools.system.package_manager:mode=install \
&& cmake -S . -B build -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_TOOLCHAIN_FILE=build/conan_toolchain.cmake \
&& cmake --build build --config Release
# Minimal runtime image
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
libssl3 ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/cli/build/metabuilder-cli /usr/local/bin/metabuilder-cli
ENTRYPOINT ["metabuilder-cli"]

View File

@@ -1,33 +1,65 @@
FROM node:24-alpine AS base
# Stage 1: Build the C++ CLI binary
ARG BASE_REGISTRY=metabuilder
FROM ${BASE_REGISTRY}/base-conan-deps:latest AS cli-builder
FROM base AS deps
COPY frontends/cli/ /app/cli/
WORKDIR /app/cli
RUN conan install . \
--output-folder=build \
--build=missing \
-s build_type=Release \
-c tools.system.package_manager:mode=install \
&& cmake -S . -B build -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_TOOLCHAIN_FILE=build/conan_toolchain.cmake \
&& cmake --build build --config Release
# Stage 2: Build the Next.js frontend
FROM node:24-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json ./
COPY frontends/dbal/package.json ./
RUN npm install --legacy-peer-deps
FROM base AS builder
FROM node:24-alpine AS nextjs-builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
COPY frontends/dbal/ .
ARG DBAL_DAEMON_URL=http://dbal:8080
ENV DBAL_DAEMON_URL=$DBAL_DAEMON_URL
RUN mkdir -p public && npm run build
FROM base AS runner
# Stage 3: Runtime — Ubuntu 24.04 (glibc 2.38 matches conan-deps build)
FROM node:24-bookworm-slim AS node-bin
FROM ubuntu:24.04 AS runner
WORKDIR /app
# Copy Node.js from official image
COPY --from=node-bin /usr/local/bin/node /usr/local/bin/node
COPY --from=node-bin /usr/local/lib/node_modules /usr/local/lib/node_modules
RUN ln -sf /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates wget libssl3 \
&& rm -rf /var/lib/apt/lists/*
# Copy CLI binary
COPY --from=cli-builder /app/cli/build/metabuilder-cli /usr/local/bin/metabuilder-cli
# Copy Next.js standalone output
COPY --from=nextjs-builder /app/public ./public
RUN mkdir .next
COPY --from=nextjs-builder /app/.next/standalone ./
COPY --from=nextjs-builder /app/.next/static ./.next/static
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
RUN mkdir .next && chown nextjs:nodejs .next
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
ENV METABUILDER_BASE_URL=http://dbal:8080
EXPOSE 3000
ENV PORT=3000 HOSTNAME="0.0.0.0"
HEALTHCHECK --interval=15s --timeout=5s --start-period=30s --retries=3 \
CMD wget --quiet --tries=1 --spider http://127.0.0.1:3000/dbal || exit 1

View File

@@ -0,0 +1,92 @@
import { NextRequest, NextResponse } from 'next/server'
import { execFile } from 'child_process'
import { promisify } from 'util'
const execFileAsync = promisify(execFile)
const CLI_PATH = '/usr/local/bin/metabuilder-cli'
const DBAL_URL = process.env.METABUILDER_BASE_URL ?? process.env.DBAL_DAEMON_URL ?? 'http://dbal:8080'
const MAX_TIMEOUT = 15000
export async function POST(request: NextRequest) {
const { command, token } = await request.json()
if (!command || typeof command !== 'string') {
return NextResponse.json({ error: 'Missing command' }, { status: 400 })
}
// Parse the command string into args
// "dbal list Snippet" → ["dbal", "list", "Snippet"]
const args = command.trim().split(/\s+/)
// Remove leading "dbal" or "metabuilder-cli" if present — it's the binary itself
if (args[0] === 'dbal' || args[0] === 'metabuilder-cli') {
args.shift()
}
// Prepend "dbal" subcommand if the first arg is a known DBAL command
const dbalCommands = ['ping', 'list', 'read', 'create', 'update', 'delete', 'execute', 'rest', 'schema']
const authCommands = ['session', 'login']
const userCommands = ['list', 'get']
// Route to the right subcommand
let finalArgs: string[]
if (dbalCommands.includes(args[0])) {
finalArgs = ['dbal', ...args]
} else if (args[0] === 'auth') {
finalArgs = args
} else if (args[0] === 'user' || args[0] === 'tenant') {
finalArgs = args
} else if (args[0] === 'package') {
finalArgs = args
} else {
// Pass through as-is
finalArgs = args
}
try {
const env = {
...process.env,
METABUILDER_BASE_URL: DBAL_URL,
...(token ? { DBAL_ADMIN_TOKEN: token } : {}),
}
const { stdout, stderr } = await execFileAsync(CLI_PATH, finalArgs, {
timeout: MAX_TIMEOUT,
maxBuffer: 1024 * 1024,
env: env as NodeJS.ProcessEnv,
})
// Try to parse stdout as JSON
let data: unknown
try {
data = JSON.parse(stdout)
} catch {
data = stdout || stderr || '(no output)'
}
return NextResponse.json({
status: 200,
statusText: 'OK',
data,
command: `metabuilder-cli ${finalArgs.join(' ')}`,
stderr: stderr || undefined,
timestamp: new Date().toISOString(),
})
} catch (err: unknown) {
const error = err as { code?: string; stdout?: string; stderr?: string; message?: string }
let data: unknown
if (error.stdout) {
try { data = JSON.parse(error.stdout) } catch { data = error.stdout }
}
return NextResponse.json({
status: error.code === 'ETIMEDOUT' ? 408 : 500,
statusText: error.code === 'ETIMEDOUT' ? 'Timeout' : 'CLI Error',
data: data ?? { error: error.stderr || error.message || 'Unknown error' },
command: `metabuilder-cli ${finalArgs.join(' ')}`,
timestamp: new Date().toISOString(),
})
}
}

View File

@@ -38,13 +38,15 @@ const MAX_HISTORY = 20
const DEFAULT_TOKEN = '069e6487a710300381cd52120eab95d56d7f53beee21479cbeba9128217cbea9'
const CLI_EXAMPLES = [
'dbal ping',
'dbal list Snippet',
'dbal read Snippet <id>',
'dbal create Snippet title="Hello" content="world"',
'dbal delete Snippet <id>',
'dbal rest pastebin pastebin Snippet',
'dbal rest pastebin pastebin User',
'dbal ping',
'dbal create Snippet title="Hello" content="world"',
'auth session',
'tenant list',
'user list',
'package list',
]
function parseCli(input: string): { method: HttpMethod; path: string; body?: Record<string, unknown> } | null {
@@ -267,18 +269,43 @@ export function QueryConsole() {
executeQuery(method, path, parsedBody)
}, [method, body, buildPath, executeQuery])
const executeCli = useCallback(() => {
const parsed = parseCli(cliInput)
if (!parsed) {
setResponse({
status: 0, statusText: 'Parse Error',
data: { error: `Unknown command. Try: ${CLI_EXAMPLES[0]}` },
url: '', timestamp: new Date().toISOString(),
const executeCli = useCallback(async () => {
if (!cliInput.trim()) return
setLoading(true)
setResponse(null)
try {
const basePath = process.env.__NEXT_ROUTER_BASEPATH || '/dbal'
const res = await fetch(`${basePath}/api/cli`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: cliInput, token }),
})
return
const data: QueryResponse & { command?: string } = await res.json()
setResponse(data)
const entry: QueryHistoryEntry = {
id: crypto.randomUUID(),
method: 'GET',
path: data.command || cliInput,
status: data.status,
statusText: data.statusText,
timestamp: data.timestamp,
cli: cliInput,
}
const updated = [entry, ...history].slice(0, MAX_HISTORY)
setHistory(updated)
saveHistory(updated)
} catch (err) {
setResponse({
status: 0, statusText: 'Fetch Error',
data: { error: err instanceof Error ? err.message : 'Unknown error' },
url: cliInput, timestamp: new Date().toISOString(),
})
} finally {
setLoading(false)
}
executeQuery(parsed.method, parsed.path, parsed.body, cliInput)
}, [cliInput, executeQuery])
}, [cliInput, token, history])
const restoreFromHistory = useCallback((entry: QueryHistoryEntry) => {
if (entry.cli) {