Files
stats/self-hosting/quiz.ts

448 lines
12 KiB
TypeScript

import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import bcrypt from 'bcrypt';
import inquirer from 'inquirer';
import yaml from 'js-yaml';
let envs = {
CLICKHOUSE_URL: '',
REDIS_URL: '',
DATABASE_URL: '',
DOMAIN_NAME: '',
COOKIE_SECRET: generatePassword(32),
RESEND_API_KEY: '',
EMAIL_SENDER: '',
};
type EnvVars = typeof envs;
const addEnvs = (env: Partial<EnvVars>) => {
envs = {
...envs,
...env,
};
};
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(
import.meta.dirname,
'caddy',
'Caddyfile.template'
);
const caddyfilePath = path.resolve(import.meta.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?: Record<string, { condition: string }> | 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(
import.meta.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(
import.meta.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[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) => {
const serviceConfig = dockerCompose.services[service];
if (serviceConfig?.depends_on) {
if (Array.isArray(serviceConfig.depends_on)) {
// Handle legacy array format
serviceConfig.depends_on = serviceConfig.depends_on.filter(
(dep) => dep !== serviceName
);
} else {
// Handle new object format
if (serviceConfig.depends_on[serviceName]) {
delete serviceConfig.depends_on[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: EnvVars) {
const envTemplatePath = path.resolve(import.meta.dirname, '.env.template');
const envPath = path.resolve(import.meta.dirname, '.env');
const envTemplate = fs.readFileSync(envTemplatePath, 'utf-8');
const newEnvFile = envTemplate
.replace('$COOKIE_SECRET', envs.COOKIE_SECRET)
.replace('$CLICKHOUSE_URL', envs.CLICKHOUSE_URL)
.replace('$REDIS_URL', envs.REDIS_URL)
.replace('$DATABASE_URL', envs.DATABASE_URL)
.replace('$DATABASE_URL_DIRECT', envs.DATABASE_URL)
.replace('$DASHBOARD_URL', stripTrailingSlash(envs.DOMAIN_NAME))
.replace('$API_URL', `${stripTrailingSlash(envs.DOMAIN_NAME)}/api`)
.replace('$RESEND_API_KEY', envs.RESEND_API_KEY)
.replace('$EMAIL_SENDER', envs.EMAIL_SENDER);
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`,
'For more information you can read our article on self-hosting at https://openpanel.dev/docs/self-hosting/self-hosting\n',
'',
'',
'Consider supporting us by becoming a supporter: https://openpanel.dev/supporter (pay what you want and help us keep the lights on)',
];
console.log(
'******************************************************************************\n'
);
console.log(message.join('\n'));
console.log(
'\n******************************************************************************'
);
// Domain name
const domain = await inquirer.prompt([
{
type: 'input',
name: 'DOMAIN_NAME',
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://"';
},
},
]);
addEnvs(domain);
// 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: '📦',
},
]);
if (!dependenciesResponse.dependencies.includes('Clickhouse')) {
const clickhouseResponse = await inquirer.prompt([
{
type: 'input',
name: 'CLICKHOUSE_URL',
message:
'Enter your ClickHouse URL (format: http://user:pw@host:port/db):',
default: process.env.DEBUG ? 'http://op-ch:8123/openpanel' : undefined,
},
]);
addEnvs(clickhouseResponse);
}
if (!dependenciesResponse.dependencies.includes('Redis')) {
const redisResponse = await inquirer.prompt([
{
type: 'input',
name: 'REDIS_URL',
message: 'Enter your Redis URL (format: redis://user:pw@host:port/db):',
default: process.env.DEBUG ? 'redis://op-kv:6379' : undefined,
},
]);
addEnvs(redisResponse);
}
if (!dependenciesResponse.dependencies.includes('Postgres')) {
const dbResponse = await inquirer.prompt([
{
type: 'input',
name: 'DATABASE_URL',
message:
'Enter your Database URL (format: postgresql://user:pw@host:port/db):',
default: process.env.DEBUG
? 'postgresql://postgres:postgres@op-db:5432/postgres?schema=public'
: undefined,
},
]);
addEnvs(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'],
},
]);
// OS
const cpus = await inquirer.prompt([
{
type: 'input',
name: 'CPUS',
default: Math.max(Math.floor(os.cpus().length / 2), 1),
message:
'How many workers do you want to spawn (in many cases 1-2 is enough)?',
validate: (value) => {
const parsed = Number.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 resend = await inquirer.prompt<{
RESEND_API_KEY: string;
}>([
{
type: 'input',
name: 'RESEND_API_KEY',
message: 'Enter your Resend API key (optional):',
},
]);
if (resend.RESEND_API_KEY) {
const emailSender = await inquirer.prompt<{
email: string;
}>([
{
type: 'input',
name: 'EMAIL_SENDER',
default: `no-reply@${envs.DOMAIN_NAME.replace(/https?:\/\//, '')}`,
message: 'The email which will be used to send out emails:',
validate: (value) => {
if (!value) {
return 'Field is required';
}
if (!value.includes('@')) {
return 'Please enter a valid email';
}
return true;
},
},
]);
addEnvs({
...resend,
...emailSender,
});
}
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');
writeEnvFile({
CLICKHOUSE_URL: envs.CLICKHOUSE_URL || 'http://op-ch:8123/openpanel',
REDIS_URL: envs.REDIS_URL || 'redis://op-kv:6379',
DATABASE_URL:
envs.DATABASE_URL ||
'postgresql://postgres:postgres@op-db:5432/postgres?schema=public',
DOMAIN_NAME: envs.DOMAIN_NAME,
COOKIE_SECRET: envs.COOKIE_SECRET,
RESEND_API_KEY: envs.RESEND_API_KEY || '',
EMAIL_SENDER: envs.EMAIL_SENDER || '',
});
console.log('Updating docker-compose.yml file...\n');
fs.copyFileSync(
path.resolve(import.meta.dirname, 'docker-compose.template.yml'),
path.resolve(import.meta.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(envs.DOMAIN_NAME, basicAuth.password);
}
searchAndReplaceDockerCompose([['$OP_WORKER_REPLICAS', cpus.CPUS]]);
console.log(
[
'======================================================================',
'Here are some good things to know before you continue:',
'',
'1. Commands:',
'\t- ./start (example: ./start)',
'\t- ./stop (example: ./stop)',
'\t- ./logs (example: ./logs)',
'\t- ./rebuild (example: ./rebuild op-dashboard)',
'\t- ./update (example: ./update) pulls the latest docker images and restarts the service',
'',
'2. Danger zone!',
'\t- ./danger_wipe_everything (example: ./danger_wipe_everything)',
'',
'3. More about self-hosting: https://openpanel.dev/docs/self-hosting/self-hosting',
'======================================================================',
'',
`Start OpenPanel with "./start" inside the self-hosting directory`,
'',
'',
].join('\n')
);
}
initiateOnboarding();