diff --git a/apps/client/src/lib/store.ts b/apps/client/src/lib/store.ts index dc45778b2..f25055de2 100644 --- a/apps/client/src/lib/store.ts +++ b/apps/client/src/lib/store.ts @@ -42,6 +42,7 @@ interface AppSession { gitlab: string | null; }; pendingInvitations: Array; + isARM: boolean } export const appSession: Writable = writable({ @@ -61,7 +62,8 @@ export const appSession: Writable = writable({ github: null, gitlab: null }, - pendingInvitations: [] + pendingInvitations: [], + isARM: false }); interface AddToast { diff --git a/apps/client/src/routes/databases/[id]/+layout.svelte b/apps/client/src/routes/databases/[id]/+layout.svelte new file mode 100644 index 000000000..666317092 --- /dev/null +++ b/apps/client/src/routes/databases/[id]/+layout.svelte @@ -0,0 +1,305 @@ + + +{#if id !== 'new'} + +{/if} + diff --git a/apps/client/src/routes/databases/[id]/+layout.ts b/apps/client/src/routes/databases/[id]/+layout.ts new file mode 100644 index 000000000..5dff433c0 --- /dev/null +++ b/apps/client/src/routes/databases/[id]/+layout.ts @@ -0,0 +1,48 @@ +import { error } from '@sveltejs/kit'; +import { trpc } from '$lib/store'; +import type { LayoutLoad } from './$types'; +import { redirect } from '@sveltejs/kit'; + +function checkConfiguration(database: any): string | null { + let configurationPhase = null; + if (!database.type) { + configurationPhase = 'type'; + } else if (!database.version) { + configurationPhase = 'version'; + } else if (!database.destinationDockerId) { + configurationPhase = 'destination'; + } + return configurationPhase; +} + +export const load: LayoutLoad = async ({ params, url }) => { + const { pathname } = new URL(url); + const { id } = params; + try { + const database = await trpc.databases.getDatabaseById.query({ id }); + if (!database) { + throw redirect(307, '/databases'); + } + const configurationPhase = checkConfiguration(database); + console.log({ configurationPhase }); + // if ( + // configurationPhase && + // pathname !== `/applications/${params.id}/configuration/${configurationPhase}` + // ) { + // throw redirect(302, `/applications/${params.id}/configuration/${configurationPhase}`); + // } + return { + database + }; + } catch (err) { + if (err instanceof Error) { + throw error(500, { + message: 'An unexpected error occurred, please try again later.' + '

' + err.message + }); + } + + throw error(500, { + message: 'An unexpected error occurred, please try again later.' + }); + } +}; diff --git a/apps/client/src/routes/databases/[id]/+page.svelte b/apps/client/src/routes/databases/[id]/+page.svelte new file mode 100644 index 000000000..1a8e76a21 --- /dev/null +++ b/apps/client/src/routes/databases/[id]/+page.svelte @@ -0,0 +1,62 @@ + + +
+
+
+
Used Memory / Memory Limit
+
{usage?.MemUsage}
+
+ +
+
Used CPU
+
{usage?.CPUPerc}
+
+ +
+
Network IO
+
{usage?.NetIO}
+
+
+
+ diff --git a/apps/client/src/routes/databases/[id]/components/DatabaseLinks.svelte b/apps/client/src/routes/databases/[id]/components/DatabaseLinks.svelte new file mode 100644 index 000000000..a3053a174 --- /dev/null +++ b/apps/client/src/routes/databases/[id]/components/DatabaseLinks.svelte @@ -0,0 +1,32 @@ + + + + {#if database.type === 'clickhouse'} + + {:else if database.type === 'couchdb'} + + {:else if database.type === 'mongodb'} + + {:else if database.type === 'mysql'} + + {:else if database.type === 'mariadb'} + + {:else if database.type === 'postgresql'} + + {:else if database.type === 'redis'} + + {:else if database.type === 'edgedb'} + + {/if} + diff --git a/apps/client/src/routes/databases/[id]/components/Databases/CouchDb.svelte b/apps/client/src/routes/databases/[id]/components/Databases/CouchDb.svelte new file mode 100644 index 000000000..e6f9acb28 --- /dev/null +++ b/apps/client/src/routes/databases/[id]/components/Databases/CouchDb.svelte @@ -0,0 +1,68 @@ + + +
+

CouchDB

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
diff --git a/apps/client/src/routes/databases/[id]/components/Databases/Databases.svelte b/apps/client/src/routes/databases/[id]/components/Databases/Databases.svelte new file mode 100644 index 000000000..25ccd352b --- /dev/null +++ b/apps/client/src/routes/databases/[id]/components/Databases/Databases.svelte @@ -0,0 +1,281 @@ + + +
+
+
+

General

+ {#if $appSession.isAdmin} + + {/if} +
+
+ + + + {#if database.destinationDockerId} +
+ +
+ {/if} + + + + + + + +
+ {#if database.type === 'mysql'} + + {:else if database.type === 'postgresql'} + + {:else if database.type === 'mongodb'} + + {:else if database.type === 'mariadb'} + + {:else if database.type === 'redis'} + + {:else if database.type === 'couchdb'} + + {:else if database.type === 'edgedb'} + + {/if} +
+
+ + +
+
+ {#if publicUrl} + + {/if} +
+
+ +
+

Features

+
+
+ changeSettings('isPublic')} + title="Set it Public" + description="Your database will be reachable over the internet.
Take security seriously in this case!" + disabled={!$status.database.isRunning} + /> + {#if database.type === 'redis'} + changeSettings('appendOnly')} + title="Change append only mode" + description="Useful if you would like to restore redis data from a backup.
Database restart is required." + /> + {/if} +
+
diff --git a/apps/client/src/routes/databases/[id]/components/Databases/EdgeDB.svelte b/apps/client/src/routes/databases/[id]/components/Databases/EdgeDB.svelte new file mode 100644 index 000000000..ec04aa03d --- /dev/null +++ b/apps/client/src/routes/databases/[id]/components/Databases/EdgeDB.svelte @@ -0,0 +1,50 @@ + + +
+
EdgeDB
+
+
+
+ + +
+
+ + +
+
+ + +
+
diff --git a/apps/client/src/routes/databases/[id]/components/Databases/MariaDB.svelte b/apps/client/src/routes/databases/[id]/components/Databases/MariaDB.svelte new file mode 100644 index 000000000..1831a6401 --- /dev/null +++ b/apps/client/src/routes/databases/[id]/components/Databases/MariaDB.svelte @@ -0,0 +1,78 @@ + + +
+

MariaDB

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
diff --git a/apps/client/src/routes/databases/[id]/components/Databases/MongoDB.svelte b/apps/client/src/routes/databases/[id]/components/Databases/MongoDB.svelte new file mode 100644 index 000000000..7f110703e --- /dev/null +++ b/apps/client/src/routes/databases/[id]/components/Databases/MongoDB.svelte @@ -0,0 +1,38 @@ + + +
+

MongoDB

+
+
+
+ + +
+
+ + +
+
diff --git a/apps/client/src/routes/databases/[id]/components/Databases/MySQL.svelte b/apps/client/src/routes/databases/[id]/components/Databases/MySQL.svelte new file mode 100644 index 000000000..db8df91cd --- /dev/null +++ b/apps/client/src/routes/databases/[id]/components/Databases/MySQL.svelte @@ -0,0 +1,76 @@ + + +
+

MySQL

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
diff --git a/apps/client/src/routes/databases/[id]/components/Databases/PostgreSQL.svelte b/apps/client/src/routes/databases/[id]/components/Databases/PostgreSQL.svelte new file mode 100644 index 000000000..8b4589e0d --- /dev/null +++ b/apps/client/src/routes/databases/[id]/components/Databases/PostgreSQL.svelte @@ -0,0 +1,68 @@ + + +
+

PostgreSQL

+
+
+
+ + +
+ {#if !$appSession.isARM} +
+ + +
+ {/if} +
+ + +
+
+ + +
+
diff --git a/apps/client/src/routes/databases/[id]/components/Databases/Redis.svelte b/apps/client/src/routes/databases/[id]/components/Databases/Redis.svelte new file mode 100644 index 000000000..50049d52c --- /dev/null +++ b/apps/client/src/routes/databases/[id]/components/Databases/Redis.svelte @@ -0,0 +1,27 @@ + + +
+

Redis

+
+
+
+ + +
+
diff --git a/apps/client/src/routes/databases/[id]/utils.ts b/apps/client/src/routes/databases/[id]/utils.ts new file mode 100644 index 000000000..2ed88f33d --- /dev/null +++ b/apps/client/src/routes/databases/[id]/utils.ts @@ -0,0 +1,37 @@ +import { errorNotification } from '$lib/common'; +import { trpc } from '$lib/store'; + +type Props = { + isNew: boolean; + name: string; + value: string; + isBuildSecret?: boolean; + isPRMRSecret?: boolean; + isNewSecret?: boolean; + databaseId: string; +}; + +export async function saveSecret({ + isNew, + name, + value, + isNewSecret, + databaseId +}: Props): Promise { + if (!name) return errorNotification('Name is required'); + if (!value) return errorNotification('Value is required'); + try { + await trpc.databases.saveSecret.mutate({ + name, + value, + isNew: isNew || false + }); + + if (isNewSecret) { + name = ''; + value = ''; + } + } catch (error) { + throw error; + } +} diff --git a/apps/server/src/lib/common.ts b/apps/server/src/lib/common.ts index 57991d242..210168ebc 100644 --- a/apps/server/src/lib/common.ts +++ b/apps/server/src/lib/common.ts @@ -10,6 +10,8 @@ import { env } from '../env'; import { day } from './dayjs'; import { executeCommand } from './executeCommand'; import { saveBuildLog } from './logging'; +import { checkContainer } from './docker'; +import yaml from 'js-yaml'; const customConfig: Config = { dictionaries: [adjectives, colors, animals], @@ -22,6 +24,37 @@ export const isDev = env.NODE_ENV === 'development'; export const version = '3.13.0'; export const sentryDSN = 'https://409f09bcb7af47928d3e0f46b78987f3@o1082494.ingest.sentry.io/4504236622217216'; +export const defaultTraefikImage = `traefik:v2.8`; +export function getAPIUrl() { + if (process.env.GITPOD_WORKSPACE_URL) { + const { href } = new URL(process.env.GITPOD_WORKSPACE_URL); + const newURL = href.replace('https://', 'https://3001-').replace(/\/$/, ''); + return newURL; + } + if (process.env.CODESANDBOX_HOST) { + return `https://${process.env.CODESANDBOX_HOST.replace(/\$PORT/, '3001')}`; + } + return isDev ? 'http://host.docker.internal:3001' : 'http://localhost:3000'; +} + +export function getUIUrl() { + if (process.env.GITPOD_WORKSPACE_URL) { + const { href } = new URL(process.env.GITPOD_WORKSPACE_URL); + const newURL = href.replace('https://', 'https://3000-').replace(/\/$/, ''); + return newURL; + } + if (process.env.CODESANDBOX_HOST) { + return `https://${process.env.CODESANDBOX_HOST.replace(/\$PORT/, '3000')}`; + } + return 'http://localhost:3000'; +} +const mainTraefikEndpoint = isDev + ? `${getAPIUrl()}/webhooks/traefik/main.json` + : 'http://coolify:3000/webhooks/traefik/main.json'; + +const otherTraefikEndpoint = isDev + ? `${getAPIUrl()}/webhooks/traefik/other.json` + : 'http://coolify:3000/webhooks/traefik/other.json'; export async function listSettings(): Promise { return await prisma.setting.findUnique({ where: { id: '0' } }); @@ -707,4 +740,86 @@ export function makeLabelForServices(type) { ]; } export const asyncSleep = (delay: number): Promise => - new Promise((resolve) => setTimeout(resolve, delay)); \ No newline at end of file + new Promise((resolve) => setTimeout(resolve, delay)); + +export async function startTraefikTCPProxy( + destinationDocker: any, + id: string, + publicPort: number, + privatePort: number, + type?: string +): Promise<{ stdout: string; stderr: string }> { + const { network, id: dockerId, remoteEngine } = destinationDocker; + const container = `${id}-${publicPort}`; + const { found } = await checkContainer({ dockerId, container, remove: true }); + const { ipv4, ipv6 } = await listSettings(); + + let dependentId = id; + if (type === 'wordpressftp') dependentId = `${id}-ftp`; + const { found: foundDependentContainer } = await checkContainer({ + dockerId, + container: dependentId, + remove: true + }); + if (foundDependentContainer && !found) { + const { stdout: Config } = await executeCommand({ + dockerId, + command: `docker network inspect ${network} --format '{{json .IPAM.Config }}'` + }); + + const ip = JSON.parse(Config)[0].Gateway; + let traefikUrl = otherTraefikEndpoint; + if (remoteEngine) { + let ip = null; + if (isDev) { + ip = getAPIUrl(); + } else { + ip = `http://${ipv4 || ipv6}:3000`; + } + traefikUrl = `${ip}/webhooks/traefik/other.json`; + } + const tcpProxy = { + version: '3.8', + services: { + [`${id}-${publicPort}`]: { + container_name: container, + image: defaultTraefikImage, + command: [ + `--entrypoints.tcp.address=:${publicPort}`, + `--entryPoints.tcp.forwardedHeaders.insecure=true`, + `--providers.http.endpoint=${traefikUrl}?id=${id}&privatePort=${privatePort}&publicPort=${publicPort}&type=tcp&address=${dependentId}`, + '--providers.http.pollTimeout=10s', + '--log.level=error' + ], + ports: [`${publicPort}:${publicPort}`], + extra_hosts: ['host.docker.internal:host-gateway', `host.docker.internal: ${ip}`], + volumes: ['/var/run/docker.sock:/var/run/docker.sock'], + networks: ['coolify-infra', network] + } + }, + networks: { + [network]: { + external: false, + name: network + }, + 'coolify-infra': { + external: false, + name: 'coolify-infra' + } + } + }; + await fs.writeFile(`/tmp/docker-compose-${id}.yaml`, yaml.dump(tcpProxy)); + await executeCommand({ + dockerId, + command: `docker compose -f /tmp/docker-compose-${id}.yaml up -d` + }); + await fs.rm(`/tmp/docker-compose-${id}.yaml`); + } + if (!foundDependentContainer && found) { + await executeCommand({ + dockerId, + command: `docker stop -t 0 ${container} && docker rm ${container}`, + shell: true + }); + } +} diff --git a/apps/server/src/trpc/routers/databases.ts b/apps/server/src/trpc/routers/databases.ts deleted file mode 100644 index 8d4d8a0ee..000000000 --- a/apps/server/src/trpc/routers/databases.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { z } from 'zod'; -import { privateProcedure, router } from '../trpc'; -import { decrypt } from '../../lib/common'; -import { prisma } from '../../prisma'; -import { executeCommand } from '../../lib/executeCommand'; -import { stopDatabaseContainer, stopTcpHttpProxy } from '../../lib/docker'; - -export const databasesRouter = router({ - status: privateProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => { - const id = input.id; - const teamId = ctx.user?.teamId; - - let isRunning = false; - const database = await prisma.database.findFirst({ - where: { id, teams: { some: { id: teamId === '0' ? undefined : teamId } } }, - include: { destinationDocker: true, settings: true } - }); - if (database) { - const { destinationDockerId, destinationDocker } = database; - if (destinationDockerId) { - try { - const { stdout } = await executeCommand({ - dockerId: destinationDocker.id, - command: `docker inspect --format '{{json .State}}' ${id}` - }); - - if (JSON.parse(stdout).Running) { - isRunning = true; - } - } catch (error) { - // - } - } - } - return { - isRunning - }; - }), - cleanup: privateProcedure.query(async ({ ctx }) => { - const teamId = ctx.user?.teamId; - let databases = await prisma.database.findMany({ - where: { teams: { some: { id: teamId === '0' ? undefined : teamId } } }, - include: { settings: true, destinationDocker: true, teams: true } - }); - for (const database of databases) { - if (!database?.version) { - const { id } = database; - if (database.destinationDockerId) { - const everStarted = await stopDatabaseContainer(database); - if (everStarted) - await stopTcpHttpProxy(id, database.destinationDocker, database.publicPort); - } - await prisma.databaseSettings.deleteMany({ where: { databaseId: id } }); - await prisma.databaseSecret.deleteMany({ where: { databaseId: id } }); - await prisma.database.delete({ where: { id } }); - } - } - return {}; - }), - delete: privateProcedure - .input(z.object({ id: z.string(), force: z.boolean() })) - .mutation(async ({ ctx, input }) => { - const { id, force } = input; - const teamId = ctx.user?.teamId; - const database = await prisma.database.findFirst({ - where: { id, teams: { some: { id: teamId === '0' ? undefined : teamId } } }, - include: { destinationDocker: true, settings: true } - }); - if (!force) { - if (database.dbUserPassword) database.dbUserPassword = decrypt(database.dbUserPassword); - if (database.rootUserPassword) - database.rootUserPassword = decrypt(database.rootUserPassword); - if (database.destinationDockerId) { - const everStarted = await stopDatabaseContainer(database); - if (everStarted) - await stopTcpHttpProxy(id, database.destinationDocker, database.publicPort); - } - } - await prisma.databaseSettings.deleteMany({ where: { databaseId: id } }); - await prisma.databaseSecret.deleteMany({ where: { databaseId: id } }); - await prisma.database.delete({ where: { id } }); - return {}; - }) -}); diff --git a/apps/server/src/trpc/routers/databases/index.ts b/apps/server/src/trpc/routers/databases/index.ts new file mode 100644 index 000000000..8851d975a --- /dev/null +++ b/apps/server/src/trpc/routers/databases/index.ts @@ -0,0 +1,379 @@ +import { z } from 'zod'; +import fs from 'fs/promises'; +import { privateProcedure, router } from '../../trpc'; +import { + createDirectories, + decrypt, + encrypt, + getContainerUsage, + listSettings, + startTraefikTCPProxy +} from '../../../lib/common'; +import { prisma } from '../../../prisma'; +import { executeCommand } from '../../../lib/executeCommand'; +import { + defaultComposeConfiguration, + stopDatabaseContainer, + stopTcpHttpProxy +} from '../../../lib/docker'; +import { + generateDatabaseConfiguration, + getDatabaseVersions, + makeLabelForStandaloneDatabase, + updatePasswordInDb +} from './lib'; +import yaml from 'js-yaml'; +import { getFreePublicPort } from '../services/lib'; + +export const databasesRouter = router({ + usage: privateProcedure + .input( + z.object({ + id: z.string() + }) + ) + .query(async ({ ctx, input }) => { + const teamId = ctx.user?.teamId; + const { id } = input; + let usage = {}; + + const database = await prisma.database.findFirst({ + where: { id, teams: { some: { id: teamId === '0' ? undefined : teamId } } }, + include: { destinationDocker: true, settings: true } + }); + if (database.dbUserPassword) database.dbUserPassword = decrypt(database.dbUserPassword); + if (database.rootUserPassword) database.rootUserPassword = decrypt(database.rootUserPassword); + if (database.destinationDockerId) { + [usage] = await Promise.all([getContainerUsage(database.destinationDocker.id, id)]); + } + return { + success: true, + data: { + usage + } + }; + }), + save: privateProcedure + .input( + z.object({ + id: z.string() + }) + ) + .mutation(async ({ ctx, input }) => { + const teamId = ctx.user?.teamId; + const { + id, + name, + defaultDatabase, + dbUser, + dbUserPassword, + rootUser, + rootUserPassword, + version, + isRunning + } = input; + const database = await prisma.database.findFirst({ + where: { id, teams: { some: { id: teamId === '0' ? undefined : teamId } } }, + include: { destinationDocker: true, settings: true } + }); + if (database.dbUserPassword) database.dbUserPassword = decrypt(database.dbUserPassword); + if (database.rootUserPassword) database.rootUserPassword = decrypt(database.rootUserPassword); + if (isRunning) { + if (database.dbUserPassword !== dbUserPassword) { + await updatePasswordInDb(database, dbUser, dbUserPassword, false); + } else if (database.rootUserPassword !== rootUserPassword) { + await updatePasswordInDb(database, rootUser, rootUserPassword, true); + } + } + const encryptedDbUserPassword = dbUserPassword && encrypt(dbUserPassword); + const encryptedRootUserPassword = rootUserPassword && encrypt(rootUserPassword); + await prisma.database.update({ + where: { id }, + data: { + name, + defaultDatabase, + dbUser, + dbUserPassword: encryptedDbUserPassword, + rootUser, + rootUserPassword: encryptedRootUserPassword, + version + } + }); + }), + saveSettings: privateProcedure + .input( + z.object({ + id: z.string(), + isPublic: z.boolean(), + appendOnly: z.boolean().default(true) + }) + ) + .mutation(async ({ ctx, input }) => { + const teamId = ctx.user?.teamId; + const { id, isPublic, appendOnly = true } = input; + + let publicPort = null; + + const { + destinationDocker: { remoteEngine, engine, remoteIpAddress } + } = await prisma.database.findUnique({ where: { id }, include: { destinationDocker: true } }); + + if (isPublic) { + publicPort = await getFreePublicPort({ id, remoteEngine, engine, remoteIpAddress }); + } + await prisma.database.update({ + where: { id }, + data: { + settings: { + upsert: { update: { isPublic, appendOnly }, create: { isPublic, appendOnly } } + } + } + }); + const database = await prisma.database.findFirst({ + where: { id, teams: { some: { id: teamId === '0' ? undefined : teamId } } }, + include: { destinationDocker: true, settings: true } + }); + const { arch } = await listSettings(); + if (database.dbUserPassword) database.dbUserPassword = decrypt(database.dbUserPassword); + if (database.rootUserPassword) database.rootUserPassword = decrypt(database.rootUserPassword); + + const { destinationDockerId, destinationDocker, publicPort: oldPublicPort } = database; + const { privatePort } = generateDatabaseConfiguration(database, arch); + + if (destinationDockerId) { + if (isPublic) { + await prisma.database.update({ where: { id }, data: { publicPort } }); + await startTraefikTCPProxy(destinationDocker, id, publicPort, privatePort); + } else { + await prisma.database.update({ where: { id }, data: { publicPort: null } }); + await stopTcpHttpProxy(id, destinationDocker, oldPublicPort); + } + } + return { publicPort }; + }), + saveSecret: privateProcedure + .input( + z.object({ + id: z.string(), + name: z.string(), + value: z.string(), + isNew: z.boolean().default(true) + }) + ) + .mutation(async ({ ctx, input }) => { + let { id, name, value, isNew } = input; + + if (isNew) { + const found = await prisma.databaseSecret.findFirst({ where: { name, databaseId: id } }); + if (found) { + throw `Secret ${name} already exists.`; + } else { + value = encrypt(value.trim()); + await prisma.databaseSecret.create({ + data: { name, value, database: { connect: { id } } } + }); + } + } else { + value = encrypt(value.trim()); + const found = await prisma.databaseSecret.findFirst({ where: { databaseId: id, name } }); + + if (found) { + await prisma.databaseSecret.updateMany({ + where: { databaseId: id, name }, + data: { value } + }); + } else { + await prisma.databaseSecret.create({ + data: { name, value, database: { connect: { id } } } + }); + } + } + }), + start: privateProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => { + const { id } = input; + const teamId = ctx.user?.teamId; + const database = await prisma.database.findFirst({ + where: { id, teams: { some: { id: teamId === '0' ? undefined : teamId } } }, + include: { destinationDocker: true, settings: true, databaseSecret: true } + }); + const { arch } = await listSettings(); + if (database.dbUserPassword) database.dbUserPassword = decrypt(database.dbUserPassword); + if (database.rootUserPassword) database.rootUserPassword = decrypt(database.rootUserPassword); + const { + type, + destinationDockerId, + destinationDocker, + publicPort, + settings: { isPublic }, + databaseSecret + } = database; + const { privatePort, command, environmentVariables, image, volume, ulimits } = + generateDatabaseConfiguration(database, arch); + + const network = destinationDockerId && destinationDocker.network; + const volumeName = volume.split(':')[0]; + const labels = await makeLabelForStandaloneDatabase({ id, image, volume }); + + const { workdir } = await createDirectories({ repository: type, buildId: id }); + if (databaseSecret.length > 0) { + databaseSecret.forEach((secret) => { + environmentVariables[secret.name] = decrypt(secret.value); + }); + } + const composeFile = { + version: '3.8', + services: { + [id]: { + container_name: id, + image, + command, + environment: environmentVariables, + volumes: [volume], + ulimits, + labels, + ...defaultComposeConfiguration(network) + } + }, + networks: { + [network]: { + external: true + } + }, + volumes: { + [volumeName]: { + name: volumeName + } + } + }; + const composeFileDestination = `${workdir}/docker-compose.yaml`; + await fs.writeFile(composeFileDestination, yaml.dump(composeFile)); + await executeCommand({ + dockerId: destinationDocker.id, + command: `docker compose -f ${composeFileDestination} up -d` + }); + if (isPublic) await startTraefikTCPProxy(destinationDocker, id, publicPort, privatePort); + }), + stop: privateProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => { + const { id } = input; + const teamId = ctx.user?.teamId; + const database = await prisma.database.findFirst({ + where: { id, teams: { some: { id: teamId === '0' ? undefined : teamId } } }, + include: { destinationDocker: true, settings: true } + }); + if (database.dbUserPassword) database.dbUserPassword = decrypt(database.dbUserPassword); + if (database.rootUserPassword) database.rootUserPassword = decrypt(database.rootUserPassword); + const everStarted = await stopDatabaseContainer(database); + if (everStarted) await stopTcpHttpProxy(id, database.destinationDocker, database.publicPort); + await prisma.database.update({ + where: { id }, + data: { + settings: { upsert: { update: { isPublic: false }, create: { isPublic: false } } } + } + }); + await prisma.database.update({ where: { id }, data: { publicPort: null } }); + }), + getDatabaseById: privateProcedure + .input(z.object({ id: z.string() })) + .query(async ({ ctx, input }) => { + const { id } = input; + const teamId = ctx.user?.teamId; + const database = await prisma.database.findFirst({ + where: { id, teams: { some: { id: teamId === '0' ? undefined : teamId } } }, + include: { destinationDocker: true, settings: true } + }); + if (!database) { + throw { status: 404, message: 'Database not found.' }; + } + const settings = await listSettings(); + if (database.dbUserPassword) database.dbUserPassword = decrypt(database.dbUserPassword); + if (database.rootUserPassword) database.rootUserPassword = decrypt(database.rootUserPassword); + const configuration = generateDatabaseConfiguration(database, settings.arch); + return { + success: true, + data: { + privatePort: configuration?.privatePort, + database, + versions: await getDatabaseVersions(database.type, settings.arch), + settings + } + }; + }), + status: privateProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => { + const id = input.id; + const teamId = ctx.user?.teamId; + + let isRunning = false; + const database = await prisma.database.findFirst({ + where: { id, teams: { some: { id: teamId === '0' ? undefined : teamId } } }, + include: { destinationDocker: true, settings: true } + }); + if (database) { + const { destinationDockerId, destinationDocker } = database; + if (destinationDockerId) { + try { + const { stdout } = await executeCommand({ + dockerId: destinationDocker.id, + command: `docker inspect --format '{{json .State}}' ${id}` + }); + + if (JSON.parse(stdout).Running) { + isRunning = true; + } + } catch (error) { + // + } + } + } + return { + success: true, + data: { + isRunning + } + }; + }), + cleanup: privateProcedure.query(async ({ ctx }) => { + const teamId = ctx.user?.teamId; + let databases = await prisma.database.findMany({ + where: { teams: { some: { id: teamId === '0' ? undefined : teamId } } }, + include: { settings: true, destinationDocker: true, teams: true } + }); + for (const database of databases) { + if (!database?.version) { + const { id } = database; + if (database.destinationDockerId) { + const everStarted = await stopDatabaseContainer(database); + if (everStarted) + await stopTcpHttpProxy(id, database.destinationDocker, database.publicPort); + } + await prisma.databaseSettings.deleteMany({ where: { databaseId: id } }); + await prisma.databaseSecret.deleteMany({ where: { databaseId: id } }); + await prisma.database.delete({ where: { id } }); + } + } + return {}; + }), + delete: privateProcedure + .input(z.object({ id: z.string(), force: z.boolean().default(false) })) + .mutation(async ({ ctx, input }) => { + const { id, force } = input; + const teamId = ctx.user?.teamId; + const database = await prisma.database.findFirst({ + where: { id, teams: { some: { id: teamId === '0' ? undefined : teamId } } }, + include: { destinationDocker: true, settings: true } + }); + if (!force) { + if (database.dbUserPassword) database.dbUserPassword = decrypt(database.dbUserPassword); + if (database.rootUserPassword) + database.rootUserPassword = decrypt(database.rootUserPassword); + if (database.destinationDockerId) { + const everStarted = await stopDatabaseContainer(database); + if (everStarted) + await stopTcpHttpProxy(id, database.destinationDocker, database.publicPort); + } + } + await prisma.databaseSettings.deleteMany({ where: { databaseId: id } }); + await prisma.databaseSecret.deleteMany({ where: { databaseId: id } }); + await prisma.database.delete({ where: { id } }); + return {}; + }) +}); diff --git a/apps/server/src/trpc/routers/databases/lib.ts b/apps/server/src/trpc/routers/databases/lib.ts new file mode 100644 index 000000000..a1a7cd647 --- /dev/null +++ b/apps/server/src/trpc/routers/databases/lib.ts @@ -0,0 +1,283 @@ +import { base64Encode, isARM, version } from "../../../lib/common"; +import { executeCommand } from "../../../lib/executeCommand"; +import { prisma } from "../../../prisma"; + +export const supportedDatabaseTypesAndVersions = [ + { + name: 'mongodb', + fancyName: 'MongoDB', + baseImage: 'bitnami/mongodb', + baseImageARM: 'mongo', + versions: ['5.0', '4.4', '4.2'], + versionsARM: ['5.0', '4.4', '4.2'] + }, + { + name: 'mysql', + fancyName: 'MySQL', + baseImage: 'bitnami/mysql', + baseImageARM: 'mysql', + versions: ['8.0', '5.7'], + versionsARM: ['8.0', '5.7'] + }, + { + name: 'mariadb', + fancyName: 'MariaDB', + baseImage: 'bitnami/mariadb', + baseImageARM: 'mariadb', + versions: ['10.8', '10.7', '10.6', '10.5', '10.4', '10.3', '10.2'], + versionsARM: ['10.8', '10.7', '10.6', '10.5', '10.4', '10.3', '10.2'] + }, + { + name: 'postgresql', + fancyName: 'PostgreSQL', + baseImage: 'bitnami/postgresql', + baseImageARM: 'postgres', + versions: ['14.5.0', '13.8.0', '12.12.0', '11.17.0', '10.22.0'], + versionsARM: ['14.5', '13.8', '12.12', '11.17', '10.22'] + }, + { + name: 'redis', + fancyName: 'Redis', + baseImage: 'bitnami/redis', + baseImageARM: 'redis', + versions: ['7.0', '6.2', '6.0', '5.0'], + versionsARM: ['7.0', '6.2', '6.0', '5.0'] + }, + { + name: 'couchdb', + fancyName: 'CouchDB', + baseImage: 'bitnami/couchdb', + baseImageARM: 'couchdb', + versions: ['3.2.2', '3.1.2', '2.3.1'], + versionsARM: ['3.2.2', '3.1.2', '2.3.1'] + }, + { + name: 'edgedb', + fancyName: 'EdgeDB', + baseImage: 'edgedb/edgedb', + versions: ['latest', '2.1', '2.0', '1.4'] + } +]; +export function getDatabaseImage(type: string, arch: string): string { + const found = supportedDatabaseTypesAndVersions.find((t) => t.name === type); + if (found) { + if (isARM(arch)) { + return found.baseImageARM || found.baseImage; + } + return found.baseImage; + } + return ''; +} +export function generateDatabaseConfiguration(database: any, arch: string) { + const { id, dbUser, dbUserPassword, rootUser, rootUserPassword, defaultDatabase, version, type } = + database; + const baseImage = getDatabaseImage(type, arch); + if (type === 'mysql') { + const configuration = { + privatePort: 3306, + environmentVariables: { + MYSQL_USER: dbUser, + MYSQL_PASSWORD: dbUserPassword, + MYSQL_ROOT_PASSWORD: rootUserPassword, + MYSQL_ROOT_USER: rootUser, + MYSQL_DATABASE: defaultDatabase + }, + image: `${baseImage}:${version}`, + volume: `${id}-${type}-data:/bitnami/mysql/data`, + ulimits: {} + }; + if (isARM(arch)) { + configuration.volume = `${id}-${type}-data:/var/lib/mysql`; + } + return configuration; + } else if (type === 'mariadb') { + const configuration = { + privatePort: 3306, + environmentVariables: { + MARIADB_ROOT_USER: rootUser, + MARIADB_ROOT_PASSWORD: rootUserPassword, + MARIADB_USER: dbUser, + MARIADB_PASSWORD: dbUserPassword, + MARIADB_DATABASE: defaultDatabase + }, + image: `${baseImage}:${version}`, + volume: `${id}-${type}-data:/bitnami/mariadb`, + ulimits: {} + }; + if (isARM(arch)) { + configuration.volume = `${id}-${type}-data:/var/lib/mysql`; + } + return configuration; + } else if (type === 'mongodb') { + const configuration = { + privatePort: 27017, + environmentVariables: { + MONGODB_ROOT_USER: rootUser, + MONGODB_ROOT_PASSWORD: rootUserPassword + }, + image: `${baseImage}:${version}`, + volume: `${id}-${type}-data:/bitnami/mongodb`, + ulimits: {} + }; + if (isARM(arch)) { + configuration.environmentVariables = { + MONGO_INITDB_ROOT_USERNAME: rootUser, + MONGO_INITDB_ROOT_PASSWORD: rootUserPassword + }; + configuration.volume = `${id}-${type}-data:/data/db`; + } + return configuration; + } else if (type === 'postgresql') { + const configuration = { + privatePort: 5432, + environmentVariables: { + POSTGRESQL_POSTGRES_PASSWORD: rootUserPassword, + POSTGRESQL_PASSWORD: dbUserPassword, + POSTGRESQL_USERNAME: dbUser, + POSTGRESQL_DATABASE: defaultDatabase + }, + image: `${baseImage}:${version}`, + volume: `${id}-${type}-data:/bitnami/postgresql`, + ulimits: {} + }; + if (isARM(arch)) { + configuration.volume = `${id}-${type}-data:/var/lib/postgresql`; + configuration.environmentVariables = { + POSTGRES_PASSWORD: dbUserPassword, + POSTGRES_USER: dbUser, + POSTGRES_DB: defaultDatabase + }; + } + return configuration; + } else if (type === 'redis') { + const { + settings: { appendOnly } + } = database; + const configuration = { + privatePort: 6379, + command: undefined, + environmentVariables: { + REDIS_PASSWORD: dbUserPassword, + REDIS_AOF_ENABLED: appendOnly ? 'yes' : 'no' + }, + image: `${baseImage}:${version}`, + volume: `${id}-${type}-data:/bitnami/redis/data`, + ulimits: {} + }; + if (isARM(arch)) { + configuration.volume = `${id}-${type}-data:/data`; + configuration.command = `/usr/local/bin/redis-server --appendonly ${ + appendOnly ? 'yes' : 'no' + } --requirepass ${dbUserPassword}`; + } + return configuration; + } else if (type === 'couchdb') { + const configuration = { + privatePort: 5984, + environmentVariables: { + COUCHDB_PASSWORD: dbUserPassword, + COUCHDB_USER: dbUser + }, + image: `${baseImage}:${version}`, + volume: `${id}-${type}-data:/bitnami/couchdb`, + ulimits: {} + }; + if (isARM(arch)) { + configuration.volume = `${id}-${type}-data:/opt/couchdb/data`; + } + return configuration; + } else if (type === 'edgedb') { + const configuration = { + privatePort: 5656, + environmentVariables: { + EDGEDB_SERVER_PASSWORD: rootUserPassword, + EDGEDB_SERVER_USER: rootUser, + EDGEDB_SERVER_DATABASE: defaultDatabase, + EDGEDB_SERVER_TLS_CERT_MODE: 'generate_self_signed' + }, + image: `${baseImage}:${version}`, + volume: `${id}-${type}-data:/var/lib/edgedb/data`, + ulimits: {} + }; + return configuration; + } + return null; +} +export function getDatabaseVersions(type: string, arch: string): string[] { + const found = supportedDatabaseTypesAndVersions.find((t) => t.name === type); + if (found) { + if (isARM(arch)) { + return found.versionsARM || found.versions; + } + return found.versions; + } + return []; +} +export async function updatePasswordInDb(database, user, newPassword, isRoot) { + const { + id, + type, + rootUser, + rootUserPassword, + dbUser, + dbUserPassword, + defaultDatabase, + destinationDockerId, + destinationDocker: { id: dockerId } + } = database; + if (destinationDockerId) { + if (type === 'mysql') { + await executeCommand({ + dockerId, + command: `docker exec ${id} mysql -u ${rootUser} -p${rootUserPassword} -e \"ALTER USER '${user}'@'%' IDENTIFIED WITH caching_sha2_password BY '${newPassword}';\"` + }); + } else if (type === 'mariadb') { + await executeCommand({ + dockerId, + command: `docker exec ${id} mysql -u ${rootUser} -p${rootUserPassword} -e \"SET PASSWORD FOR '${user}'@'%' = PASSWORD('${newPassword}');\"` + }); + } else if (type === 'postgresql') { + if (isRoot) { + await executeCommand({ + dockerId, + command: `docker exec ${id} psql postgresql://postgres:${rootUserPassword}@${id}:5432/${defaultDatabase} -c "ALTER role postgres WITH PASSWORD '${newPassword}'"` + }); + } else { + await executeCommand({ + dockerId, + command: `docker exec ${id} psql postgresql://${dbUser}:${dbUserPassword}@${id}:5432/${defaultDatabase} -c "ALTER role ${user} WITH PASSWORD '${newPassword}'"` + }); + } + } else if (type === 'mongodb') { + await executeCommand({ + dockerId, + command: `docker exec ${id} mongo 'mongodb://${rootUser}:${rootUserPassword}@${id}:27017/admin?readPreference=primary&ssl=false' --eval "db.changeUserPassword('${user}','${newPassword}')"` + }); + } else if (type === 'redis') { + await executeCommand({ + dockerId, + command: `docker exec ${id} redis-cli -u redis://${dbUserPassword}@${id}:6379 --raw CONFIG SET requirepass ${newPassword}` + }); + } + } +} +export async function makeLabelForStandaloneDatabase({ id, image, volume }) { + const database = await prisma.database.findFirst({ where: { id } }); + delete database.destinationDockerId; + delete database.createdAt; + delete database.updatedAt; + return [ + 'coolify.managed=true', + `coolify.version=${version}`, + `coolify.type=standalone-database`, + `coolify.name=${database.name}`, + `coolify.configuration=${base64Encode( + JSON.stringify({ + version, + image, + volume, + ...database + }) + )}` + ]; +} \ No newline at end of file