diff --git a/app/api/mcp/route.ts b/app/api/mcp/route.ts index 4be096f..a298709 100644 --- a/app/api/mcp/route.ts +++ b/app/api/mcp/route.ts @@ -28,7 +28,8 @@ import { import { VIBN_GCS_LOCATION } from '@/lib/gcp/storage'; import { getApplicationRuntimeLogs } from '@/lib/coolify-logs'; import { execInCoolifyApp } from '@/lib/coolify-exec'; -import { isCoolifySshConfigured } from '@/lib/coolify-ssh'; +import { isCoolifySshConfigured, runOnCoolifyHost } from '@/lib/coolify-ssh'; +import { listContainersForApp } from '@/lib/coolify-containers'; import { deployApplication, getApplicationInProject, @@ -41,6 +42,8 @@ import { deleteApplicationEnv, // Phase 4 ── create/update/delete + domains + databases + services createPublicApp, + createDockerImageApp, + createDockerComposeApp, updateApplication, deleteApplication, setApplicationDomains, @@ -74,7 +77,7 @@ const GITEA_API_URL = process.env.GITEA_API_URL ?? 'https://git.vibnai.com'; export async function GET() { return NextResponse.json({ name: 'vibn-mcp', - version: '2.2.0', + version: '2.3.0', authentication: { scheme: 'Bearer', tokenPrefix: 'vibn_sk_', @@ -102,6 +105,8 @@ export async function GET() { 'apps.domains.set', 'apps.logs', 'apps.exec', + 'apps.volumes.list', + 'apps.volumes.wipe', 'apps.envs.list', 'apps.envs.upsert', 'apps.envs.delete', @@ -198,6 +203,10 @@ export async function POST(request: Request) { return await toolAppsLogs(principal, params); case 'apps.exec': return await toolAppsExec(principal, params); + case 'apps.volumes.list': + return await toolAppsVolumesList(principal, params); + case 'apps.volumes.wipe': + return await toolAppsVolumesWipe(principal, params); case 'databases.list': return await toolDatabasesList(principal); @@ -509,6 +518,113 @@ async function toolAppsExec(principal: Principal, params: Record) { } } +// ── Volume tools ──────────────────────────────────────────────────────── + +/** + * apps.volumes.list — list Docker volumes that belong to an app. + * Returns name, size (bytes), and which containers are currently + * using each volume (if any are running). + */ +async function toolAppsVolumesList(principal: Principal, params: Record) { + const projectUuid = requireCoolifyProject(principal); + if (projectUuid instanceof NextResponse) return projectUuid; + if (!isCoolifySshConfigured()) { + return NextResponse.json({ error: 'apps.volumes.list requires SSH to the Coolify host' }, { status: 501 }); + } + const appUuid = String(params.uuid ?? params.appUuid ?? '').trim(); + if (!appUuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 }); + await getApplicationInProject(appUuid, projectUuid); + + const res = await runOnCoolifyHost( + `docker volume ls --filter name=${sq(appUuid)} --format '{{.Name}}' | xargs -r -I{} sh -c 'echo "{}|$(docker volume inspect {} --format "{{.UsageData.Size}}" 2>/dev/null || echo -1)"'`, + { timeoutMs: 12_000 }, + ); + if (res.code !== 0) { + return NextResponse.json({ error: `docker volume ls failed: ${res.stderr.trim()}` }, { status: 502 }); + } + + const volumes = res.stdout + .split('\n') + .map(l => l.trim()) + .filter(Boolean) + .map(l => { + const [name, sizeStr] = l.split('|'); + const sizeBytes = parseInt(sizeStr ?? '-1', 10); + return { name, sizeBytes: isNaN(sizeBytes) ? -1 : sizeBytes }; + }); + + return NextResponse.json({ result: { volumes } }); +} + +/** + * apps.volumes.wipe — destroy a Docker volume for this app. + * + * This is a destructive, irreversible operation. The agent MUST pass + * `confirm: ""` exactly matching the volume name, to + * prevent accidents. All containers using the volume are stopped and + * removed first (Coolify will restart them on the next deploy). + * + * Typical use: wipe a stale Postgres data volume before redeploying + * so the database is initialised fresh. + */ +async function toolAppsVolumesWipe(principal: Principal, params: Record) { + const projectUuid = requireCoolifyProject(principal); + if (projectUuid instanceof NextResponse) return projectUuid; + if (!isCoolifySshConfigured()) { + return NextResponse.json({ error: 'apps.volumes.wipe requires SSH to the Coolify host' }, { status: 501 }); + } + const appUuid = String(params.uuid ?? params.appUuid ?? '').trim(); + const volumeName = String(params.volume ?? '').trim(); + const confirm = String(params.confirm ?? '').trim(); + if (!appUuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 }); + if (!volumeName) return NextResponse.json({ error: 'Param "volume" is required (exact volume name from apps.volumes.list)' }, { status: 400 }); + if (confirm !== volumeName) { + return NextResponse.json( + { error: `Param "confirm" must equal the exact volume name "${volumeName}" to proceed` }, + { status: 400 }, + ); + } + + // Security check: volume must belong to this app (name must contain the uuid) + if (!volumeName.includes(appUuid)) { + return NextResponse.json( + { error: `Volume "${volumeName}" does not appear to belong to app ${appUuid}` }, + { status: 403 }, + ); + } + await getApplicationInProject(appUuid, projectUuid); + + // Stop + remove all containers using this volume, then remove the volume + const cmd = [ + // Stop and remove containers for this app (they'll be recreated on next deploy) + `CONTAINERS=$(docker ps -a --filter name=${sq(appUuid)} --format '{{.Names}}')`, + `[ -n "$CONTAINERS" ] && echo "$CONTAINERS" | xargs docker stop -t 10 || true`, + `[ -n "$CONTAINERS" ] && echo "$CONTAINERS" | xargs docker rm -f || true`, + // Remove the volume + `docker volume rm ${sq(volumeName)}`, + `echo "done"`, + ].join(' && '); + + const res = await runOnCoolifyHost(cmd, { timeoutMs: 30_000 }); + if (res.code !== 0 || !res.stdout.includes('done')) { + return NextResponse.json( + { error: `Volume removal failed (exit ${res.code}): ${res.stderr.trim() || res.stdout.trim()}` }, + { status: 502 }, + ); + } + + return NextResponse.json({ + result: { + wiped: volumeName, + message: 'Volume removed. Trigger apps.deploy to restart the app with a fresh volume.', + }, + }); +} + +function sq(s: string): string { + return `'${s.replace(/'/g, `'\\''`)}'`; +} + async function toolAppsEnvsList(principal: Principal, params: Record) { const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; @@ -575,19 +691,98 @@ async function toolAppsEnvsDelete(principal: Principal, params: Record) { const ws = principal.workspace; - if (!ws.coolify_project_uuid || !ws.gitea_org) { + if (!ws.coolify_project_uuid) { return NextResponse.json( - { error: 'Workspace not fully provisioned (need Coolify project + Gitea org)' }, + { error: 'Workspace not fully provisioned (need Coolify project)' }, { status: 503 } ); } - // We clone via HTTPS with the workspace's bot PAT (NOT SSH) — Gitea's - // builtin SSH is on an internal-only port and port 22 hits the host's - // OpenSSH, so SSH clones fail. HTTPS+PAT works in all topologies and - // the PAT is scoped to the org via team membership. + const commonOpts = { + projectUuid: ws.coolify_project_uuid, + serverUuid: ws.coolify_server_uuid ?? undefined, + environmentName: ws.coolify_environment_name, + destinationUuid: ws.coolify_destination_uuid ?? undefined, + isForceHttpsEnabled: true, + instantDeploy: false, + }; + + // ── Pathway 2: Docker image ─────────────────────────────────────────── + if (params.image) { + const image = String(params.image).trim(); + const appName = slugify(String(params.name ?? image.split('/').pop()?.split(':')[0] ?? 'app')); + const fqdn = resolveFqdn(params.domain, ws.slug, appName); + if (fqdn instanceof NextResponse) return fqdn; + + const created = await createDockerImageApp({ + ...commonOpts, + image, + name: appName, + portsExposes: String(params.ports ?? '80'), + domains: toDomainsString([fqdn]), + description: params.description ? String(params.description) : undefined, + }); + + await applyEnvsAndDeploy(created.uuid, params); + return NextResponse.json({ result: { uuid: created.uuid, name: appName, domain: fqdn, url: `https://${fqdn}` } }); + } + + // ── Pathway 3: Inline Docker Compose ───────────────────────────────── + if (params.composeRaw) { + const composeRaw = String(params.composeRaw).trim(); + const appName = slugify(String(params.name ?? 'app')); + const fqdn = resolveFqdn(params.domain, ws.slug, appName); + if (fqdn instanceof NextResponse) return fqdn; + + // composeDomains: array of { service, domain } or derive from fqdn + const composeDomains: Array<{ service: string; domain: string }> = + Array.isArray(params.composeDomains) && params.composeDomains.length > 0 + ? params.composeDomains + : [{ service: params.composeService ?? 'server', domain: fqdn }]; + + const created = await createDockerComposeApp({ + ...commonOpts, + composeRaw, + name: appName, + description: params.description ? String(params.description) : undefined, + composeDomains, + }); + + await applyEnvsAndDeploy(created.uuid, params); + return NextResponse.json({ result: { uuid: created.uuid, name: appName, domain: fqdn, url: `https://${fqdn}` } }); + } + + // ── Pathway 1: Gitea repo (original behaviour) ──────────────────────── + if (!ws.gitea_org) { + return NextResponse.json( + { error: 'Workspace not fully provisioned (need Gitea org). For third-party apps, use `image` or `composeRaw` instead of `repo`.' }, + { status: 503 } + ); + } const botCreds = getWorkspaceBotCredentials(ws); if (!botCreds) { return NextResponse.json( @@ -597,7 +792,12 @@ async function toolAppsCreate(principal: Principal, params: Record) } const repoIn = String(params.repo ?? '').trim(); - if (!repoIn) return NextResponse.json({ error: 'Param "repo" is required' }, { status: 400 }); + if (!repoIn) { + return NextResponse.json( + { error: 'One of `repo`, `image`, or `composeRaw` is required' }, + { status: 400 } + ); + } const parts = repoIn.replace(/\.git$/, '').split('/'); const repoOrg = parts.length === 2 ? parts[0] : ws.gitea_org; @@ -614,21 +814,11 @@ async function toolAppsCreate(principal: Principal, params: Record) } const appName = slugify(String(params.name ?? repoName)); - const fqdn = String(params.domain ?? '').trim() - ? String(params.domain).replace(/^https?:\/\//, '') - : workspaceAppFqdn(ws.slug, appName); - if (!isDomainUnderWorkspace(fqdn, ws.slug)) { - return NextResponse.json( - { error: `Domain ${fqdn} must end with .${ws.slug}.vibnai.com` }, - { status: 403 } - ); - } + const fqdn = resolveFqdn(params.domain, ws.slug, appName); + if (fqdn instanceof NextResponse) return fqdn; const created = await createPublicApp({ - projectUuid: ws.coolify_project_uuid, - serverUuid: ws.coolify_server_uuid ?? undefined, - environmentName: ws.coolify_environment_name, - destinationUuid: ws.coolify_destination_uuid ?? undefined, + ...commonOpts, gitRepository: giteaHttpsUrl(repoOrg, repoName, botCreds.username, botCreds.token), gitBranch: String(params.branch ?? repo.default_branch ?? 'main'), portsExposes: String(params.ports ?? '3000'), @@ -636,50 +826,62 @@ async function toolAppsCreate(principal: Principal, params: Record) name: appName, domains: toDomainsString([fqdn]), isAutoDeployEnabled: true, - isForceHttpsEnabled: true, - instantDeploy: false, - dockerComposeLocation: params.dockerComposeLocation - ? String(params.dockerComposeLocation) - : undefined, - dockerfileLocation: params.dockerfileLocation - ? String(params.dockerfileLocation) - : undefined, + dockerComposeLocation: params.dockerComposeLocation ? String(params.dockerComposeLocation) : undefined, + dockerfileLocation: params.dockerfileLocation ? String(params.dockerfileLocation) : undefined, baseDirectory: params.baseDirectory ? String(params.baseDirectory) : undefined, }); - // Attach envs - if (params.envs && typeof params.envs === 'object') { - for (const [k, v] of Object.entries(params.envs as Record)) { - if (!/^[A-Z_][A-Z0-9_]*$/i.test(k)) continue; - try { - await upsertApplicationEnv(created.uuid, { key: k, value: String(v) }); - } catch (e) { - console.warn('[mcp apps.create] upsert env failed', k, e); - } - } - } - - let deploymentUuid: string | null = null; - if (params.instantDeploy !== false) { - try { - const dep = await deployApplication(created.uuid); - deploymentUuid = dep.deployment_uuid ?? null; - } catch (e) { - console.warn('[mcp apps.create] first deploy failed', e); - } - } - + const dep = await applyEnvsAndDeploy(created.uuid, params); return NextResponse.json({ result: { uuid: created.uuid, name: appName, domain: fqdn, url: `https://${fqdn}`, - deploymentUuid, + deploymentUuid: dep, }, }); } +/** Resolve fqdn from params.domain or auto-generate. Returns NextResponse on policy error. */ +function resolveFqdn(domainParam: unknown, slug: string, appName: string): string | NextResponse { + const fqdn = String(domainParam ?? '').trim() + ? String(domainParam).replace(/^https?:\/\//, '') + : workspaceAppFqdn(slug, appName); + if (!isDomainUnderWorkspace(fqdn, slug)) { + return NextResponse.json( + { error: `Domain ${fqdn} must end with .${slug}.vibnai.com` }, + { status: 403 } + ); + } + return fqdn; +} + +/** Upsert envs then optionally trigger deploy. Returns deploymentUuid or null. */ +async function applyEnvsAndDeploy( + appUuid: string, + params: Record, +): Promise { + if (params.envs && typeof params.envs === 'object') { + for (const [k, v] of Object.entries(params.envs as Record)) { + if (!/^[A-Z_][A-Z0-9_]*$/i.test(k)) continue; + try { + await upsertApplicationEnv(appUuid, { key: k, value: String(v) }); + } catch (e) { + console.warn('[mcp apps.create] upsert env failed', k, e); + } + } + } + if (params.instantDeploy === false) return null; + try { + const dep = await deployApplication(appUuid); + return dep.deployment_uuid ?? null; + } catch (e) { + console.warn('[mcp apps.create] first deploy failed', e); + return null; + } +} + async function toolAppsUpdate(principal: Principal, params: Record) { const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; diff --git a/lib/coolify.ts b/lib/coolify.ts index f92a98b..6edca71 100644 --- a/lib/coolify.ts +++ b/lib/coolify.ts @@ -343,7 +343,7 @@ export async function restartDatabase(uuid: string): Promise { // Applications // ────────────────────────────────────────────────── -export type CoolifyBuildPack = 'nixpacks' | 'static' | 'dockerfile' | 'dockercompose'; +export type CoolifyBuildPack = 'nixpacks' | 'static' | 'dockerfile' | 'dockercompose' | 'dockerimage'; export interface CreatePrivateDeployKeyAppOpts { projectUuid: string; @@ -455,6 +455,92 @@ export async function createPublicApp(opts: CreatePublicAppOpts): Promise<{ uuid }); } +// ────────────────────────────────────────────────── +// Repo-free app creation (Docker image / raw compose) +// ────────────────────────────────────────────────── + +export interface CreateDockerImageAppOpts { + projectUuid: string; + image: string; // e.g. "twentyhq/twenty:1.23.0" or "nginx:alpine" + name?: string; + portsExposes?: string; // default "80" + domains?: string; + description?: string; + serverUuid?: string; + environmentName?: string; + destinationUuid?: string; + isForceHttpsEnabled?: boolean; + instantDeploy?: boolean; +} + +export async function createDockerImageApp( + opts: CreateDockerImageAppOpts, +): Promise<{ uuid: string }> { + const body = stripUndefined({ + project_uuid: opts.projectUuid, + server_uuid: opts.serverUuid ?? COOLIFY_DEFAULT_SERVER_UUID, + environment_name: opts.environmentName ?? 'production', + destination_uuid: opts.destinationUuid ?? COOLIFY_DEFAULT_DESTINATION_UUID, + docker_registry_image_name: opts.image, + name: opts.name, + description: opts.description, + ports_exposes: opts.portsExposes ?? '80', + domains: opts.domains, + is_force_https_enabled: opts.isForceHttpsEnabled ?? true, + instant_deploy: opts.instantDeploy ?? false, + }); + return coolifyFetch('/applications/dockerimage', { + method: 'POST', + body: JSON.stringify(body), + }); +} + +export interface CreateDockerComposeAppOpts { + projectUuid: string; + composeRaw: string; // raw docker-compose YAML as a string + name?: string; + description?: string; + serverUuid?: string; + environmentName?: string; + destinationUuid?: string; + isForceHttpsEnabled?: boolean; + instantDeploy?: boolean; + /** + * Map compose service(s) to public domain(s) after creation. + * Array of { service, domain } pairs. The first entry becomes the + * primary public URL. + */ + composeDomains?: Array<{ service: string; domain: string }>; +} + +export async function createDockerComposeApp( + opts: CreateDockerComposeAppOpts, +): Promise<{ uuid: string }> { + const body = stripUndefined({ + project_uuid: opts.projectUuid, + server_uuid: opts.serverUuid ?? COOLIFY_DEFAULT_SERVER_UUID, + environment_name: opts.environmentName ?? 'production', + destination_uuid: opts.destinationUuid ?? COOLIFY_DEFAULT_DESTINATION_UUID, + build_pack: 'dockercompose', + name: opts.name, + description: opts.description, + docker_compose_raw: opts.composeRaw, + is_force_https_enabled: opts.isForceHttpsEnabled ?? true, + instant_deploy: opts.instantDeploy ?? false, + // domains for compose are set via docker_compose_domains after creation + docker_compose_domains: opts.composeDomains + ? JSON.stringify(opts.composeDomains.map(({ service, domain }) => ({ + name: service, + domain: `https://${domain.replace(/^https?:\/\//, '')}`, + }))) + : undefined, + }); + return coolifyFetch('/applications/dockercompose', { + method: 'POST', + body: JSON.stringify(body), + }); +} + export async function updateApplication( uuid: string, patch: Record