wip: trpc

This commit is contained in:
Andras Bacsai
2023-01-13 14:17:36 +01:00
parent c651570e62
commit 568ab24fd9
30 changed files with 9082 additions and 173 deletions

View File

@@ -665,4 +665,46 @@ export async function getContainerUsage(dockerId: string, container: string): Pr
NetIO: 0
};
}
}
}
export function fixType(type) {
return type?.replaceAll(' ', '').toLowerCase() || null;
}
const compareSemanticVersions = (a: string, b: string) => {
const a1 = a.split('.');
const b1 = b.split('.');
const len = Math.min(a1.length, b1.length);
for (let i = 0; i < len; i++) {
const a2 = +a1[i] || 0;
const b2 = +b1[i] || 0;
if (a2 !== b2) {
return a2 > b2 ? 1 : -1;
}
}
return b1.length - a1.length;
};
export async function getTags(type: string) {
try {
if (type) {
const tagsPath = isDev ? './tags.json' : '/app/tags.json';
const data = await fs.readFile(tagsPath, 'utf8');
let tags = JSON.parse(data);
if (tags) {
tags = tags.find((tag: any) => tag.name.includes(type));
tags.tags = tags.tags.sort(compareSemanticVersions).reverse();
return tags;
}
}
} catch (error) {
return [];
}
}
export function makeLabelForServices(type) {
return [
'coolify.managed=true',
`coolify.version=${version}`,
`coolify.type=service`,
`coolify.service.type=${type}`
];
}
export const asyncSleep = (delay: number): Promise<unknown> =>
new Promise((resolve) => setTimeout(resolve, delay));

View File

@@ -9,7 +9,7 @@ Bree.extend(TSBree);
const options: any = {
defaultExtension: 'js',
logger: new Cabin({}),
logger: false,
jobs: [{ name: 'applicationBuildQueue' }]
};
if (isDev) options.root = path.join(__dirname, './jobs');

View File

@@ -1,171 +0,0 @@
import { z } from 'zod';
import { privateProcedure, router } from '../trpc';
import { decrypt, getTemplates, removeService } from '../../lib/common';
import { prisma } from '../../prisma';
import { executeCommand } from '../../lib/executeCommand';
export const servicesRouter = router({
status: privateProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
const id = input.id;
const teamId = ctx.user?.teamId;
if (!teamId) {
throw { status: 400, message: 'Team not found.' };
}
const service = await getServiceFromDB({ id, teamId });
const { destinationDockerId } = service;
let payload = {};
if (destinationDockerId) {
const { stdout: containers } = await executeCommand({
dockerId: service.destinationDocker.id,
command: `docker ps -a --filter "label=com.docker.compose.project=${id}" --format '{{json .}}'`
});
if (containers) {
const containersArray = containers.trim().split('\n');
if (containersArray.length > 0 && containersArray[0] !== '') {
const templates = await getTemplates();
let template = templates.find((t: { type: string }) => t.type === service.type);
const templateStr = JSON.stringify(template);
if (templateStr) {
template = JSON.parse(templateStr.replaceAll('$$id', service.id));
}
for (const container of containersArray) {
let isRunning = false;
let isExited = false;
let isRestarting = false;
let isExcluded = false;
const containerObj = JSON.parse(container);
const exclude = template?.services[containerObj.Names]?.exclude;
if (exclude) {
payload[containerObj.Names] = {
status: {
isExcluded: true,
isRunning: false,
isExited: false,
isRestarting: false
}
};
continue;
}
const status = containerObj.State;
if (status === 'running') {
isRunning = true;
}
if (status === 'exited') {
isExited = true;
}
if (status === 'restarting') {
isRestarting = true;
}
payload[containerObj.Names] = {
status: {
isExcluded,
isRunning,
isExited,
isRestarting
}
};
}
}
}
}
return payload;
}),
cleanup: privateProcedure.query(async ({ ctx }) => {
const teamId = ctx.user?.teamId;
let services = await prisma.service.findMany({
where: { teams: { some: { id: teamId === '0' ? undefined : teamId } } },
include: { destinationDocker: true, teams: true }
});
for (const service of services) {
if (!service.fqdn) {
if (service.destinationDockerId) {
const { stdout: containers } = await executeCommand({
dockerId: service.destinationDockerId,
command: `docker ps -a --filter 'label=com.docker.compose.project=${service.id}' --format {{.ID}}`
});
if (containers) {
const containerArray = containers.split('\n');
if (containerArray.length > 0) {
for (const container of containerArray) {
await executeCommand({
dockerId: service.destinationDockerId,
command: `docker stop -t 0 ${container}`
});
await executeCommand({
dockerId: service.destinationDockerId,
command: `docker rm --force ${container}`
});
}
}
}
}
await removeService({ id: service.id });
}
}
}),
delete: privateProcedure
.input(z.object({ force: z.boolean(), id: z.string() }))
.mutation(async ({ input }) => {
// todo: check if user is allowed to delete service
const { id } = input;
await prisma.serviceSecret.deleteMany({ where: { serviceId: id } });
await prisma.serviceSetting.deleteMany({ where: { serviceId: id } });
await prisma.servicePersistentStorage.deleteMany({ where: { serviceId: id } });
await prisma.meiliSearch.deleteMany({ where: { serviceId: id } });
await prisma.fider.deleteMany({ where: { serviceId: id } });
await prisma.ghost.deleteMany({ where: { serviceId: id } });
await prisma.umami.deleteMany({ where: { serviceId: id } });
await prisma.hasura.deleteMany({ where: { serviceId: id } });
await prisma.plausibleAnalytics.deleteMany({ where: { serviceId: id } });
await prisma.minio.deleteMany({ where: { serviceId: id } });
await prisma.vscodeserver.deleteMany({ where: { serviceId: id } });
await prisma.wordpress.deleteMany({ where: { serviceId: id } });
await prisma.glitchTip.deleteMany({ where: { serviceId: id } });
await prisma.moodle.deleteMany({ where: { serviceId: id } });
await prisma.appwrite.deleteMany({ where: { serviceId: id } });
await prisma.searxng.deleteMany({ where: { serviceId: id } });
await prisma.weblate.deleteMany({ where: { serviceId: id } });
await prisma.taiga.deleteMany({ where: { serviceId: id } });
await prisma.service.delete({ where: { id } });
return {};
})
});
export async function getServiceFromDB({
id,
teamId
}: {
id: string;
teamId: string;
}): Promise<any> {
const settings = await prisma.setting.findFirst();
const body = await prisma.service.findFirst({
where: { id, teams: { some: { id: teamId === '0' ? undefined : teamId } } },
include: {
destinationDocker: true,
persistentStorage: true,
serviceSecret: true,
serviceSetting: true,
wordpress: true,
plausibleAnalytics: true
}
});
if (!body) {
return null;
}
// body.type = fixType(body.type);
if (body?.serviceSecret.length > 0) {
body.serviceSecret = body.serviceSecret.map((s) => {
s.value = decrypt(s.value);
return s;
});
}
if (body.wordpress) {
body.wordpress.ftpPassword = decrypt(body.wordpress.ftpPassword);
}
return { ...body, settings };
}

