WIP: Remote docker engine

This commit is contained in:
Andras Bacsai 2022-02-26 15:08:26 +01:00
parent c9b52f1310
commit e0e50b4bd5
12 changed files with 456 additions and 60 deletions

View File

@ -147,8 +147,12 @@ model DestinationDocker {
id String @id @default(cuid()) id String @id @default(cuid())
network String @unique network String @unique
name String name String
engine String engine String?
remoteEngine Boolean @default(false) remoteEngine Boolean @default(false)
ipAddress String?
sshPrivateKey String?
user String? @default("root")
port Int? @default(22)
isCoolifyProxyUsed Boolean? @default(false) isCoolifyProxyUsed Boolean? @default(false)
teams Team[] teams Team[]
application Application[] application Application[]

View File

@ -103,7 +103,7 @@ export const getUserDetails = async (event, isAdminRequired = true) => {
}; };
export function getEngine(engine) { export function getEngine(engine) {
return engine === '/var/run/docker.sock' ? 'unix:///var/run/docker.sock' : `tcp://${engine}:2375`; return engine === '/var/run/docker.sock' ? 'unix:///var/run/docker.sock' : engine;
} }
export async function removeContainer(id, engine) { export async function removeContainer(id, engine) {

View File

@ -15,3 +15,6 @@ export const notNodeDeployments = ['php', 'docker', 'rust'];
export function getDomain(domain) { export function getDomain(domain) {
return domain?.replace('https://', '').replace('http://', ''); return domain?.replace('https://', '').replace('http://', '');
} }
export function generateRemoteEngine(destination) {
return `ssh://${destination.user}@${destination.ipAddress}:${destination.port}`;
}

View File

@ -1,4 +1,5 @@
import { asyncExecShell, getEngine } from '$lib/common'; import { asyncExecShell, getEngine } from '$lib/common';
import { decrypt, encrypt } from '$lib/crypto';
import { dockerInstance } from '$lib/docker'; import { dockerInstance } from '$lib/docker';
import { startCoolifyProxy } from '$lib/haproxy'; import { startCoolifyProxy } from '$lib/haproxy';
import { getDatabaseImage } from '.'; import { getDatabaseImage } from '.';
@ -47,7 +48,36 @@ export async function updateDestination({ id, name, engine, network }) {
return await prisma.destinationDocker.update({ where: { id }, data: { name, engine, network } }); return await prisma.destinationDocker.update({ where: { id }, data: { name, engine, network } });
} }
export async function newDestination({ name, teamId, engine, network, isCoolifyProxyUsed }) { export async function newRemoteDestination({
name,
teamId,
engine,
network,
isCoolifyProxyUsed,
remoteEngine,
ipAddress,
user,
port,
sshPrivateKey
}) {
const encryptedPrivateKey = encrypt(sshPrivateKey);
const destination = await prisma.destinationDocker.create({
data: {
name,
teams: { connect: { id: teamId } },
engine,
network,
isCoolifyProxyUsed,
remoteEngine,
ipAddress,
user,
port,
sshPrivateKey: encryptedPrivateKey
}
});
return destination.id;
}
export async function newLocalDestination({ name, teamId, engine, network, isCoolifyProxyUsed }) {
const host = getEngine(engine); const host = getEngine(engine);
const docker = dockerInstance({ destinationDocker: { engine, network } }); const docker = dockerInstance({ destinationDocker: { engine, network } });
const found = await docker.engine.listNetworks({ filters: { name: [`^${network}$`] } }); const found = await docker.engine.listNetworks({ filters: { name: [`^${network}$`] } });
@ -94,9 +124,13 @@ export async function removeDestination({ id }) {
} }
export async function getDestination({ id, teamId }) { export async function getDestination({ id, teamId }) {
return await prisma.destinationDocker.findFirst({ let destination = await prisma.destinationDocker.findFirst({
where: { id, teams: { some: { id: teamId } } } where: { id, teams: { some: { id: teamId } } }
}); });
if (destination.remoteEngine) {
destination.sshPrivateKey = decrypt(destination.sshPrivateKey);
}
return destination;
} }
export async function getDestinationByApplicationId({ id, teamId }) { export async function getDestinationByApplicationId({ id, teamId }) {
return await prisma.destinationDocker.findFirst({ return await prisma.destinationDocker.findFirst({

View File

@ -4,7 +4,7 @@
export let state; export let state;
import { toast } from '@zerodevx/svelte-toast'; import { toast } from '@zerodevx/svelte-toast';
import { page } from '$app/stores'; import { page, session } from '$app/stores';
import Setting from '$lib/components/Setting.svelte'; import Setting from '$lib/components/Setting.svelte';
import { errorNotification } from '$lib/form'; import { errorNotification } from '$lib/form';
import { post } from '$lib/api'; import { post } from '$lib/api';
@ -125,6 +125,7 @@
<form on:submit|preventDefault={handleSubmit} class="grid grid-flow-row gap-2 py-4"> <form on:submit|preventDefault={handleSubmit} class="grid grid-flow-row gap-2 py-4">
<div class="flex space-x-1 pb-5"> <div class="flex space-x-1 pb-5">
<div class="title font-bold">Configuration</div> <div class="title font-bold">Configuration</div>
{#if $session.isAdmin}
<button <button
type="submit" type="submit"
class="bg-sky-600 hover:bg-sky-500" class="bg-sky-600 hover:bg-sky-500"
@ -139,13 +140,20 @@
on:click|preventDefault={forceRestartProxy} on:click|preventDefault={forceRestartProxy}
>{restarting ? 'Restarting... please wait...' : 'Force restart proxy'}</button >{restarting ? 'Restarting... please wait...' : 'Force restart proxy'}</button
> >
{/if}
<!-- <button type="button" class="bg-coollabs hover:bg-coollabs-100" on:click={scanApps} <!-- <button type="button" class="bg-coollabs hover:bg-coollabs-100" on:click={scanApps}
>Scan for applications</button >Scan for applications</button
> --> > -->
</div> </div>
<div class="grid grid-cols-2 items-center px-10 "> <div class="grid grid-cols-2 items-center px-10 ">
<label for="name" class="text-base font-bold text-stone-100">Name</label> <label for="name" class="text-base font-bold text-stone-100">Name</label>
<input name="name" placeholder="name" bind:value={destination.name} /> <input
name="name"
placeholder="name"
disabled={!$session.isAdmin}
readonly={!$session.isAdmin}
bind:value={destination.name}
/>
</div> </div>
<div class="grid grid-cols-2 items-center px-10"> <div class="grid grid-cols-2 items-center px-10">

View File

@ -0,0 +1,225 @@
<script lang="ts">
export let destination;
export let settings;
export let state;
import { toast } from '@zerodevx/svelte-toast';
import { page, session } from '$app/stores';
import Setting from '$lib/components/Setting.svelte';
import { errorNotification } from '$lib/form';
import { post } from '$lib/api';
import CopyPasswordField from '$lib/components/CopyPasswordField.svelte';
import { onMount } from 'svelte';
import { generateRemoteEngine } from '$lib/components/common';
const { id } = $page.params;
let cannotDisable = settings.fqdn && destination.engine === '/var/run/docker.sock';
// let scannedApps = [];
let loading = false;
let restarting = false;
async function handleSubmit() {
loading = true;
try {
return await post(`/destinations/${id}.json`, { ...destination });
} catch ({ error }) {
return errorNotification(error);
} finally {
loading = false;
}
}
// async function scanApps() {
// scannedApps = [];
// const data = await fetch(`/destinations/${id}/scan.json`);
// const { containers } = await data.json();
// scannedApps = containers;
// }
onMount(async () => {
if (state === false && destination.isCoolifyProxyUsed === true) {
destination.isCoolifyProxyUsed = !destination.isCoolifyProxyUsed;
try {
await post(`/destinations/${id}/settings.json`, {
isCoolifyProxyUsed: destination.isCoolifyProxyUsed,
engine: destination.engine
});
await stopProxy();
} catch ({ error }) {
return errorNotification(error);
}
} else if (state === true && destination.isCoolifyProxyUsed === false) {
destination.isCoolifyProxyUsed = !destination.isCoolifyProxyUsed;
try {
await post(`/destinations/${id}/settings.json`, {
isCoolifyProxyUsed: destination.isCoolifyProxyUsed,
engine: destination.engine
});
await startProxy();
} catch ({ error }) {
return errorNotification(error);
}
}
});
async function changeProxySetting() {
if (!cannotDisable) {
const isProxyActivated = destination.isCoolifyProxyUsed;
if (isProxyActivated) {
const sure = confirm(
`Are you sure you want to ${
destination.isCoolifyProxyUsed ? 'disable' : 'enable'
} Coolify proxy? It will remove the proxy for all configured networks and all deployments on '${
destination.engine
}'! Nothing will be reachable if you do it!`
);
if (!sure) return;
}
destination.isCoolifyProxyUsed = !destination.isCoolifyProxyUsed;
try {
await post(`/destinations/${id}/settings.json`, {
isCoolifyProxyUsed: destination.isCoolifyProxyUsed,
engine: destination.engine
});
if (isProxyActivated) {
await stopProxy();
} else {
await startProxy();
}
} catch ({ error }) {
return errorNotification(error);
}
}
}
async function stopProxy() {
try {
const engine = generateRemoteEngine(destination);
await post(`/destinations/${id}/stop.json`, { engine });
return toast.push('Coolify Proxy stopped!');
} catch ({ error }) {
return errorNotification(error);
}
}
async function startProxy() {
try {
const engine = generateRemoteEngine(destination);
await post(`/destinations/${id}/start.json`, { engine });
return toast.push('Coolify Proxy started!');
} catch ({ error }) {
return errorNotification(error);
}
}
async function forceRestartProxy() {
const sure = confirm(
'Are you sure you want to restart the proxy? Everyting will be reconfigured in ~10 sec.'
);
if (sure) {
try {
restarting = true;
toast.push('Coolify Proxy restarting...');
await post(`/destinations/${id}/restart.json`, {
engine: destination.engine,
fqdn: settings.fqdn
});
} catch ({ error }) {
setTimeout(() => {
window.location.reload();
}, 5000);
}
}
}
</script>
<form on:submit|preventDefault={handleSubmit} class="grid grid-flow-row gap-2 py-4">
<div class="flex space-x-1 pb-5">
<div class="title font-bold">Configuration</div>
{#if $session.isAdmin}
<button
type="submit"
class="bg-sky-600 hover:bg-sky-500"
class:bg-sky-600={!loading}
class:hover:bg-sky-500={!loading}
disabled={loading}
>{loading ? 'Saving...' : 'Save'}
</button>
<button
class={restarting ? '' : 'bg-red-600 hover:bg-red-500'}
disabled={restarting}
on:click|preventDefault={forceRestartProxy}
>{restarting ? 'Restarting... please wait...' : 'Force restart proxy'}</button
>
{/if}
<!-- <button type="button" class="bg-coollabs hover:bg-coollabs-100" on:click={scanApps}
>Scan for applications</button
> -->
</div>
<div class="grid grid-cols-2 items-center px-10 ">
<label for="name" class="text-base font-bold text-stone-100">Name</label>
<input
name="name"
placeholder="name"
disabled={!$session.isAdmin}
readonly={!$session.isAdmin}
bind:value={destination.name}
/>
</div>
<div class="grid grid-cols-2 items-center px-10">
<label for="engine" class="text-base font-bold text-stone-100">Engine</label>
<CopyPasswordField
id="engine"
readonly
disabled
name="engine"
placeholder="eg: /var/run/docker.sock"
value={destination.engine}
/>
</div>
<!-- <div class="flex items-center">
<label for="remoteEngine">Remote Engine?</label>
<input name="remoteEngine" type="checkbox" bind:checked={payload.remoteEngine} />
</div> -->
<div class="grid grid-cols-2 items-center px-10">
<label for="network" class="text-base font-bold text-stone-100">Network</label>
<CopyPasswordField
id="network"
readonly
disabled
name="network"
placeholder="default: coolify"
value={destination.network}
/>
</div>
<div class="grid grid-cols-2 items-center">
<Setting
disabled={cannotDisable}
bind:setting={destination.isCoolifyProxyUsed}
on:click={changeProxySetting}
title="Use Coolify Proxy?"
description={`This will install a proxy on the destination to allow you to access your applications and services without any manual configuration. Databases will have their own proxy. <br><br>${
cannotDisable
? '<span class="font-bold text-white">You cannot disable this proxy as FQDN is configured for Coolify.</span>'
: ''
}`}
/>
</div>
</form>
<!-- <div class="flex justify-center">
{#if payload.isCoolifyProxyUsed}
{#if state}
<button on:click={stopProxy}>Stop proxy</button>
{:else}
<button on:click={startProxy}>Start proxy</button>
{/if}
{/if}
</div> -->
<!-- {#if scannedApps.length > 0}
<div class="flex justify-center px-6 pb-10">
<div class="flex space-x-2 h-8 items-center">
<div class="font-bold text-xl text-white">Found applications</div>
</div>
</div>
<div class="max-w-4xl mx-auto px-6">
<div class="flex space-x-2 justify-center">
{#each scannedApps as app}
<FoundApp {app} />
{/each}
</div>
</div>
{/if} -->

View File

@ -1,4 +1,5 @@
import { asyncExecShell, getEngine, getTeam, getUserDetails } from '$lib/common'; import { getUserDetails } from '$lib/common';
import { generateRemoteEngine } from '$lib/components/common';
import * as db from '$lib/database'; import * as db from '$lib/database';
import { ErrorHandler } from '$lib/database'; import { ErrorHandler } from '$lib/database';
import { checkContainer } from '$lib/haproxy'; import { checkContainer } from '$lib/haproxy';
@ -12,15 +13,21 @@ export const get: RequestHandler = async (event) => {
try { try {
const destination = await db.getDestination({ id, teamId }); const destination = await db.getDestination({ id, teamId });
const settings = await db.listSettings(); const settings = await db.listSettings();
const state = let payload = {
destination?.engine && (await checkContainer(destination.engine, 'coolify-haproxy'));
return {
status: 200,
body: {
destination, destination,
settings, settings,
state state: false
};
if (destination.remoteEngine) {
const engine = await generateRemoteEngine(destination);
payload.state = await checkContainer(engine, 'coolify-haproxy');
} else {
payload.state =
destination?.engine && (await checkContainer(destination.engine, 'coolify-haproxy'));
} }
return {
status: 200,
body: { ...payload }
}; };
} catch (error) { } catch (error) {
return ErrorHandler(error); return ErrorHandler(error);

View File

@ -35,6 +35,7 @@
import type Prisma from '@prisma/client'; import type Prisma from '@prisma/client';
import LocalDocker from './_LocalDocker.svelte'; import LocalDocker from './_LocalDocker.svelte';
import RemoteDocker from './_RemoteDocker.svelte';
</script> </script>
<div class="flex space-x-1 p-6 text-2xl font-bold"> <div class="flex space-x-1 p-6 text-2xl font-bold">
@ -42,6 +43,11 @@
<span class="arrow-right-applications px-1">></span> <span class="arrow-right-applications px-1">></span>
<span class="pr-2">{destination.name}</span> <span class="pr-2">{destination.name}</span>
</div> </div>
<div class="mx-auto max-w-4xl px-6"> <div class="mx-auto max-w-4xl px-6">
{#if destination.remoteEngine}
<RemoteDocker bind:destination {settings} {state} />
{:else}
<LocalDocker bind:destination {settings} {state} /> <LocalDocker bind:destination {settings} {state} />
{/if}
</div> </div>

View File

@ -51,26 +51,7 @@
placeholder="eg: /var/run/docker.sock" placeholder="eg: /var/run/docker.sock"
bind:value={payload.engine} bind:value={payload.engine}
/> />
<!-- <Explainer text="You can use remote Docker Engine with over SSH." /> -->
</div> </div>
<!-- <div class="flex items-center">
<label for="remoteEngine">Remote Docker Engine?</label>
<input name="remoteEngine" type="checkbox" bind:checked={payload.remoteEngine} />
</div>
{#if payload.remoteEngine}
<div class="grid grid-cols-3 items-center">
<label for="user">User</label>
<div class="col-span-2">
<input required name="user" placeholder="eg: root" bind:value={payload.user} />
</div>
</div>
<div class="grid grid-cols-3 items-center">
<label for="port">Port</label>
<div class="col-span-2">
<input required name="port" placeholder="eg: 22" bind:value={payload.port} />
</div>
</div>
{/if} -->
<div class="grid grid-cols-2 items-center px-10"> <div class="grid grid-cols-2 items-center px-10">
<label for="network" class="text-base font-bold text-stone-100">Network</label> <label for="network" class="text-base font-bold text-stone-100">Network</label>
<input required name="network" placeholder="default: coolify" bind:value={payload.network} /> <input required name="network" placeholder="default: coolify" bind:value={payload.network} />

View File

@ -0,0 +1,90 @@
<script lang="ts">
import { goto } from '$app/navigation';
export let payload;
import { post } from '$lib/api';
import Explainer from '$lib/components/Explainer.svelte';
import Setting from '$lib/components/Setting.svelte';
import { errorNotification } from '$lib/form';
let loading = false;
async function handleSubmit() {
try {
const { id } = await post('/new/destination/docker.json', {
...payload
});
return await goto(`/destinations/${id}`);
} catch ({ error }) {
return errorNotification(error);
}
}
</script>
<div class="flex justify-center px-6 pb-8">
<form on:submit|preventDefault={handleSubmit} class="grid grid-flow-row gap-2 py-4">
<div class="flex items-center space-x-2 pb-5">
<div class="title font-bold">Configuration</div>
<button
type="submit"
class:bg-sky-600={!loading}
class:hover:bg-sky-500={!loading}
disabled={loading}
>{loading
? payload.isCoolifyProxyUsed
? 'Saving and configuring proxy...'
: 'Saving...'
: 'Save'}</button
>
</div>
<div class="mt-2 grid grid-cols-2 items-center px-10">
<label for="name" class="text-base font-bold text-stone-100">Name</label>
<input required name="name" placeholder="name" bind:value={payload.name} />
</div>
<div class="grid grid-cols-2 items-center px-10">
<label for="ipAddress" class="text-base font-bold text-stone-100">IP Address</label>
<input
required
name="ipAddress"
placeholder="eg: 192.168..."
bind:value={payload.ipAddress}
/>
</div>
<div class="grid grid-cols-2 items-center px-10">
<label for="user" class="text-base font-bold text-stone-100">User</label>
<input required name="user" placeholder="eg: root" bind:value={payload.user} />
</div>
<div class="grid grid-cols-2 items-center px-10">
<label for="port" class="text-base font-bold text-stone-100">Port</label>
<input required name="port" placeholder="eg: 22" bind:value={payload.port} />
</div>
<div class="grid grid-cols-2 items-center px-10">
<label for="sshPrivateKey" class="text-base font-bold text-stone-100">SSH Private Key</label>
<textarea
rows="10"
class="resize-none"
required
name="sshPrivateKey"
placeholder="eg: -----BEGIN...."
bind:value={payload.sshPrivateKey}
/>
</div>
<div class="grid grid-cols-2 items-center px-10">
<label for="network" class="text-base font-bold text-stone-100">Network</label>
<input required name="network" placeholder="default: coolify" bind:value={payload.network} />
</div>
<div class="grid grid-cols-2 items-center">
<Setting
bind:setting={payload.isCoolifyProxyUsed}
on:click={() => (payload.isCoolifyProxyUsed = !payload.isCoolifyProxyUsed)}
title="Use Coolify Proxy?"
description="This will install a proxy on the destination to allow you to access your applications and services without any manual configuration (recommended for Docker).<br><br>Databases will have their own proxy."
/>
</div>
</form>
</div>

View File

@ -8,10 +8,36 @@ export const post: RequestHandler = async (event) => {
const { teamId, status, body } = await getUserDetails(event); const { teamId, status, body } = await getUserDetails(event);
if (status === 401) return { status, body }; if (status === 401) return { status, body };
const { name, engine, network, isCoolifyProxyUsed } = await event.request.json(); const {
name,
engine,
network,
isCoolifyProxyUsed,
remoteEngine,
ipAddress,
user,
port,
sshPrivateKey
} = await event.request.json();
try { try {
const id = await db.newDestination({ name, teamId, engine, network, isCoolifyProxyUsed }); let id = null;
if (remoteEngine) {
id = await db.newRemoteDestination({
name,
teamId,
engine,
network,
isCoolifyProxyUsed,
remoteEngine,
ipAddress,
user,
port,
sshPrivateKey
});
} else {
id = await db.newLocalDestination({ name, teamId, engine, network, isCoolifyProxyUsed });
}
return { status: 200, body: { id } }; return { status: 200, body: { id } };
} catch (error) { } catch (error) {
return ErrorHandler(error); return ErrorHandler(error);

View File

@ -1,25 +1,34 @@
<script> <script>
import Docker from './_Docker.svelte'; import LocalDocker from './_LocalDocker.svelte';
import cuid from 'cuid'; import cuid from 'cuid';
import RemoteDocker from './_RemoteDocker.svelte';
let payload = {}; let payload = {};
let selected = 'docker'; let selected = 'localDocker';
function setPredefined(type) { function setPredefined(type) {
selected = type; selected = type;
switch (type) { switch (type) {
case 'docker': case 'localDocker':
payload = { payload = {
name: 'Local Docker', name: 'Local Docker',
engine: '/var/run/docker.sock', engine: '/var/run/docker.sock',
remoteEngine: false, remoteEngine: false,
user: 'root',
port: 22,
privateKey: null,
network: cuid(), network: cuid(),
isCoolifyProxyUsed: true isCoolifyProxyUsed: true
}; };
break; break;
case 'remoteDocker':
payload = {
name: 'Remote Docker',
remoteEngine: true,
ipAddress: null,
user: 'root',
port: 22,
sshPrivateKey: null,
network: cuid(),
isCoolifyProxyUsed: true
};
break;
default: default:
break; break;
} }
@ -32,12 +41,15 @@
<div class="flex-col space-y-2 pb-10 text-center"> <div class="flex-col space-y-2 pb-10 text-center">
<div class="text-xl font-bold text-white">Predefined destinations</div> <div class="text-xl font-bold text-white">Predefined destinations</div>
<div class="flex justify-center space-x-2"> <div class="flex justify-center space-x-2">
<button class="w-32" on:click={() => setPredefined('docker')}>Docker</button> <button class="w-32" on:click={() => setPredefined('localDocker')}>Local Docker</button>
<button class="w-32" on:click={() => setPredefined('remoteDocker')}>Remote Docker</button>
<button class="w-32" on:click={() => setPredefined('kubernetes')}>Kubernetes</button> <button class="w-32" on:click={() => setPredefined('kubernetes')}>Kubernetes</button>
</div> </div>
</div> </div>
{#if selected === 'docker'} {#if selected === 'localDocker'}
<Docker {payload} /> <LocalDocker {payload} />
{:else if selected === 'remoteDocker'}
<RemoteDocker {payload} />
{:else} {:else}
<div class="text-center font-bold text-4xl py-10">Not implemented yet</div> <div class="text-center font-bold text-4xl py-10">Not implemented yet</div>
{/if} {/if}