system.executors.ts 17 KB

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