Files
stats/self-hosting/quiz.ts
Carl-Gerhard Lindesvärd df05e2dab3 Self-hosting! (#49)
* added self-hosting
2024-08-28 09:28:44 +02:00

477 lines
14 KiB
TypeScript

import fs from 'fs';
import os from 'os';
import path from 'path';
import bcrypt from 'bcrypt';
import inquirer from 'inquirer';
import yaml from 'js-yaml';
function generatePassword(length: number) {
const charset =
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let password = '';
for (let i = 0, n = charset.length; i < length; ++i) {
password += charset.charAt(Math.floor(Math.random() * n));
}
return password;
}
function writeCaddyfile(domainName: string, basicAuthPassword: string) {
const caddyfileTemplatePath = path.resolve(
__dirname,
'caddy',
'Caddyfile.template'
);
const caddyfilePath = path.resolve(__dirname, 'caddy', 'Caddyfile');
fs.writeFileSync(
caddyfilePath,
fs
.readFileSync(caddyfileTemplatePath, 'utf-8')
.replaceAll('$DOMAIN_NAME', domainName.replace(/https?:\/\//, ''))
.replaceAll(
'$BASIC_AUTH_PASSWORD',
bcrypt.hashSync(basicAuthPassword, 10)
)
.replaceAll(
'$SSL_CONFIG',
domainName.includes('localhost:443') ? '\n\ttls internal' : ''
)
);
}
export interface DockerComposeFile {
version: string;
services: Record<
string,
{
image: string;
restart: string;
ports: string[];
volumes: string[];
depends_on: string[];
}
>;
volumes?: Record<string, unknown>;
}
const stripTrailingSlash = (str: string) =>
str.endsWith('/') ? str.slice(0, -1) : str;
function searchAndReplaceDockerCompose(replacements: [string, string][]) {
const dockerComposePath = path.resolve(__dirname, 'docker-compose.yml');
const dockerComposeContent = fs.readFileSync(dockerComposePath, 'utf-8');
const dockerComposeReplaced = replacements.reduce(
(acc, [search, replace]) => acc.replaceAll(search, replace),
dockerComposeContent
);
fs.writeFileSync(dockerComposePath, dockerComposeReplaced);
}
function removeServiceFromDockerCompose(serviceName: string) {
const dockerComposePath = path.resolve(__dirname, 'docker-compose.yml');
const dockerComposeContent = fs.readFileSync(dockerComposePath, 'utf-8');
// Parse the YAML file
const dockerCompose = yaml.load(dockerComposeContent) as DockerComposeFile;
// Remove the service
if (dockerCompose.services && dockerCompose.services[serviceName]) {
delete dockerCompose.services[serviceName];
console.log(`Service '${serviceName}' has been removed.`);
} else {
console.log(`Service '${serviceName}' not found.`);
// return;
}
// filter depends_on
Object.keys(dockerCompose.services).forEach((service) => {
if (dockerCompose.services[service]?.depends_on) {
// @ts-expect-error
dockerCompose.services[service].depends_on = dockerCompose.services[
service
].depends_on.filter((dep) => dep !== serviceName);
}
});
// filter volumes
Object.keys(dockerCompose.volumes ?? {}).forEach((volume) => {
if (dockerCompose.volumes && volume.startsWith(serviceName)) {
delete dockerCompose.volumes[volume];
}
});
if (Object.keys(dockerCompose.volumes ?? {}).length === 0) {
delete dockerCompose.volumes;
}
// Convert the object back to YAML
const newYaml = yaml.dump(dockerCompose, {
lineWidth: -1,
});
fs.writeFileSync(dockerComposePath, newYaml);
}
function writeEnvFile(envs: {
POSTGRES_PASSWORD: string | undefined;
REDIS_PASSWORD: string | undefined;
CLICKHOUSE_URL: string;
CLICKHOUSE_DB: string;
CLICKHOUSE_USER: string;
CLICKHOUSE_PASSWORD: string;
REDIS_URL: string;
DATABASE_URL: string;
DOMAIN_NAME: string;
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: string;
CLERK_SECRET_KEY: string;
CLERK_SIGNING_SECRET: string;
}) {
const envTemplatePath = path.resolve(__dirname, '.env.template');
const envPath = path.resolve(__dirname, '.env');
const envTemplate = fs.readFileSync(envTemplatePath, 'utf-8');
let newEnvFile = envTemplate
.replace('$CLICKHOUSE_URL', envs.CLICKHOUSE_URL)
.replace('$CLICKHOUSE_DB', envs.CLICKHOUSE_DB)
.replace('$CLICKHOUSE_USER', envs.CLICKHOUSE_USER)
.replace('$CLICKHOUSE_PASSWORD', envs.CLICKHOUSE_PASSWORD)
.replace('$REDIS_URL', envs.REDIS_URL)
.replace('$DATABASE_URL', envs.DATABASE_URL)
.replace('$DATABASE_URL_DIRECT', envs.DATABASE_URL)
.replace('$NEXT_PUBLIC_DASHBOARD_URL', stripTrailingSlash(envs.DOMAIN_NAME))
.replace(
'$NEXT_PUBLIC_API_URL',
`${stripTrailingSlash(envs.DOMAIN_NAME)}/api`
)
.replace(
'$NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY',
envs.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
)
.replace('$CLERK_SECRET_KEY', envs.CLERK_SECRET_KEY)
.replace('$CLERK_SIGNING_SECRET', envs.CLERK_SIGNING_SECRET);
if (envs.POSTGRES_PASSWORD) {
newEnvFile += `\nPOSTGRES_PASSWORD=${envs.POSTGRES_PASSWORD}`;
}
fs.writeFileSync(
envPath,
newEnvFile
.split('\n')
.filter((line) => {
return !line.includes('=""');
})
.join('\n')
);
}
async function initiateOnboarding() {
const T = ' ';
const message = [
'',
'DISCLAIMER: This script is provided as-is and without warranty. Use at your own risk.',
'',
'',
'WORTH MENTIONING: This is an early version of the script and it may not cover all scenarios.',
' We recommend using our cloud service for production workloads until we release a stable version of self-hosting.',
'',
'',
"With that said let's get started! 🤠",
'',
`Hey and welcome to Openpanel's self-hosting setup! 🚀\n`,
`Before you continue, please make sure you have the following:`,
`${T}1. Docker and Docker Compose installed on your machine.`,
`${T}2. A domain name that you can use for this setup and point it to this machine's ip`,
`${T}3. A Clerk.com account`,
`${T}${T}- If you don't have one, you can create one at https://clerk.dev`,
`${T}${T}- We'll need NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY, CLERK_SECRET_KEY, CLERK_SIGNING_SECRET`,
`${T}${T}- Create a webhook pointing to https://your_domain/api/webhook/clerk\n`,
'For more information you can read our article on self-hosting at https://docs.openpanel.dev/docs/self-hosting\n',
];
console.log(
'******************************************************************************\n'
);
console.log(message.join('\n'));
console.log(
'\n******************************************************************************'
);
// Domain name
const domainNameResponse = await inquirer.prompt([
{
type: 'input',
name: 'domainName',
message: "What's the domain name you want to use?",
default: process.env.DEBUG ? 'http://localhost' : undefined,
prefix: '🌐',
validate: (value) => {
if (value.startsWith('http://') || value.startsWith('https://')) {
return true;
}
return 'Please enter a valid domain name. Should start with "http://" or "https://"';
},
},
]);
// Dependencies
const dependenciesResponse = await inquirer.prompt([
{
type: 'checkbox',
name: 'dependencies',
message: 'Which of these dependencies will you need us to install?',
choices: ['Clickhouse', 'Redis', 'Postgres'],
default: ['Clickhouse', 'Redis', 'Postgres'],
prefix: '📦',
},
]);
let envs: Record<string, string> = {};
if (!dependenciesResponse.dependencies.includes('Clickhouse')) {
const clickhouseResponse = await inquirer.prompt([
{
type: 'input',
name: 'CLICKHOUSE_URL',
message: 'Enter your ClickHouse URL:',
default: process.env.DEBUG ? 'http://clickhouse:8123' : undefined,
},
{
type: 'input',
name: 'CLICKHOUSE_DB',
message: 'Enter your ClickHouse DB name:',
default: process.env.DEBUG ? 'db_openpanel' : undefined,
},
{
type: 'input',
name: 'CLICKHOUSE_USER',
message: 'Enter your ClickHouse user name:',
default: process.env.DEBUG ? 'user_openpanel' : undefined,
},
{
type: 'input',
name: 'CLICKHOUSE_PASSWORD',
message: 'Enter your ClickHouse password:',
default: process.env.DEBUG ? 'ch_password' : undefined,
},
]);
envs = {
...envs,
...clickhouseResponse,
};
}
if (!dependenciesResponse.dependencies.includes('Redis')) {
const redisResponse = await inquirer.prompt([
{
type: 'input',
name: 'REDIS_URL',
message: 'Enter your Redis URL:',
default: process.env.DEBUG ? 'redis://redis:6379' : undefined,
},
]);
envs = {
...envs,
...redisResponse,
};
}
if (!dependenciesResponse.dependencies.includes('Postgres')) {
const dbResponse = await inquirer.prompt([
{
type: 'input',
name: 'DATABASE_URL',
message: 'Enter your Database URL:',
default: process.env.DEBUG
? 'postgresql://postgres:postgres@postgres:5432/postgres?schema=public'
: undefined,
},
]);
envs = {
...envs,
...dbResponse,
};
}
// Proxy
const proxyResponse = await inquirer.prompt([
{
type: 'list',
name: 'proxy',
message:
'Do you already have a web service setup or would you like us to install Caddy with SSL?',
choices: ['Install Caddy with SSL', 'Bring my own'],
},
]);
// Clerk
const clerkResponse = await inquirer.prompt([
{
type: 'input',
name: 'NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY',
message: 'Enter your Clerk Publishable Key:',
default: process.env.DEBUG ? 'pk_test_1234567890' : undefined,
validate: (value) => {
if (value.startsWith('pk_live_') || value.startsWith('pk_test_')) {
return true;
}
return 'Please enter a valid Clerk Publishable Key. Should start with "pk_live_" or "pk_test_"';
},
},
{
type: 'input',
name: 'CLERK_SECRET_KEY',
message: 'Enter your Clerk Secret Key:',
default: process.env.DEBUG ? 'sk_test_1234567890' : undefined,
validate: (value) => {
if (value.startsWith('sk_live_') || value.startsWith('sk_test_')) {
return true;
}
return 'Please enter a valid Clerk Secret Key. Should start with "sk_live_" or "sk_test_"';
},
},
{
type: 'input',
name: 'CLERK_SIGNING_SECRET',
message: 'Enter your Clerk Signing Secret:',
default: process.env.DEBUG ? 'whsec_1234567890' : undefined,
validate: (value) => {
if (value.startsWith('whsec_')) {
return true;
}
return 'Please enter a valid Clerk Signing Secret. Should start with "whsec_"';
},
},
]);
// OS
const cpus = await inquirer.prompt([
{
type: 'input',
name: 'CPUS',
default: os.cpus().length,
message: 'How many CPUs do you have?',
validate: (value) => {
const parsed = parseInt(value, 10);
if (Number.isNaN(parsed)) {
return 'Please enter a valid number';
}
if (parsed < 1) {
return 'Please enter a number greater than 0';
}
return true;
},
},
]);
const basicAuth = await inquirer.prompt<{
password: string;
}>([
{
type: 'input',
name: 'password',
default: generatePassword(12),
message: 'Give a password for basic auth',
validate: (value) => {
if (!value) {
return 'Please enter a valid password';
}
if (value.length < 5) {
return 'Password should be atleast 5 characters';
}
return true;
},
},
]);
console.log('');
console.log('Creating .env file...\n');
const POSTGRES_PASSWORD = generatePassword(20);
const REDIS_PASSWORD = generatePassword(20);
writeEnvFile({
POSTGRES_PASSWORD: envs.DATABASE_URL ? undefined : POSTGRES_PASSWORD,
REDIS_PASSWORD: envs.REDIS_URL ? undefined : REDIS_PASSWORD,
CLICKHOUSE_URL: envs.CLICKHOUSE_URL || 'http://op-ch:8123',
CLICKHOUSE_DB: envs.CLICKHOUSE_DB || 'openpanel',
CLICKHOUSE_USER: envs.CLICKHOUSE_USER || 'openpanel',
CLICKHOUSE_PASSWORD: envs.CLICKHOUSE_PASSWORD || generatePassword(20),
REDIS_URL: envs.REDIS_URL || 'redis://op-kv:6379',
DATABASE_URL:
envs.DATABASE_URL ||
`postgresql://postgres:${POSTGRES_PASSWORD}@op-db:5432/postgres?schema=public`,
DOMAIN_NAME: domainNameResponse.domainName,
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY:
clerkResponse.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY || '',
CLERK_SECRET_KEY: clerkResponse.CLERK_SECRET_KEY || '',
CLERK_SIGNING_SECRET: clerkResponse.CLERK_SIGNING_SECRET || '',
});
console.log('Updating docker-compose.yml file...\n');
fs.copyFileSync(
path.resolve(__dirname, 'docker-compose.template.yml'),
path.resolve(__dirname, 'docker-compose.yml')
);
if (envs.CLICKHOUSE_URL) {
removeServiceFromDockerCompose('op-ch');
removeServiceFromDockerCompose('op-ch-migrator');
}
if (envs.REDIS_URL) {
removeServiceFromDockerCompose('op-kv');
}
if (envs.DATABASE_URL) {
removeServiceFromDockerCompose('op-db');
}
if (proxyResponse.proxy === 'Bring my own') {
removeServiceFromDockerCompose('op-proxy');
} else {
writeCaddyfile(domainNameResponse.domainName, basicAuth.password);
}
searchAndReplaceDockerCompose([['$OP_WORKER_REPLICAS', cpus.CPUS]]);
console.log(
[
'======================================================================',
'Here are some good things to know before you continue:',
'',
`1. Make sure that your webhook is pointing at ${domainNameResponse.domainName}/api/webhook/clerk`,
'',
'2. Commands:',
'\t- ./start (example: ./start)',
'\t- ./stop (example: ./stop)',
'\t- ./logs (example: ./logs)',
'\t- ./rebuild (example: ./rebuild op-dashboard)',
'',
'3. Danger zone!',
'\t- ./danger_wipe_everything (example: ./danger_wipe_everything)',
'',
'4. More about self-hosting: https://docs.openpanel.dev/docs/self-hosting',
'======================================================================',
'',
`Start OpenPanel with "./start" inside the self-hosting directory`,
'',
'',
].join('\n')
);
}
initiateOnboarding();