mirror of
https://github.com/johndoe6345789/metabuilder.git
synced 2026-04-24 13:54:57 +00:00
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:
@@ -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
31
frontends/cli/Dockerfile
Normal 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"]
|
||||
@@ -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
|
||||
|
||||
92
frontends/dbal/app/api/cli/route.ts
Normal file
92
frontends/dbal/app/api/cli/route.ts
Normal 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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user