View File

@@ -0,0 +1,895 @@
import { z } from 'zod';
import yaml from 'js-yaml';
import fs from 'fs/promises';
import path from 'path';
import { privateProcedure, router } from '../../trpc';
import {
createDirectories,
decrypt,
encrypt,
fixType,
getTags,
getTemplates,
isARM,
isDev,
listSettings,
makeLabelForServices,
removeService
} from '../../../lib/common';
import { prisma } from '../../../prisma';
import { executeCommand } from '../../../lib/executeCommand';
import {
generatePassword,
getFreePublicPort,
parseAndFindServiceTemplates,
persistentVolumes,
startServiceContainers,
verifyAndDecryptServiceSecrets
} from './lib';
import { checkContainer, defaultComposeConfiguration, stopTcpHttpProxy } from '../../../lib/docker';
import cuid from 'cuid';
import { day } from '../../../lib/dayjs';
export const servicesRouter = router({
getLogs: privateProcedure
.input(
z.object({
id: z.string(),
containerId: z.string(),
since: z.number().optional().default(0)
})
)
.query(async ({ input, ctx }) => {
let { id, containerId, since } = input;
if (since !== 0) {
since = day(since).unix();
}
const {
destinationDockerId,
destinationDocker: { id: dockerId }
} = await prisma.service.findUnique({
where: { id },
include: { destinationDocker: true }
});
if (destinationDockerId) {
try {
const { default: ansi } = await import('strip-ansi');
const { stdout, stderr } = await executeCommand({
dockerId,
command: `docker logs --since ${since} --tail 5000 --timestamps ${containerId}`
});
const stripLogsStdout = stdout
.toString()
.split('\n')
.map((l) => ansi(l))
.filter((a) => a);
const stripLogsStderr = stderr
.toString()
.split('\n')
.map((l) => ansi(l))
.filter((a) => a);
const logs = stripLogsStderr.concat(stripLogsStdout);
const sortedLogs = logs.sort((a, b) =>
day(a.split(' ')[0]).isAfter(day(b.split(' ')[0])) ? 1 : -1
);
return {
data: {
logs: sortedLogs
}
};
// }
} catch (error) {
const { statusCode, stderr } = error;
if (stderr.startsWith('Error: No such container')) {
return {
data: {
logs: [],
noContainer: true
}
};
}
if (statusCode === 404) {
return {
data: {
logs: []
}
};
}
}
}
return {
message: 'No logs found.'
};
}),
deleteStorage: privateProcedure
.input(
z.object({
storageId: z.string()
})
)
.mutation(async ({ input, ctx }) => {
const { storageId } = input;
await prisma.servicePersistentStorage.deleteMany({ where: { id: storageId } });
}),
saveStorage: privateProcedure
.input(
z.object({
id: z.string(),
path: z.string(),
isNewStorage: z.boolean(),
storageId: z.string().optional().nullable(),
containerId: z.string().optional()
})
)
.mutation(async ({ input, ctx }) => {
const { id, path, isNewStorage, storageId, containerId } = input;
if (isNewStorage) {
const volumeName = `${id}-custom${path.replace(/\//gi, '-')}`;
const found = await prisma.servicePersistentStorage.findFirst({
where: { path, containerId }
});
if (found) {
throw {
status: 500,
message: 'Persistent storage already exists for this container and path.'
};
}
await prisma.servicePersistentStorage.create({
data: { path, volumeName, containerId, service: { connect: { id } } }
});
} else {
await prisma.servicePersistentStorage.update({
where: { id: storageId },
data: { path, containerId }
});
}
}),
getStorages: privateProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input, ctx }) => {
const { id } = input;
const persistentStorages = await prisma.servicePersistentStorage.findMany({
where: { serviceId: id }
});
return {
success: true,
data: {
persistentStorages
}
};
}),
deleteSecret: privateProcedure
.input(z.object({ id: z.string(), name: z.string() }))
.mutation(async ({ input, ctx }) => {
const { id, name } = input;
await prisma.serviceSecret.deleteMany({ where: { serviceId: id, name } });
}),
saveService: privateProcedure
.input(
z.object({
id: z.string(),
name: z.string(),
fqdn: z.string().optional(),
exposePort: z.string().optional(),
type: z.string(),
serviceSetting: z.any(),
version: z.string().optional()
})
)
.mutation(async ({ input, ctx }) => {
const teamId = ctx.user?.teamId;
let { id, name, fqdn, exposePort, type, serviceSetting, version } = input;
if (fqdn) fqdn = fqdn.toLowerCase();
if (exposePort) exposePort = Number(exposePort);
type = fixType(type);
const data = {
fqdn,
name,
exposePort,
version
};
const templates = await getTemplates();
const service = await prisma.service.findUnique({ where: { id } });
const foundTemplate = templates.find((t) => fixType(t.type) === fixType(service.type));
for (const setting of serviceSetting) {
let { id: settingId, name, value, changed = false, isNew = false, variableName } = setting;
if (value) {
if (changed) {
await prisma.serviceSetting.update({ where: { id: settingId }, data: { value } });
}
if (isNew) {
if (!variableName) {
variableName = foundTemplate?.variables.find((v) => v.name === name).id;
}
await prisma.serviceSetting.create({
data: { name, value, variableName, service: { connect: { id } } }
});
}
}
}
await prisma.service.update({
where: { id },
data
});
}),
createSecret: privateProcedure
.input(
z.object({
id: z.string(),
name: z.string(),
value: z.string(),
isBuildSecret: z.boolean().optional(),
isPRMRSecret: z.boolean().optional(),
isNew: z.boolean().optional()
})
)
.mutation(async ({ input }) => {
let { id, name, value, isNew } = input;
if (isNew) {
const found = await prisma.serviceSecret.findFirst({ where: { name, serviceId: id } });
if (found) {
throw `Secret ${name} already exists.`;
} else {
value = encrypt(value.trim());
await prisma.serviceSecret.create({
data: { name, value, service: { connect: { id } } }
});
}
} else {
value = encrypt(value.trim());
const found = await prisma.serviceSecret.findFirst({ where: { serviceId: id, name } });
if (found) {
await prisma.serviceSecret.updateMany({
where: { serviceId: id, name },
data: { value }
});
} else {
await prisma.serviceSecret.create({
data: { name, value, service: { connect: { id } } }
});
}
}
}),
getSecrets: privateProcedure.input(z.object({ id: z.string() })).query(async ({ input, ctx }) => {
const { id } = input;
const teamId = ctx.user?.teamId;
const service = await getServiceFromDB({ id, teamId });
let secrets = await prisma.serviceSecret.findMany({
where: { serviceId: id },
orderBy: { createdAt: 'desc' }
});
const templates = await getTemplates();
if (!templates) throw new Error('No templates found. Please contact support.');
const foundTemplate = templates.find((t) => fixType(t.type) === service.type);
secrets = secrets.map((secret) => {
const foundVariable = foundTemplate?.variables?.find((v) => v.name === secret.name) || null;
if (foundVariable) {
secret.readOnly = foundVariable.readOnly;
}
secret.value = decrypt(secret.value);
return secret;
});
return {
success: true,
data: {
secrets
}
};
}),
wordpress: privateProcedure
.input(z.object({ id: z.string(), ftpEnabled: z.boolean() }))
.mutation(async ({ input, ctx }) => {
const { id } = input;
const teamId = ctx.user?.teamId;
const {
service: {
destinationDocker: { engine, remoteEngine, remoteIpAddress }
}
} = await prisma.wordpress.findUnique({
where: { serviceId: id },
include: { service: { include: { destinationDocker: true } } }
});
const publicPort = await getFreePublicPort({ id, remoteEngine, engine, remoteIpAddress });
let ftpUser = cuid();
let ftpPassword = generatePassword({});
const hostkeyDir = isDev ? '/tmp/hostkeys' : '/app/ssl/hostkeys';
try {
const data = await prisma.wordpress.update({
where: { serviceId: id },
data: { ftpEnabled },
include: { service: { include: { destinationDocker: true } } }
});
const {
service: { destinationDockerId, destinationDocker },
ftpPublicPort,
ftpUser: user,
ftpPassword: savedPassword,
ftpHostKey,
ftpHostKeyPrivate
} = data;
const { network, engine } = destinationDocker;
if (ftpEnabled) {
if (user) ftpUser = user;
if (savedPassword) ftpPassword = decrypt(savedPassword);
// TODO: rewrite these to usable without shell
const { stdout: password } = await executeCommand({
command: `echo ${ftpPassword} | openssl passwd -1 -stdin`,
shell: true
});
if (destinationDockerId) {
try {
await fs.stat(hostkeyDir);
} catch (error) {
await executeCommand({ command: `mkdir -p ${hostkeyDir}` });
}
if (!ftpHostKey) {
await executeCommand({
command: `ssh-keygen -t ed25519 -f ssh_host_ed25519_key -N "" -q -f ${hostkeyDir}/${id}.ed25519`
});
const { stdout: ftpHostKey } = await executeCommand({
command: `cat ${hostkeyDir}/${id}.ed25519`
});
await prisma.wordpress.update({
where: { serviceId: id },
data: { ftpHostKey: encrypt(ftpHostKey) }
});
} else {
await executeCommand({
command: `echo "${decrypt(ftpHostKey)}" > ${hostkeyDir}/${id}.ed25519`,
shell: true
});
}
if (!ftpHostKeyPrivate) {
await executeCommand({
command: `ssh-keygen -t rsa -b 4096 -N "" -f ${hostkeyDir}/${id}.rsa`
});
const { stdout: ftpHostKeyPrivate } = await executeCommand({
command: `cat ${hostkeyDir}/${id}.rsa`
});
await prisma.wordpress.update({
where: { serviceId: id },
data: { ftpHostKeyPrivate: encrypt(ftpHostKeyPrivate) }
});
} else {
await executeCommand({
command: `echo "${decrypt(ftpHostKeyPrivate)}" > ${hostkeyDir}/${id}.rsa`,
shell: true
});
}
await prisma.wordpress.update({
where: { serviceId: id },
data: {
ftpPublicPort: publicPort,
ftpUser: user ? undefined : ftpUser,
ftpPassword: savedPassword ? undefined : encrypt(ftpPassword)
}
});
try {
const { found: isRunning } = await checkContainer({
dockerId: destinationDocker.id,
container: `${id}-ftp`
});
if (isRunning) {
await executeCommand({
dockerId: destinationDocker.id,
command: `docker stop -t 0 ${id}-ftp && docker rm ${id}-ftp`,
shell: true
});
}
} catch (error) {}
const volumes = [
`${id}-wordpress-data:/home/${ftpUser}/wordpress`,
`${
isDev ? hostkeyDir : '/var/lib/docker/volumes/coolify-ssl-certs/_data/hostkeys'
}/${id}.ed25519:/etc/ssh/ssh_host_ed25519_key`,
`${
isDev ? hostkeyDir : '/var/lib/docker/volumes/coolify-ssl-certs/_data/hostkeys'
}/${id}.rsa:/etc/ssh/ssh_host_rsa_key`,
`${
isDev ? hostkeyDir : '/var/lib/docker/volumes/coolify-ssl-certs/_data/hostkeys'
}/${id}.sh:/etc/sftp.d/chmod.sh`
];
const compose = {
version: '3.8',
services: {
[`${id}-ftp`]: {
image: `atmoz/sftp:alpine`,
command: `'${ftpUser}:${password.replace('\n', '').replace(/\$/g, '$$$')}:e:33'`,
extra_hosts: ['host.docker.internal:host-gateway'],
container_name: `${id}-ftp`,
volumes,
networks: [network],
depends_on: [],
restart: 'always'
}
},
networks: {
[network]: {
external: true
}
},
volumes: {
[`${id}-wordpress-data`]: {
external: true,
name: `${id}-wordpress-data`
}
}
};
await fs.writeFile(
`${hostkeyDir}/${id}.sh`,
`#!/bin/bash\nchmod 600 /etc/ssh/ssh_host_ed25519_key /etc/ssh/ssh_host_rsa_key\nuserdel -f xfs\nchown -R 33:33 /home/${ftpUser}/wordpress/`
);
await executeCommand({ command: `chmod +x ${hostkeyDir}/${id}.sh` });
await fs.writeFile(`${hostkeyDir}/${id}-docker-compose.yml`, yaml.dump(compose));
await executeCommand({
dockerId: destinationDocker.id,
command: `docker compose -f ${hostkeyDir}/${id}-docker-compose.yml up -d`
});
}
return {
publicPort,
ftpUser,
ftpPassword
};
} else {
await prisma.wordpress.update({
where: { serviceId: id },
data: { ftpPublicPort: null }
});
try {
await executeCommand({
dockerId: destinationDocker.id,
command: `docker stop -t 0 ${id}-ftp && docker rm ${id}-ftp`,
shell: true
});
} catch (error) {
//
}
await stopTcpHttpProxy(id, destinationDocker, ftpPublicPort);
}
} catch ({ status, message }) {
throw message;
} finally {
try {
await executeCommand({
command: `rm -fr ${hostkeyDir}/${id}-docker-compose.yml ${hostkeyDir}/${id}.ed25519 ${hostkeyDir}/${id}.ed25519.pub ${hostkeyDir}/${id}.rsa ${hostkeyDir}/${id}.rsa.pub ${hostkeyDir}/${id}.sh`
});
} catch (error) {}
}
}),
start: privateProcedure.input(z.object({ id: z.string() })).mutation(async ({ input, ctx }) => {
const { id } = input;
const teamId = ctx.user?.teamId;
const service = await getServiceFromDB({ id, teamId });
const arm = isARM(service.arch);
const { type, destinationDockerId, destinationDocker, persistentStorage, exposePort } = service;
const { workdir } = await createDirectories({ repository: type, buildId: id });
const template: any = await parseAndFindServiceTemplates(service, workdir, true);
const network = destinationDockerId && destinationDocker.network;
const config = {};
for (const s in template.services) {
let newEnvironments = [];
if (arm) {
if (template.services[s]?.environmentArm?.length > 0) {
for (const environment of template.services[s].environmentArm) {
let [env, ...value] = environment.split('=');
value = value.join('=');
if (!value.startsWith('$$secret') && value !== '') {
newEnvironments.push(`${env}=${value}`);
}
}
}
} else {
if (template.services[s]?.environment?.length > 0) {
for (const environment of template.services[s].environment) {
let [env, ...value] = environment.split('=');
value = value.join('=');
if (!value.startsWith('$$secret') && value !== '') {
newEnvironments.push(`${env}=${value}`);
}
}
}
}
const secrets = await verifyAndDecryptServiceSecrets(id);
for (const secret of secrets) {
const { name, value } = secret;
if (value) {
const foundEnv = !!template.services[s].environment?.find((env) =>
env.startsWith(`${name}=`)
);
const foundNewEnv = !!newEnvironments?.find((env) => env.startsWith(`${name}=`));
if (foundEnv && !foundNewEnv) {
newEnvironments.push(`${name}=${value}`);
}
if (!foundEnv && !foundNewEnv && s === id) {
newEnvironments.push(`${name}=${value}`);
}
}
}
const customVolumes = await prisma.servicePersistentStorage.findMany({
where: { serviceId: id }
});
let volumes = new Set();
if (arm) {
template.services[s]?.volumesArm &&
template.services[s].volumesArm.length > 0 &&
template.services[s].volumesArm.forEach((v) => volumes.add(v));
} else {
template.services[s]?.volumes &&
template.services[s].volumes.length > 0 &&
template.services[s].volumes.forEach((v) => volumes.add(v));
}
// Workaround: old plausible analytics service wrong volume id name
if (service.type === 'plausibleanalytics' && service.plausibleAnalytics?.id) {
let temp = Array.from(volumes);
temp.forEach((a) => {
const t = a.replace(service.id, service.plausibleAnalytics.id);
volumes.delete(a);
volumes.add(t);
});
}
if (customVolumes.length > 0) {
for (const customVolume of customVolumes) {
const { volumeName, path, containerId } = customVolume;
if (
volumes &&
volumes.size > 0 &&
!volumes.has(`${volumeName}:${path}`) &&
containerId === service
) {
volumes.add(`${volumeName}:${path}`);
}
}
}
let ports = [];
if (template.services[s].proxy?.length > 0) {
for (const proxy of template.services[s].proxy) {
if (proxy.hostPort) {
ports.push(`${proxy.hostPort}:${proxy.port}`);
}
}
} else {
if (template.services[s].ports?.length === 1) {
for (const port of template.services[s].ports) {
if (exposePort) {
ports.push(`${exposePort}:${port}`);
}
}
}
}
let image = template.services[s].image;
if (arm && template.services[s].imageArm) {
image = template.services[s].imageArm;
}
config[s] = {
container_name: s,
build: template.services[s].build || undefined,
command: template.services[s].command,
entrypoint: template.services[s]?.entrypoint,
image,
expose: template.services[s].ports,
ports: ports.length > 0 ? ports : undefined,
volumes: Array.from(volumes),
environment: newEnvironments,
depends_on: template.services[s]?.depends_on,
ulimits: template.services[s]?.ulimits,
cap_drop: template.services[s]?.cap_drop,
cap_add: template.services[s]?.cap_add,
labels: makeLabelForServices(type),
...defaultComposeConfiguration(network)
};
// Generate files for builds
if (template.services[s]?.files?.length > 0) {
if (!config[s].build) {
config[s].build = {
context: workdir,
dockerfile: `Dockerfile.${s}`
};
}
let Dockerfile = `
FROM ${template.services[s].image}`;
for (const file of template.services[s].files) {
const { location, content } = file;
const source = path.join(workdir, location);
await fs.mkdir(path.dirname(source), { recursive: true });
await fs.writeFile(source, content);
Dockerfile += `
COPY .${location} ${location}`;
}
await fs.writeFile(`${workdir}/Dockerfile.${s}`, Dockerfile);
}
}
const { volumeMounts } = persistentVolumes(id, persistentStorage, config);
const composeFile = {
version: '3.8',
services: config,
networks: {
[network]: {
external: true
}
},
volumes: volumeMounts
};
const composeFileDestination = `${workdir}/docker-compose.yaml`;
await fs.writeFile(composeFileDestination, yaml.dump(composeFile));
// TODO: TODO!
let fastify = null;
await startServiceContainers(fastify, id, teamId, destinationDocker.id, composeFileDestination);
// Workaround: Stop old minio proxies
if (service.type === 'minio') {
try {
const { stdout: containers } = await executeCommand({
dockerId: destinationDocker.id,
command: `docker container ls -a --filter 'name=${id}-' --format {{.ID}}`
});
if (containers) {
const containerArray = containers.split('\n');
if (containerArray.length > 0) {
for (const container of containerArray) {
await executeCommand({
dockerId: destinationDockerId,
command: `docker stop -t 0 ${container}`
});
await executeCommand({
dockerId: destinationDockerId,
command: `docker rm --force ${container}`
});
}
}
}
} catch (error) {}
try {
const { stdout: containers } = await executeCommand({
dockerId: destinationDocker.id,
command: `docker container ls -a --filter 'name=${id}-' --format {{.ID}}`
});
if (containers) {
const containerArray = containers.split('\n');
if (containerArray.length > 0) {
for (const container of containerArray) {
await executeCommand({
dockerId: destinationDockerId,
command: `docker stop -t 0 ${container}`
});
await executeCommand({
dockerId: destinationDockerId,
command: `docker rm --force ${container}`
});
}
}
}
} catch (error) {}
}
}),
stop: privateProcedure.input(z.object({ id: z.string() })).mutation(async ({ input, ctx }) => {
const { id } = input;
const teamId = ctx.user?.teamId;
const { destinationDockerId } = await getServiceFromDB({ id, teamId });
if (destinationDockerId) {
const { stdout: containers } = await executeCommand({
dockerId: destinationDockerId,
command: `docker ps -a --filter 'label=com.docker.compose.project=${id}' --format {{.ID}}`
});
if (containers) {
const containerArray = containers.split('\n');
if (containerArray.length > 0) {
for (const container of containerArray) {
await executeCommand({
dockerId: destinationDockerId,
command: `docker stop -t 0 ${container}`
});
await executeCommand({
dockerId: destinationDockerId,
command: `docker rm --force ${container}`
});
}
}
}
return {};
}
}),
getServices: privateProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input, ctx }) => {
const { id } = input;
const teamId = ctx.user?.teamId;
const service = await getServiceFromDB({ id, teamId });
if (!service) {
throw { status: 404, message: 'Service not found.' };
}
let template = {};
let tags = [];
if (service.type) {
template = await parseAndFindServiceTemplates(service);
tags = await getTags(service.type);
}
return {
success: true,
data: {
settings: await listSettings(),
service,
template,
tags
}
};
}),
status: privateProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
const id = input.id;
const teamId = ctx.user?.teamId;
if (!teamId) {
throw { status: 400, message: 'Team not found.' };
}
const service = await getServiceFromDB({ id, teamId });
const { destinationDockerId } = service;
let payload = {};
if (destinationDockerId) {
const { stdout: containers } = await executeCommand({
dockerId: service.destinationDocker.id,
command: `docker ps -a --filter "label=com.docker.compose.project=${id}" --format '{{json .}}'`
});
if (containers) {
const containersArray = containers.trim().split('\n');
if (containersArray.length > 0 && containersArray[0] !== '') {
const templates = await getTemplates();
let template = templates.find((t: { type: string }) => t.type === service.type);
const templateStr = JSON.stringify(template);
if (templateStr) {
template = JSON.parse(templateStr.replaceAll('$$id', service.id));
}
for (const container of containersArray) {
let isRunning = false;
let isExited = false;
let isRestarting = false;
let isExcluded = false;
const containerObj = JSON.parse(container);
const exclude = template?.services[containerObj.Names]?.exclude;
if (exclude) {
payload[containerObj.Names] = {
status: {
isExcluded: true,
isRunning: false,
isExited: false,
isRestarting: false
}
};
continue;
}
const status = containerObj.State;
if (status === 'running') {
isRunning = true;
}
if (status === 'exited') {
isExited = true;
}
if (status === 'restarting') {
isRestarting = true;
}
payload[containerObj.Names] = {
status: {
isExcluded,
isRunning,
isExited,
isRestarting
}
};
}
}
}
}
return payload;
}),
cleanup: privateProcedure.query(async ({ ctx }) => {
const teamId = ctx.user?.teamId;
let services = await prisma.service.findMany({
where: { teams: { some: { id: teamId === '0' ? undefined : teamId } } },
include: { destinationDocker: true, teams: true }
});
for (const service of services) {
if (!service.fqdn) {
if (service.destinationDockerId) {
const { stdout: containers } = await executeCommand({
dockerId: service.destinationDockerId,
command: `docker ps -a --filter 'label=com.docker.compose.project=${service.id}' --format {{.ID}}`
});
if (containers) {
const containerArray = containers.split('\n');
if (containerArray.length > 0) {
for (const container of containerArray) {
await executeCommand({
dockerId: service.destinationDockerId,
command: `docker stop -t 0 ${container}`
});
await executeCommand({
dockerId: service.destinationDockerId,
command: `docker rm --force ${container}`
});
}
}
}
}
await removeService({ id: service.id });
}
}
}),
delete: privateProcedure
.input(z.object({ force: z.boolean(), id: z.string() }))
.mutation(async ({ input }) => {
// todo: check if user is allowed to delete service
const { id } = input;
await prisma.serviceSecret.deleteMany({ where: { serviceId: id } });
await prisma.serviceSetting.deleteMany({ where: { serviceId: id } });
await prisma.servicePersistentStorage.deleteMany({ where: { serviceId: id } });
await prisma.meiliSearch.deleteMany({ where: { serviceId: id } });
await prisma.fider.deleteMany({ where: { serviceId: id } });
await prisma.ghost.deleteMany({ where: { serviceId: id } });
await prisma.umami.deleteMany({ where: { serviceId: id } });
await prisma.hasura.deleteMany({ where: { serviceId: id } });
await prisma.plausibleAnalytics.deleteMany({ where: { serviceId: id } });
await prisma.minio.deleteMany({ where: { serviceId: id } });
await prisma.vscodeserver.deleteMany({ where: { serviceId: id } });
await prisma.wordpress.deleteMany({ where: { serviceId: id } });
await prisma.glitchTip.deleteMany({ where: { serviceId: id } });
await prisma.moodle.deleteMany({ where: { serviceId: id } });
await prisma.appwrite.deleteMany({ where: { serviceId: id } });
await prisma.searxng.deleteMany({ where: { serviceId: id } });
await prisma.weblate.deleteMany({ where: { serviceId: id } });
await prisma.taiga.deleteMany({ where: { serviceId: id } });
await prisma.service.delete({ where: { id } });
return {};
})
});
export async function getServiceFromDB({
id,
teamId
}: {
id: string;
teamId: string;
}): Promise<any> {
const settings = await prisma.setting.findFirst();
const body = await prisma.service.findFirst({
where: { id, teams: { some: { id: teamId === '0' ? undefined : teamId } } },
include: {
destinationDocker: true,
persistentStorage: true,
serviceSecret: true,
serviceSetting: true,
wordpress: true,
plausibleAnalytics: true
}
});
if (!body) {
return null;
}
// body.type = fixType(body.type);
if (body?.serviceSecret.length > 0) {
body.serviceSecret = body.serviceSecret.map((s) => {
s.value = decrypt(s.value);
return s;
});
}
if (body.wordpress) {
body.wordpress.ftpPassword = decrypt(body.wordpress.ftpPassword);
}
return { ...body, settings };
}

