system.executors.ts 12 KB

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