system.executors.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430
  1. /* eslint-disable no-await-in-loop */
  2. import { Queue } from 'bullmq';
  3. import fs from 'fs';
  4. import cliProgress from 'cli-progress';
  5. import semver from 'semver';
  6. import axios from 'axios';
  7. import boxen from 'boxen';
  8. import path from 'path';
  9. import { exec, spawn } from 'child_process';
  10. import si from 'systeminformation';
  11. import { Stream } from 'stream';
  12. import { promisify } from 'util';
  13. import dotenv from 'dotenv';
  14. import { SystemEvent } from '@runtipi/shared';
  15. import { killOtherWorkers } from 'src/services/watcher/watcher';
  16. import chalk from 'chalk';
  17. import { AppExecutors } from '../app/app.executors';
  18. import { copySystemFiles, generateSystemEnvFile, generateTlsCertificates } from './system.helpers';
  19. import { TerminalSpinner } from '@/utils/logger/terminal-spinner';
  20. import { pathExists } from '@/utils/fs-helpers';
  21. import { getEnv } from '@/utils/environment/environment';
  22. import { fileLogger } from '@/utils/logger/file-logger';
  23. import { runPostgresMigrations } from '@/utils/migrations/run-migration';
  24. import { getUserIds } from '@/utils/environment/user';
  25. const execAsync = promisify(exec);
  26. export class SystemExecutors {
  27. private readonly rootFolder: string;
  28. private readonly envFile: string;
  29. constructor() {
  30. this.rootFolder = process.cwd();
  31. this.envFile = path.join(this.rootFolder, '.env');
  32. }
  33. private handleSystemError = (err: unknown) => {
  34. if (err instanceof Error) {
  35. fileLogger.error(`An error occurred: ${err.message}`);
  36. return { success: false, message: err.message };
  37. }
  38. fileLogger.error(`An error occurred: ${err}`);
  39. return { success: false, message: `An error occurred: ${err}` };
  40. };
  41. private getSystemLoad = async () => {
  42. const { currentLoad } = await si.currentLoad();
  43. const mem = await si.mem();
  44. const [disk0] = await si.fsSize();
  45. return {
  46. cpu: { load: currentLoad },
  47. memory: { total: mem.total, used: mem.used, available: mem.available },
  48. disk: { total: disk0?.size, used: disk0?.used, available: disk0?.available },
  49. };
  50. };
  51. private ensureFilePermissions = async (rootFolderHost: string, logSudoRequest = true) => {
  52. const logger = new TerminalSpinner('');
  53. // if we are running as root, we don't need to change permissions
  54. if (process.getuid && process.getuid() === 0) {
  55. return;
  56. }
  57. if (logSudoRequest) {
  58. logger.log('Tipi needs to change permissions on some files and folders and will ask for your password.');
  59. }
  60. // Create group tipi if it does not exist
  61. try {
  62. await execAsync('getent group tipi');
  63. } catch (e) {
  64. try {
  65. await execAsync('sudo groupadd tipi');
  66. logger.done('Created group tipi');
  67. } catch (e2) {
  68. logger.fail('Failed to create group tipi');
  69. fileLogger.error(e2);
  70. }
  71. }
  72. // Add current user to group tipi
  73. if (!(await execAsync(`groups ${process.env.USER}`)).stdout.includes('tipi')) {
  74. try {
  75. await execAsync(`sudo usermod -aG tipi ${process.env.USER}`);
  76. // Reload permissions
  77. await execAsync('newgrp tipi');
  78. } catch (e) {
  79. logger.fail('Failed to add current user to group tipi');
  80. }
  81. }
  82. const filesAndFolders = [
  83. path.join(rootFolderHost, 'apps'),
  84. path.join(rootFolderHost, 'app-data'),
  85. path.join(rootFolderHost, 'logs'),
  86. path.join(rootFolderHost, 'media'),
  87. path.join(rootFolderHost, 'repos'),
  88. path.join(rootFolderHost, 'state'),
  89. path.join(rootFolderHost, 'traefik'),
  90. path.join(rootFolderHost, '.env'),
  91. path.join(rootFolderHost, 'docker-compose.yml'),
  92. path.join(rootFolderHost, 'VERSION'),
  93. ];
  94. // Give permission to read and write to all files and folders for the current user
  95. await Promise.all(
  96. filesAndFolders.map(async (fileOrFolder) => {
  97. if (await pathExists(fileOrFolder)) {
  98. await execAsync(`sudo chmod -R a+rwx ${fileOrFolder}`);
  99. }
  100. }),
  101. );
  102. };
  103. public systemInfo = async () => {
  104. try {
  105. const { rootFolderHost } = getEnv();
  106. const systemLoad = await this.getSystemLoad();
  107. await fs.promises.writeFile(path.join(rootFolderHost, 'state', 'system-info.json'), JSON.stringify(systemLoad, null, 2));
  108. await fs.promises.chmod(path.join(rootFolderHost, 'state', 'system-info.json'), 0o777);
  109. return { success: true, message: '' };
  110. } catch (e) {
  111. return this.handleSystemError(e);
  112. }
  113. };
  114. /**
  115. * This method will stop Tipi
  116. * It will stop all the apps and then stop the main containers.
  117. */
  118. public stop = async () => {
  119. const spinner = new TerminalSpinner('Stopping Tipi...');
  120. try {
  121. if (await pathExists(path.join(this.rootFolder, 'apps'))) {
  122. const apps = await fs.promises.readdir(path.join(this.rootFolder, 'apps'));
  123. const appExecutor = new AppExecutors();
  124. // eslint-disable-next-line no-restricted-syntax
  125. for (const app of apps) {
  126. spinner.setMessage(`Stopping ${app}...`);
  127. spinner.start();
  128. await appExecutor.stopApp(app, {}, true);
  129. spinner.done(`${app} stopped`);
  130. }
  131. await Promise.all(
  132. apps.map(async (app) => {
  133. const appSpinner = new TerminalSpinner(`Stopping ${app}...`);
  134. appSpinner.start();
  135. await appExecutor.stopApp(app, {}, true);
  136. appSpinner.done(`${app} stopped`);
  137. }),
  138. );
  139. }
  140. spinner.setMessage('Stopping containers...');
  141. spinner.start();
  142. await execAsync('docker compose down --remove-orphans --rmi local');
  143. spinner.done('Tipi successfully stopped');
  144. return { success: true, message: 'Tipi stopped' };
  145. } catch (e) {
  146. spinner.fail('Tipi failed to stop. Please check the logs for more details (logs/error.log)');
  147. return this.handleSystemError(e);
  148. }
  149. };
  150. /**
  151. * This method will start Tipi.
  152. * It will copy the system files, generate the system env file, pull the images and start the containers.
  153. */
  154. public start = async () => {
  155. const spinner = new TerminalSpinner('Starting Tipi...');
  156. try {
  157. const { isSudo } = getUserIds();
  158. if (!isSudo) {
  159. console.log(chalk.red('Tipi needs to run as root to start. Use sudo ./runtipi-cli start'));
  160. throw new Error('Tipi needs to run as root to start. Use sudo ./runtipi-cli start');
  161. }
  162. await this.ensureFilePermissions(this.rootFolder);
  163. spinner.start();
  164. spinner.setMessage('Copying system files...');
  165. await copySystemFiles();
  166. spinner.done('System files copied');
  167. await this.ensureFilePermissions(this.rootFolder, false);
  168. spinner.setMessage('Generating system env file...');
  169. spinner.start();
  170. const envMap = await generateSystemEnvFile();
  171. spinner.done('System env file generated');
  172. // Reload env variables after generating the env file
  173. dotenv.config({ path: this.envFile, override: true });
  174. // Stop and Remove container tipi if exists
  175. spinner.setMessage('Stopping and removing containers...');
  176. spinner.start();
  177. await execAsync('docker rm -f tipi-db');
  178. await execAsync('docker rm -f tipi-redis');
  179. await execAsync('docker rm -f tipi-dashboard');
  180. await execAsync('docker rm -f tipi-reverse-proxy');
  181. spinner.done('Containers stopped and removed');
  182. // Pull images
  183. spinner.setMessage('Pulling images...');
  184. spinner.start();
  185. await execAsync(`docker compose --env-file "${this.envFile}" pull`);
  186. spinner.done('Images pulled');
  187. // Start containers
  188. spinner.setMessage('Starting containers...');
  189. spinner.start();
  190. await execAsync(`docker compose --env-file "${this.envFile}" up --detach --remove-orphans --build`);
  191. spinner.done('Containers started');
  192. // start watcher cli in the background
  193. spinner.setMessage('Starting watcher...');
  194. spinner.start();
  195. await generateTlsCertificates({ domain: envMap.get('LOCAL_DOMAIN') });
  196. const out = fs.openSync('./logs/watcher.log', 'a');
  197. const err = fs.openSync('./logs/watcher.log', 'a');
  198. await killOtherWorkers();
  199. const subprocess = spawn('./runtipi-cli', [process.argv[1] as string, 'watch'], { cwd: this.rootFolder, detached: true, stdio: ['ignore', out, err] });
  200. subprocess.unref();
  201. spinner.done('Watcher started');
  202. const queue = new Queue('events', { connection: { host: '127.0.0.1', port: 6379, password: envMap.get('REDIS_PASSWORD') } });
  203. await queue.obliterate({ force: true });
  204. // Initial jobs
  205. await queue.add(`${Math.random().toString()}_system_info`, { type: 'system', command: 'system_info' } as SystemEvent);
  206. await queue.add(`${Math.random().toString()}_repo_clone`, { type: 'repo', command: 'clone', url: envMap.get('APPS_REPO_URL') } as SystemEvent);
  207. // Scheduled jobs
  208. await queue.add(`${Math.random().toString()}_repo_update`, { type: 'repo', command: 'update', url: envMap.get('APPS_REPO_URL') } as SystemEvent, { repeat: { pattern: '*/30 * * * *' } });
  209. await queue.add(`${Math.random().toString()}_system_info`, { type: 'system', command: 'system_info' } as SystemEvent, { repeat: { pattern: '* * * * *' } });
  210. await queue.close();
  211. spinner.setMessage('Running database migrations...');
  212. spinner.start();
  213. await runPostgresMigrations({
  214. postgresHost: '127.0.0.1',
  215. postgresDatabase: envMap.get('POSTGRES_DBNAME') as string,
  216. postgresUsername: envMap.get('POSTGRES_USERNAME') as string,
  217. postgresPassword: envMap.get('POSTGRES_PASSWORD') as string,
  218. postgresPort: envMap.get('POSTGRES_PORT') as string,
  219. });
  220. spinner.done('Database migrations complete');
  221. console.log(
  222. boxen(`Visit: http://${envMap.get('INTERNAL_IP')}:${envMap.get('NGINX_PORT')} to access the dashboard\n\nFind documentation and guides at: https://runtipi.io`, {
  223. title: 'Tipi successfully started 🎉',
  224. titleAlignment: 'center',
  225. padding: 1,
  226. borderStyle: 'double',
  227. borderColor: 'green',
  228. margin: { top: 1 },
  229. }),
  230. );
  231. return { success: true, message: 'Tipi started' };
  232. } catch (e) {
  233. spinner.fail('Tipi failed to start. Please check the logs for more details (logs/error.log)');
  234. return this.handleSystemError(e);
  235. }
  236. };
  237. /**
  238. * This method will stop and start Tipi.
  239. */
  240. public restart = async () => {
  241. try {
  242. await this.stop();
  243. await this.start();
  244. return { success: true, message: '' };
  245. } catch (e) {
  246. return this.handleSystemError(e);
  247. }
  248. };
  249. /**
  250. * This method will create a password change request file in the state folder.
  251. */
  252. public resetPassword = async () => {
  253. try {
  254. const { rootFolderHost } = getEnv();
  255. await fs.promises.writeFile(path.join(rootFolderHost, 'state', 'password-change-request'), '');
  256. return { success: true, message: '' };
  257. } catch (e) {
  258. return this.handleSystemError(e);
  259. }
  260. };
  261. /**
  262. * Given a target version, this method will download the corresponding release from GitHub and replace the current
  263. * runtipi-cli binary with the new one.
  264. * @param {string} target
  265. */
  266. public update = async (target: string) => {
  267. const spinner = new TerminalSpinner('Evaluating target version...');
  268. try {
  269. spinner.start();
  270. let targetVersion = target;
  271. if (!targetVersion || targetVersion === 'latest') {
  272. spinner.setMessage('Fetching latest version...');
  273. const { data } = await axios.get<{ tag_name: string }>('https://api.github.com/repos/meienberger/runtipi/releases');
  274. targetVersion = data.tag_name;
  275. }
  276. if (!semver.valid(targetVersion)) {
  277. spinner.fail(`Invalid version: ${targetVersion}`);
  278. throw new Error(`Invalid version: ${targetVersion}`);
  279. }
  280. const { rootFolderHost, arch } = getEnv();
  281. let assetName = 'runtipi-cli-linux-x64';
  282. if (arch === 'arm64') {
  283. assetName = 'runtipi-cli-linux-arm64';
  284. }
  285. const fileName = `runtipi-cli-${targetVersion}`;
  286. const savePath = path.join(rootFolderHost, fileName);
  287. const fileUrl = `https://github.com/meienberger/runtipi/releases/download/${targetVersion}/${assetName}`;
  288. spinner.done(`Target version: ${targetVersion}`);
  289. spinner.done(`Download url: ${fileUrl}`);
  290. await this.stop();
  291. console.log(`Downloading Tipi ${targetVersion}...`);
  292. const bar = new cliProgress.SingleBar({}, cliProgress.Presets.rect);
  293. bar.start(100, 0);
  294. await new Promise((resolve, reject) => {
  295. axios<Stream>({
  296. method: 'GET',
  297. url: fileUrl,
  298. responseType: 'stream',
  299. onDownloadProgress: (progress) => {
  300. bar.update(Math.round((progress.loaded / (progress.total || 0)) * 100));
  301. },
  302. }).then((response) => {
  303. const writer = fs.createWriteStream(savePath);
  304. response.data.pipe(writer);
  305. writer.on('error', (err) => {
  306. bar.stop();
  307. spinner.fail(`\nFailed to download Tipi ${targetVersion}`);
  308. reject(err);
  309. });
  310. writer.on('finish', () => {
  311. bar.stop();
  312. resolve('');
  313. });
  314. });
  315. }).catch((e) => {
  316. spinner.fail(`\nFailed to download Tipi ${targetVersion}. Please make sure this version exists on GitHub.`);
  317. throw e;
  318. });
  319. spinner.done(`Tipi ${targetVersion} downloaded`);
  320. await fs.promises.chmod(savePath, 0o755);
  321. spinner.setMessage('Replacing old cli...');
  322. spinner.start();
  323. // Delete old cli
  324. if (await pathExists(path.join(rootFolderHost, 'runtipi-cli'))) {
  325. await fs.promises.unlink(path.join(rootFolderHost, 'runtipi-cli'));
  326. }
  327. // Delete VERSION file
  328. if (await pathExists(path.join(rootFolderHost, 'VERSION'))) {
  329. await fs.promises.unlink(path.join(rootFolderHost, 'VERSION'));
  330. }
  331. // Rename downloaded cli to runtipi-cli
  332. await fs.promises.rename(savePath, path.join(rootFolderHost, 'runtipi-cli'));
  333. spinner.done('Old cli replaced');
  334. // Wait for 3 second to make sure the old cli is gone
  335. // eslint-disable-next-line no-promise-executor-return
  336. await new Promise((resolve) => setTimeout(resolve, 3000));
  337. const childProcess = spawn('./runtipi-cli', [process.argv[1] as string, 'start']);
  338. childProcess.stdout.on('data', (data) => {
  339. process.stdout.write(data);
  340. });
  341. childProcess.stderr.on('data', (data) => {
  342. process.stderr.write(data);
  343. });
  344. spinner.done(`Tipi ${targetVersion} successfully updated. Please run './runtipi-cli start' to start Tipi again.`);
  345. return { success: true, message: 'Tipi updated' };
  346. } catch (e) {
  347. spinner.fail('Tipi update failed, see logs for more details (logs/error.log)');
  348. return this.handleSystemError(e);
  349. }
  350. };
  351. }