View File

@@ -0,0 +1,376 @@
import { asyncSleep, decrypt, fixType, generateRangeArray, getDomain, getTemplates } from '../../../lib/common';
import bcrypt from 'bcryptjs';
import { prisma } from '../../../prisma';
import crypto from 'crypto';
import { executeCommand } from '../../../lib/executeCommand';
export async function parseAndFindServiceTemplates(
service: any,
workdir?: string,
isDeploy: boolean = false
) {
const templates = await getTemplates();
const foundTemplate = templates.find((t) => fixType(t.type) === service.type);
let parsedTemplate = {};
if (foundTemplate) {
if (!isDeploy) {
for (const [key, value] of Object.entries(foundTemplate.services)) {
const realKey = key.replace('$$id', service.id);
let name = value.name;
if (!name) {
if (Object.keys(foundTemplate.services).length === 1) {
name = foundTemplate.name || service.name.toLowerCase();
} else {
if (key === '$$id') {
name =
foundTemplate.name || key.replaceAll('$$id-', '') || service.name.toLowerCase();
} else {
name = key.replaceAll('$$id-', '') || service.name.toLowerCase();
}
}
}
parsedTemplate[realKey] = {
value,
name,
documentation:
value.documentation || foundTemplate.documentation || 'https://docs.coollabs.io',
image: value.image,
files: value?.files,
environment: [],
fqdns: [],
hostPorts: [],
proxy: {}
};
if (value.environment?.length > 0) {
for (const env of value.environment) {
let [envKey, ...envValue] = env.split('=');
envValue = envValue.join('=');
let variable = null;
if (foundTemplate?.variables) {
variable =
foundTemplate?.variables.find((v) => v.name === envKey) ||
foundTemplate?.variables.find((v) => v.id === envValue);
}
if (variable) {
const id = variable.id.replaceAll('$$', '');
const label = variable?.label;
const description = variable?.description;
const defaultValue = variable?.defaultValue;
const main = variable?.main || '$$id';
const type = variable?.type || 'input';
const placeholder = variable?.placeholder || '';
const readOnly = variable?.readOnly || false;
const required = variable?.required || false;
if (envValue.startsWith('$$config') || variable?.showOnConfiguration) {
if (envValue.startsWith('$$config_coolify')) {
continue;
}
parsedTemplate[realKey].environment.push({
id,
name: envKey,
value: envValue,
main,
label,
description,
defaultValue,
type,
placeholder,
required,
readOnly
});
}
}
}
}
if (value?.proxy && value.proxy.length > 0) {
for (const proxyValue of value.proxy) {
if (proxyValue.domain) {
const variable = foundTemplate?.variables.find((v) => v.id === proxyValue.domain);
if (variable) {
const { id, name, label, description, defaultValue, required = false } = variable;
const found = await prisma.serviceSetting.findFirst({
where: { serviceId: service.id, variableName: proxyValue.domain }
});
parsedTemplate[realKey].fqdns.push({
id,
name,
value: found?.value || '',
label,
description,
defaultValue,
required
});
}
}
if (proxyValue.hostPort) {
const variable = foundTemplate?.variables.find((v) => v.id === proxyValue.hostPort);
if (variable) {
const { id, name, label, description, defaultValue, required = false } = variable;
const found = await prisma.serviceSetting.findFirst({
where: { serviceId: service.id, variableName: proxyValue.hostPort }
});
parsedTemplate[realKey].hostPorts.push({
id,
name,
value: found?.value || '',
label,
description,
defaultValue,
required
});
}
}
}
}
}
} else {
parsedTemplate = foundTemplate;
}
let strParsedTemplate = JSON.stringify(parsedTemplate);
// replace $$id and $$workdir
strParsedTemplate = strParsedTemplate.replaceAll('$$id', service.id);
strParsedTemplate = strParsedTemplate.replaceAll(
'$$core_version',
service.version || foundTemplate.defaultVersion
);
// replace $$workdir
if (workdir) {
strParsedTemplate = strParsedTemplate.replaceAll('$$workdir', workdir);
}
// replace $$config
if (service.serviceSetting.length > 0) {
for (const setting of service.serviceSetting) {
const { value, variableName } = setting;
const regex = new RegExp(`\\$\\$config_${variableName.replace('$$config_', '')}\"`, 'gi');
if (value === '$$generate_fqdn') {
strParsedTemplate = strParsedTemplate.replaceAll(regex, service.fqdn + '"' || '' + '"');
} else if (value === '$$generate_fqdn_slash') {
strParsedTemplate = strParsedTemplate.replaceAll(regex, service.fqdn + '/' + '"');
} else if (value === '$$generate_domain') {
strParsedTemplate = strParsedTemplate.replaceAll(regex, getDomain(service.fqdn) + '"');
} else if (service.destinationDocker?.network && value === '$$generate_network') {
strParsedTemplate = strParsedTemplate.replaceAll(
regex,
service.destinationDocker.network + '"'
);
} else {
strParsedTemplate = strParsedTemplate.replaceAll(regex, value + '"');
}
}
}
// replace $$secret
if (service.serviceSecret.length > 0) {
for (const secret of service.serviceSecret) {
let { name, value } = secret;
name = name.toLowerCase();
const regexHashed = new RegExp(`\\$\\$hashed\\$\\$secret_${name}`, 'gi');
const regex = new RegExp(`\\$\\$secret_${name}`, 'gi');
if (value) {
strParsedTemplate = strParsedTemplate.replaceAll(
regexHashed,
bcrypt.hashSync(value.replaceAll('"', '\\"'), 10)
);
strParsedTemplate = strParsedTemplate.replaceAll(regex, value.replaceAll('"', '\\"'));
} else {
strParsedTemplate = strParsedTemplate.replaceAll(regexHashed, '');
strParsedTemplate = strParsedTemplate.replaceAll(regex, '');
}
}
}
parsedTemplate = JSON.parse(strParsedTemplate);
}
return parsedTemplate;
}
export function generatePassword({
length = 24,
symbols = false,
isHex = false
}: { length?: number; symbols?: boolean; isHex?: boolean } | null): string {
if (isHex) {
return crypto.randomBytes(length).toString('hex');
}
const password = generator.generate({
length,
numbers: true,
strict: true,
symbols
});
return password;
}
export async function getFreePublicPort({ id, remoteEngine, engine, remoteIpAddress }) {
const { default: isReachable } = await import('is-port-reachable');
const data = await prisma.setting.findFirst();
const { minPort, maxPort } = data;
if (remoteEngine) {
const dbUsed = await (
await prisma.database.findMany({
where: {
publicPort: { not: null },
id: { not: id },
destinationDocker: { remoteIpAddress }
},
select: { publicPort: true }
})
).map((a) => a.publicPort);
const wpFtpUsed = await (
await prisma.wordpress.findMany({
where: {
ftpPublicPort: { not: null },
id: { not: id },
service: { destinationDocker: { remoteIpAddress } }
},
select: { ftpPublicPort: true }
})
).map((a) => a.ftpPublicPort);
const wpUsed = await (
await prisma.wordpress.findMany({
where: {
mysqlPublicPort: { not: null },
id: { not: id },
service: { destinationDocker: { remoteIpAddress } }
},
select: { mysqlPublicPort: true }
})
).map((a) => a.mysqlPublicPort);
const minioUsed = await (
await prisma.minio.findMany({
where: {
publicPort: { not: null },
id: { not: id },
service: { destinationDocker: { remoteIpAddress } }
},
select: { publicPort: true }
})
).map((a) => a.publicPort);
const usedPorts = [...dbUsed, ...wpFtpUsed, ...wpUsed, ...minioUsed];
const range = generateRangeArray(minPort, maxPort);
const availablePorts = range.filter((port) => !usedPorts.includes(port));
for (const port of availablePorts) {
const found = await isReachable(port, { host: remoteIpAddress });
if (!found) {
return port;
}
}
return false;
} else {
const dbUsed = await (
await prisma.database.findMany({
where: { publicPort: { not: null }, id: { not: id }, destinationDocker: { engine } },
select: { publicPort: true }
})
).map((a) => a.publicPort);
const wpFtpUsed = await (
await prisma.wordpress.findMany({
where: {
ftpPublicPort: { not: null },
id: { not: id },
service: { destinationDocker: { engine } }
},
select: { ftpPublicPort: true }
})
).map((a) => a.ftpPublicPort);
const wpUsed = await (
await prisma.wordpress.findMany({
where: {
mysqlPublicPort: { not: null },
id: { not: id },
service: { destinationDocker: { engine } }
},
select: { mysqlPublicPort: true }
})
).map((a) => a.mysqlPublicPort);
const minioUsed = await (
await prisma.minio.findMany({
where: {
publicPort: { not: null },
id: { not: id },
service: { destinationDocker: { engine } }
},
select: { publicPort: true }
})
).map((a) => a.publicPort);
const usedPorts = [...dbUsed, ...wpFtpUsed, ...wpUsed, ...minioUsed];
const range = generateRangeArray(minPort, maxPort);
const availablePorts = range.filter((port) => !usedPorts.includes(port));
for (const port of availablePorts) {
const found = await isReachable(port, { host: 'localhost' });
if (!found) {
return port;
}
}
return false;
}
}
export async function verifyAndDecryptServiceSecrets(id: string) {
const secrets = await prisma.serviceSecret.findMany({ where: { serviceId: id } })
let decryptedSecrets = secrets.map(secret => {
const { name, value } = secret
if (value) {
let rawValue = decrypt(value)
rawValue = rawValue.replaceAll(/\$/gi, '$$$')
return { name, value: rawValue }
}
return { name, value }
})
return decryptedSecrets
}
export function persistentVolumes(id, persistentStorage, config) {
let volumeSet = new Set();
if (Object.keys(config).length > 0) {
for (const [key, value] of Object.entries(config)) {
if (value.volumes) {
for (const volume of value.volumes) {
if (!volume.startsWith('/')) {
volumeSet.add(volume);
}
}
}
}
}
const volumesArray = Array.from(volumeSet);
const persistentVolume =
persistentStorage?.map((storage) => {
return `${id}${storage.path.replace(/\//gi, '-')}:${storage.path}`;
}) || [];
let volumes = [...persistentVolume];
if (volumesArray) volumes = [...volumesArray, ...volumes];
const composeVolumes =
(volumes.length > 0 &&
volumes.map((volume) => {
return {
[`${volume.split(':')[0]}`]: {
name: volume.split(':')[0]
}
};
})) ||
[];
const volumeMounts = Object.assign({}, ...composeVolumes) || {};
return { volumeMounts };
}
export async function startServiceContainers(fastify, id, teamId, dockerId, composeFileDestination) {
try {
// fastify.io.to(teamId).emit(`start-service`, { serviceId: id, state: 'Pulling images...' })
await executeCommand({ dockerId, command: `docker compose -f ${composeFileDestination} pull` })
} catch (error) { }
// fastify.io.to(teamId).emit(`start-service`, { serviceId: id, state: 'Building images...' })
await executeCommand({ dockerId, command: `docker compose -f ${composeFileDestination} build --no-cache` })
// fastify.io.to(teamId).emit(`start-service`, { serviceId: id, state: 'Creating containers...' })
await executeCommand({ dockerId, command: `docker compose -f ${composeFileDestination} create` })
// fastify.io.to(teamId).emit(`start-service`, { serviceId: id, state: 'Starting containers...' })
await executeCommand({ dockerId, command: `docker compose -f ${composeFileDestination} start` })
await asyncSleep(1000);
await executeCommand({ dockerId, command: `docker compose -f ${composeFileDestination} up -d` })
// fastify.io.to(teamId).emit(`start-service`, { serviceId: id, state: 0 })
}