diff --git a/apps/api/prisma/migrations/20230307101148_add_host_volumes/migration.sql b/apps/api/prisma/migrations/20230307101148_add_host_volumes/migration.sql new file mode 100644 index 000000000..220ad995c --- /dev/null +++ b/apps/api/prisma/migrations/20230307101148_add_host_volumes/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "ApplicationPersistentStorage" ADD COLUMN "hostPath" TEXT; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index bb76d83b0..7dca8314b 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -195,6 +195,7 @@ model ApplicationSettings { model ApplicationPersistentStorage { id String @id @default(cuid()) applicationId String + hostPath String? path String oldPath Boolean @default(false) createdAt DateTime @default(now()) diff --git a/apps/api/src/jobs/deployApplication.ts b/apps/api/src/jobs/deployApplication.ts index 00805b832..5c35307b9 100644 --- a/apps/api/src/jobs/deployApplication.ts +++ b/apps/api/src/jobs/deployApplication.ts @@ -110,6 +110,9 @@ import * as buildpacks from '../lib/buildPacks'; .replace(/\//gi, '-') .replace('-app', '')}:${storage.path}`; } + if (storage.hostPath) { + return `${storage.hostPath}:${storage.path}` + } return `${applicationId}${storage.path.replace(/\//gi, '-')}:${storage.path}`; }) || []; @@ -160,7 +163,11 @@ import * as buildpacks from '../lib/buildPacks'; port: exposePort ? `${exposePort}:${port}` : port }); try { - const composeVolumes = volumes.map((volume) => { + const composeVolumes = volumes.filter(v => { + if (!v.startsWith('.') && !v.startsWith('..') && !v.startsWith('/') && !v.startsWith('~')) { + return v; + } + }).map((volume) => { return { [`${volume.split(':')[0]}`]: { name: volume.split(':')[0] @@ -381,6 +388,9 @@ import * as buildpacks from '../lib/buildPacks'; .replace(/\//gi, '-') .replace('-app', '')}:${storage.path}`; } + if (storage.hostPath) { + return `${storage.hostPath}:${storage.path}` + } return `${applicationId}${storage.path.replace(/\//gi, '-')}:${storage.path}`; }) || []; @@ -691,7 +701,11 @@ import * as buildpacks from '../lib/buildPacks'; await saveDockerRegistryCredentials({ url, username, password, workdir }); } try { - const composeVolumes = volumes.map((volume) => { + const composeVolumes = volumes.filter(v => { + if (!v.startsWith('.') && !v.startsWith('..') && !v.startsWith('/') && !v.startsWith('~')) { + return v; + } + }).map((volume) => { return { [`${volume.split(':')[0]}`]: { name: volume.split(':')[0] diff --git a/apps/api/src/lib/buildPacks/compose.ts b/apps/api/src/lib/buildPacks/compose.ts index 439462231..839f3e3ae 100644 --- a/apps/api/src/lib/buildPacks/compose.ts +++ b/apps/api/src/lib/buildPacks/compose.ts @@ -36,12 +36,13 @@ export default async function (data) { if (volumes.length > 0) { for (const volume of volumes) { let [v, path] = volume.split(':'); - composeVolumes[v] = { - name: v - }; + if (!v.startsWith('.') && !v.startsWith('..') && !v.startsWith('/') && !v.startsWith('~')) { + composeVolumes[v] = { + name: v + }; + } } } - let networks = {}; for (let [key, value] of Object.entries(dockerComposeYaml.services)) { value['container_name'] = `${applicationId}-${key}`; @@ -77,17 +78,54 @@ export default async function (data) { // TODO: If we support separated volume for each service, we need to add it here if (value['volumes']?.length > 0) { value['volumes'] = value['volumes'].map((volume) => { - let [v, path, permission] = volume.split(':'); - if (!path) { - path = v; - v = `${applicationId}${v.replace(/\//gi, '-').replace(/\./gi, '')}`; - } else { - v = `${applicationId}${v.replace(/\//gi, '-').replace(/\./gi, '')}`; + if (typeof volume === 'string') { + let [v, path, permission] = volume.split(':'); + if ( + v.startsWith('.') || + v.startsWith('..') || + v.startsWith('/') || + v.startsWith('~') || + v.startsWith('$PWD') + ) { + v = v.replace(/^\./, `~`).replace(/^\.\./, '~').replace(/^\$PWD/, '~'); + } else { + if (!path) { + path = v; + v = `${applicationId}${v.replace(/\//gi, '-').replace(/\./gi, '')}`; + } else { + v = `${applicationId}${v.replace(/\//gi, '-').replace(/\./gi, '')}`; + } + composeVolumes[v] = { + name: v + }; + } + return `${v}:${path}${permission ? ':' + permission : ''}`; } - composeVolumes[v] = { - name: v - }; - return `${v}:${path}${permission ? ':' + permission : ''}`; + if (typeof volume === 'object') { + let { source, target, mode } = volume; + if ( + source.startsWith('.') || + source.startsWith('..') || + source.startsWith('/') || + source.startsWith('~') || + source.startsWith('$PWD') + ) { + + source = source.replace(/^\./, `~`).replace(/^\.\./, '~').replace(/^\$PWD/, '~'); + console.log({source}) + + } else { + if (!target) { + target = source; + source = `${applicationId}${source.replace(/\//gi, '-').replace(/\./gi, '')}`; + } else { + source = `${applicationId}${source.replace(/\//gi, '-').replace(/\./gi, '')}`; + } + } + + return `${source}:${target}${mode ? ':' + mode : ''}`; + } + }); } if (volumes.length > 0) { diff --git a/apps/api/src/lib/common.ts b/apps/api/src/lib/common.ts index 0014f793e..322bb3bd8 100644 --- a/apps/api/src/lib/common.ts +++ b/apps/api/src/lib/common.ts @@ -11,7 +11,7 @@ import { promises as dns } from 'dns'; import * as Sentry from '@sentry/node'; import { PrismaClient } from '@prisma/client'; import os from 'os'; -import sshConfig from 'ssh-config'; +import * as SSHConfig from 'ssh-config/src/ssh-config'; import jsonwebtoken from 'jsonwebtoken'; import { checkContainer, removeContainer } from './docker'; import { day } from './dayjs'; @@ -19,7 +19,7 @@ import { saveBuildLog, saveDockerRegistryCredentials } from './buildPacks/common import { scheduler } from './scheduler'; import type { ExecaChildProcess } from 'execa'; -export const version = '3.12.25'; +export const version = '3.12.26'; export const isDev = process.env.NODE_ENV === 'development'; export const proxyPort = process.env.COOLIFY_PROXY_PORT; export const proxySecurePort = process.env.COOLIFY_PROXY_SECURE_PORT; @@ -498,33 +498,56 @@ export async function getFreeSSHLocalPort(id: string): Promise return false; } +/** + * Update the ssh config file with a host + * + * @param id Destination ID + * @returns + */ export async function createRemoteEngineConfiguration(id: string) { - const homedir = os.homedir(); const sshKeyFile = `/tmp/id_rsa-${id}`; const localPort = await getFreeSSHLocalPort(id); const { sshKey: { privateKey }, - network, remoteIpAddress, remotePort, remoteUser } = await prisma.destinationDocker.findFirst({ where: { id }, include: { sshKey: true } }); + + // Write new keyfile await fs.writeFile(sshKeyFile, decrypt(privateKey) + '\n', { encoding: 'utf8', mode: 400 }); - const config = sshConfig.parse(''); + const Host = `${remoteIpAddress}-remote`; + // Removes previous ssh-keys try { await executeCommand({ command: `ssh-keygen -R ${Host}` }); await executeCommand({ command: `ssh-keygen -R ${remoteIpAddress}` }); await executeCommand({ command: `ssh-keygen -R localhost:${localPort}` }); - } catch (error) { } + } catch (error) { + // + } + const homedir = os.homedir(); + let currentConfigFileContent = ''; + try { + // Read the current config file + currentConfigFileContent = (await fs.readFile(`${homedir}/.ssh/config`)).toString(); + } catch (error) { + // File doesn't exist, so we do nothing, a new one is going to be created + } + + // Parse the config file + const config = SSHConfig.parse(currentConfigFileContent); + + // Remove current config for the given host const found = config.find({ Host }); const foundIp = config.find({ Host: remoteIpAddress }); if (found) config.remove({ Host }); if (foundIp) config.remove({ Host: remoteIpAddress }); + // Create the new config config.append({ Host, Hostname: remoteIpAddress, @@ -537,13 +560,17 @@ export async function createRemoteEngineConfiguration(id: string) { ControlPersist: '10m' }); + // Check if .ssh folder exists, and if not create one try { await fs.stat(`${homedir}/.ssh/`); } catch (error) { await fs.mkdir(`${homedir}/.ssh/`); } - return await fs.writeFile(`${homedir}/.ssh/config`, sshConfig.stringify(config)); + + // Write the config + return await fs.writeFile(`${homedir}/.ssh/config`, SSHConfig.stringify(config)); } + export async function executeCommand({ command, dockerId = null, @@ -1633,6 +1660,9 @@ export function errorHandler({ type?: string | null; }) { if (message.message) message = message.message; + if (message.includes('Unique constraint failed')) { + message = 'This data is unique and already exists. Please try again with a different value.'; + } if (type === 'normal') { Sentry.captureException(message); } diff --git a/apps/api/src/routes/api/v1/applications/handlers.ts b/apps/api/src/routes/api/v1/applications/handlers.ts index 886b7bc88..8621a93ca 100644 --- a/apps/api/src/routes/api/v1/applications/handlers.ts +++ b/apps/api/src/routes/api/v1/applications/handlers.ts @@ -1340,16 +1340,16 @@ export async function getStorages(request: FastifyRequest) { export async function saveStorage(request: FastifyRequest, reply: FastifyReply) { try { const { id } = request.params; - const { path, newStorage, storageId } = request.body; + const { hostPath, path, newStorage, storageId } = request.body; if (newStorage) { await prisma.applicationPersistentStorage.create({ - data: { path, application: { connect: { id } } } + data: { hostPath, path, application: { connect: { id } } } }); } else { await prisma.applicationPersistentStorage.update({ where: { id: storageId }, - data: { path } + data: { hostPath, path } }); } return reply.code(201).send(); diff --git a/apps/api/src/routes/api/v1/applications/types.ts b/apps/api/src/routes/api/v1/applications/types.ts index 517194bd6..1c42f468a 100644 --- a/apps/api/src/routes/api/v1/applications/types.ts +++ b/apps/api/src/routes/api/v1/applications/types.ts @@ -96,6 +96,7 @@ export interface DeleteSecret extends OnlyId { } export interface SaveStorage extends OnlyId { Body: { + hostPath?: string; path: string; newStorage: boolean; storageId: string; diff --git a/apps/ui/src/routes/applications/[id]/_Storage.svelte b/apps/ui/src/routes/applications/[id]/_Storage.svelte index 0e0e13b8b..6eeb2c66b 100644 --- a/apps/ui/src/routes/applications/[id]/_Storage.svelte +++ b/apps/ui/src/routes/applications/[id]/_Storage.svelte @@ -12,6 +12,7 @@ import { errorNotification } from '$lib/common'; import { addToast } from '$lib/store'; import CopyVolumeField from '$lib/components/CopyVolumeField.svelte'; + import SimpleExplainer from '$lib/components/SimpleExplainer.svelte'; const { id } = $page.params; let isHttps = browser && window.location.protocol === 'https:'; export let value: string; @@ -33,11 +34,13 @@ storage.path.replace(/\/\//g, '/'); await post(`/applications/${id}/storages`, { path: storage.path, + hostPath: storage.hostPath, storageId: storage.id, newStorage }); dispatch('refresh'); if (isNew) { + storage.hostPath = null; storage.path = null; storage.id = null; } @@ -80,27 +83,42 @@
{#if storage.applicationId} {#if storage.oldPath} - - + {:else if !storage.hostPath} + - {:else} - - {/if} {/if} + + {#if isNew} +
+ + + +
+ {:else if storage.hostPath} + + {/if} -
+
{#if isNew}
{:else if $isDeploymentEnabled && !$page.url.pathname.startsWith(`/applications/${id}/configuration/`)} - {#if $status.application.overallStatus === 'degraded'} - - {/if} + {/if} {/if} {#if $location && $status.application.overallStatus === 'healthy'} 0}> - Add New Volume -
- - +
0}> + Add New Volume +
+ + {/if}
diff --git a/package.json b/package.json index 2c79da20b..e518e9dda 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "coolify", "description": "An open-source & self-hostable Heroku / Netlify alternative.", - "version": "3.12.25", + "version": "3.12.26", "license": "Apache-2.0", "repository": "github:coollabsio/coolify", "scripts": {