diff --git a/apps/api/src/lib/common.ts b/apps/api/src/lib/common.ts index 8876ac38c..453036788 100644 --- a/apps/api/src/lib/common.ts +++ b/apps/api/src/lib/common.ts @@ -1,6 +1,5 @@ -import { exec } from 'node:child_process'; -import util from 'util'; import fs from 'fs/promises'; +import fsNormal from 'fs'; import yaml from 'js-yaml'; import forge from 'node-forge'; import { uniqueNamesGenerator, adjectives, colors, animals } from 'unique-names-generator'; @@ -17,6 +16,7 @@ import { day } from './dayjs'; import { saveBuildLog } from './buildPacks/common'; import { scheduler } from './scheduler'; import type { ExecaChildProcess } from 'execa'; +import { FastifyReply } from 'fastify'; export const version = '3.12.34'; export const isDev = process.env.NODE_ENV === 'development'; @@ -1942,3 +1942,51 @@ export function generateSecrets( } return envs; } + +export async function backupPostgresqlDatabase(database, reply) { + const backupFolder = '/tmp' + const fileName = `${database.id}-${new Date().getTime()}.gz` + const backupFileName = `${backupFolder}/${fileName}` + console.log({ database }) + let command = null + switch (database?.type) { + case 'postgresql': + command = `docker exec ${database.id} sh -c "PGPASSWORD=${database.rootUserPassword} pg_dumpall -U postgres | gzip > ${backupFileName}"` + break; + case 'mongodb': + command = `docker exec ${database.id} sh -c "mongodump --archive=${backupFileName} --gzip --username=${database.rootUser} --password=${database.rootUserPassword}"` + break; + case 'mysql': + command = `docker exec ${database.id} sh -c "mysqldump --all-databases --single-transaction --quick --lock-tables=false --user=${database.rootUser} --password=${database.rootUserPassword} | gzip > ${backupFileName}"` + break; + case 'mariadb': + command = `docker exec ${database.id} sh -c "mysqldump --all-databases --single-transaction --quick --lock-tables=false --user=${database.rootUser} --password=${database.rootUserPassword} | gzip > ${backupFileName}"` + break; + case 'couchdb': + command = `docker exec ${database.id} sh -c "tar -czvf ${backupFileName} /bitnami/couchdb/data"` + break; + default: + return; + } + await executeCommand({ + dockerId: database.destinationDockerId, + command, + }); + const copyCommand = `docker cp ${database.id}:${backupFileName} ${backupFileName}` + await executeCommand({ + dockerId: database.destinationDockerId, + command: copyCommand + }); + if (isDev) { + await executeCommand({ + dockerId: database.destinationDockerId, + command: `docker cp ${database.id}:${backupFileName} /app/backups/` + }); + } + const stream = fsNormal.createReadStream(backupFileName); + reply.header('Content-Type', 'application/octet-stream'); + reply.header('Content-Disposition', `attachment; filename=${fileName}`); + reply.header('Content-Length', fsNormal.statSync(backupFileName).size); + reply.header('Content-Transfer-Encoding', 'binary'); + return reply.send(stream) +} diff --git a/apps/api/src/routes/api/v1/databases/handlers.ts b/apps/api/src/routes/api/v1/databases/handlers.ts index dc9a6e9b4..126e34a93 100644 --- a/apps/api/src/routes/api/v1/databases/handlers.ts +++ b/apps/api/src/routes/api/v1/databases/handlers.ts @@ -5,6 +5,7 @@ import yaml from 'js-yaml'; import fs from 'fs/promises'; import { ComposeFile, + backupPostgresqlDatabase, createDirectories, decrypt, defaultComposeConfiguration, @@ -351,6 +352,21 @@ export async function startDatabase(request: FastifyRequest) { return errorHandler({ status, message }); } } +export async function backupDatabase(request: FastifyRequest, reply: FastifyReply) { + try { + const teamId = request.user.teamId; + const { id } = request.params; + 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); + return await backupPostgresqlDatabase(database, reply); + } catch ({ status, message }) { + return errorHandler({ status, message }); + } +} export async function stopDatabase(request: FastifyRequest) { try { const teamId = request.user.teamId; diff --git a/apps/api/src/routes/api/v1/databases/index.ts b/apps/api/src/routes/api/v1/databases/index.ts index 65f0d58f4..8a225e67e 100644 --- a/apps/api/src/routes/api/v1/databases/index.ts +++ b/apps/api/src/routes/api/v1/databases/index.ts @@ -1,5 +1,5 @@ import { FastifyPluginAsync } from 'fastify'; -import { cleanupUnconfiguredDatabases, deleteDatabase, deleteDatabaseSecret, getDatabase, getDatabaseLogs, getDatabaseSecrets, getDatabaseStatus, getDatabaseTypes, getDatabaseUsage, getVersions, listDatabases, newDatabase, saveDatabase, saveDatabaseDestination, saveDatabaseSecret, saveDatabaseSettings, saveDatabaseType, saveVersion, startDatabase, stopDatabase } from './handlers'; +import { backupDatabase, cleanupUnconfiguredDatabases, deleteDatabase, deleteDatabaseSecret, getDatabase, getDatabaseLogs, getDatabaseSecrets, getDatabaseStatus, getDatabaseTypes, getDatabaseUsage, getVersions, listDatabases, newDatabase, saveDatabase, saveDatabaseDestination, saveDatabaseSecret, saveDatabaseSettings, saveDatabaseType, saveVersion, startDatabase, stopDatabase } from './handlers'; import type { OnlyId } from '../../../../types'; @@ -39,6 +39,7 @@ const root: FastifyPluginAsync = async (fastify): Promise => { fastify.post('/:id/start', async (request) => await startDatabase(request)); fastify.post('/:id/stop', async (request) => await stopDatabase(request)); + fastify.post('/:id/backup', async (request, reply) => await backupDatabase(request, reply)); }; export default root; diff --git a/apps/ui/src/lib/api.ts b/apps/ui/src/lib/api.ts index 058ec083f..9373641fe 100644 --- a/apps/ui/src/lib/api.ts +++ b/apps/ui/src/lib/api.ts @@ -1,5 +1,6 @@ import { dev } from '$app/env'; import Cookies from 'js-cookie'; +import { dashify } from './common'; export function getAPIUrl() { if (GITPOD_WORKSPACE_URL) { @@ -100,6 +101,14 @@ async function send({ responseData = await response.json(); } else if (contentType?.indexOf('text/plain') !== -1) { responseData = await response.text(); + } else if (contentType?.indexOf('application/octet-stream') !== -1) { + responseData = await response.blob(); + const fileName = dashify(data.id + '-' + data.name) + const fileLink = document.createElement('a'); + fileLink.href = URL.createObjectURL(new Blob([responseData])) + fileLink.download = fileName + '.gz'; + fileLink.click(); + fileLink.remove(); } else { return {}; } diff --git a/apps/ui/src/routes/databases/[id]/_Databases/_Databases.svelte b/apps/ui/src/routes/databases/[id]/_Databases/_Databases.svelte index 00b679eb9..6eddda7a1 100644 --- a/apps/ui/src/routes/databases/[id]/_Databases/_Databases.svelte +++ b/apps/ui/src/routes/databases/[id]/_Databases/_Databases.svelte @@ -13,17 +13,19 @@ import Redis from './_Redis.svelte'; import CouchDb from './_CouchDb.svelte'; import EdgeDB from './_EdgeDB.svelte'; - import { post } from '$lib/api'; + import { get, post } from '$lib/api'; import { t } from '$lib/translations'; import { errorNotification } from '$lib/common'; import { addToast, appSession, status } from '$lib/store'; import Explainer from '$lib/components/Explainer.svelte'; + import Tooltip from '$lib/components/Tooltip.svelte'; const { id } = $page.params; let loading = { main: false, - public: false + public: false, + backup: false }; let publicUrl = ''; let appendOnly = database.settings.appendOnly; @@ -131,6 +133,22 @@ loading.main = false; } } + async function backupDatabase() { + try { + loading.backup = true; + addToast({ + message: + 'Backup will be downloaded soon and saved to /var/lib/docker/volumes/coolify-local-backup/ on the host system.', + type: 'success', + timeout: 15000 + }); + return await post(`/databases/${id}/backup`, { id, name: database.name }); + } catch (error) { + return errorNotification(error); + } finally { + loading.backup = false; + } + }
@@ -145,6 +163,19 @@ class:bg-databases={!loading.main} disabled={loading.main}>{$t('forms.save')} + {#if database.type !== 'redis' && database.type !== 'edgedb'} + {#if $status.database.isRunning} + + {:else} + + {/if} + {/if} {/if}