ソースを参照

Merge pull request #677 from meienberger/release/2.0.3

Release/2.0.3
Nicolas Meienberger 1 年間 前
コミット
7c40c7e0da

+ 2 - 0
.github/workflows/alpha-release.yml

@@ -121,6 +121,8 @@ jobs:
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
         with:
+          body: |
+            **${{ needs.create-tag.outputs.tagname }}**
           tag_name: ${{ needs.create-tag.outputs.tagname }}
           release_name: ${{ needs.create-tag.outputs.tagname }}
           draft: false

+ 1 - 0
.github/workflows/release.yml

@@ -54,6 +54,7 @@ jobs:
 
   build-cli:
     runs-on: ubuntu-latest
+    timeout-minutes: 10
     needs: get-tag
     steps:
       - name: Checkout code

+ 5 - 0
codecov.yml

@@ -5,3 +5,8 @@ ignore:
   - 'screenshots'
   - '**/*.json'
   - '**/tests/**'
+coverage:
+  status:
+    project:
+      default:
+        informational: true

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "runtipi",
-  "version": "2.0.2",
+  "version": "2.0.3",
   "description": "A homeserver for everyone",
   "scripts": {
     "knip": "knip",

+ 1 - 1
packages/cli/assets/docker-compose.yml

@@ -39,7 +39,7 @@ services:
 
   tipi-redis:
     container_name: tipi-redis
-    image: redis:7.2.0-alpine
+    image: redis:7.2.0
     restart: on-failure
     command: redis-server --requirepass ${REDIS_PASSWORD}
     ports:

+ 1 - 1
packages/cli/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@runtipi/cli",
-  "version": "2.0.2",
+  "version": "2.0.3",
   "description": "",
   "main": "index.js",
   "bin": "dist/index.js",

+ 17 - 21
packages/cli/src/executors/repo/repo.executors.ts

@@ -75,39 +75,35 @@ export class RepoExecutors {
 
       this.logger.info(`Pulling repo ${repoUrl} to ${repoPath}`);
 
-      await execAsync(`git config --global --add safe.directory ${repoPath}`).then(({ stdout, stderr }) => {
-        this.logger.info('------------------ git config --global --add safe.directory ------------------');
-        this.logger.error(`stderr: ${stderr}`);
-        this.logger.info(`stdout: ${stdout}`);
+      this.logger.info(`Executing: git config --global --add safe.directory ${repoPath}`);
+      await execAsync(`git config --global --add safe.directory ${repoPath}`).then(({ stderr }) => {
+        if (stderr) {
+          this.logger.error(`stderr: ${stderr}`);
+        }
       });
 
       // git config pull.rebase false
-      await execAsync(`git -C ${repoPath} config pull.rebase false`).then(({ stdout, stderr }) => {
-        this.logger.info(`------------------ git -C ${repoPath} config pull.rebase false ------------------`);
-        this.logger.error(`stderr: ${stderr}`);
-        this.logger.info(`stdout: ${stdout}`);
+      this.logger.info(`Executing: git -C ${repoPath} config pull.rebase false`);
+      await execAsync(`git -C ${repoPath} config pull.rebase false`).then(({ stderr }) => {
+        if (stderr) {
+          this.logger.error(`stderr: ${stderr}`);
+        }
       });
 
+      this.logger.info(`Executing: git -C ${repoPath} rev-parse --abbrev-ref HEAD`);
       const currentBranch = await execAsync(`git -C ${repoPath} rev-parse --abbrev-ref HEAD`).then(({ stdout }) => {
         return stdout.trim();
       });
 
-      // reset hard
-      await execAsync(`git -C ${repoPath} fetch origin && git -C ${repoPath} reset --hard origin/${currentBranch}`).then(({ stdout, stderr }) => {
-        this.logger.info(`------------------ git -C ${repoPath} reset --hard ------------------`);
-        this.logger.error(`stderr: ${stderr}`);
-        this.logger.info(`stdout: ${stdout}`);
+      this.logger.info(`Executing: git -C ${repoPath} fetch origin && git -C ${repoPath} reset --hard origin/${currentBranch}`);
+      await execAsync(`git -C ${repoPath} fetch origin && git -C ${repoPath} reset --hard origin/${currentBranch}`).then(({ stderr }) => {
+        if (stderr) {
+          this.logger.error(`stderr: ${stderr}`);
+        }
       });
 
-      const { stderr, stdout } = await execAsync(`git -C ${repoPath} pull`);
-
-      if (stderr) {
-        this.logger.error(`Error pulling repo ${repoUrl}: ${stderr}`);
-        return { success: false, message: stderr };
-      }
-
       this.logger.info(`Pulled repo ${repoUrl} to ${repoPath}`);
-      return { success: true, message: stdout };
+      return { success: true, message: '' };
     } catch (err) {
       return this.handleRepoError(err);
     }

+ 91 - 50
packages/cli/src/executors/system/system.executors.ts

@@ -1,3 +1,4 @@
+/* eslint-disable no-restricted-syntax */
 /* eslint-disable no-await-in-loop */
 import { Queue } from 'bullmq';
 import fs from 'fs';
@@ -12,8 +13,8 @@ import { Stream } from 'stream';
 import { promisify } from 'util';
 import dotenv from 'dotenv';
 import { SystemEvent } from '@runtipi/shared';
-import { killOtherWorkers } from 'src/services/watcher/watcher';
 import chalk from 'chalk';
+import { killOtherWorkers } from 'src/services/watcher/watcher';
 import { AppExecutors } from '../app/app.executors';
 import { copySystemFiles, generateSystemEnvFile, generateTlsCertificates } from './system.helpers';
 import { TerminalSpinner } from '@/utils/logger/terminal-spinner';
@@ -30,18 +31,21 @@ export class SystemExecutors {
 
   private readonly envFile: string;
 
+  private readonly logger;
+
   constructor() {
     this.rootFolder = process.cwd();
+    this.logger = fileLogger;
 
     this.envFile = path.join(this.rootFolder, '.env');
   }
 
   private handleSystemError = (err: unknown) => {
     if (err instanceof Error) {
-      fileLogger.error(`An error occurred: ${err.message}`);
+      this.logger.error(`An error occurred: ${err.message}`);
       return { success: false, message: err.message };
     }
-    fileLogger.error(`An error occurred: ${err}`);
+    this.logger.error(`An error occurred: ${err}`);
 
     return { success: false, message: `An error occurred: ${err}` };
   };
@@ -63,39 +67,56 @@ export class SystemExecutors {
 
     const filesAndFolders = [
       path.join(rootFolderHost, 'apps'),
-      path.join(rootFolderHost, 'app-data'),
       path.join(rootFolderHost, 'logs'),
       path.join(rootFolderHost, 'media'),
       path.join(rootFolderHost, 'repos'),
       path.join(rootFolderHost, 'state'),
       path.join(rootFolderHost, 'traefik'),
       path.join(rootFolderHost, '.env'),
-      path.join(rootFolderHost, 'docker-compose.yml'),
       path.join(rootFolderHost, 'VERSION'),
+      path.join(rootFolderHost, 'docker-compose.yml'),
     ];
 
     const files600 = [path.join(rootFolderHost, 'traefik', 'shared', 'acme.json')];
 
+    this.logger.info('Setting file permissions a+rwx on required files');
     // Give permission to read and write to all files and folders for the current user
-    await Promise.all(
-      filesAndFolders.map(async (fileOrFolder) => {
-        if (await pathExists(fileOrFolder)) {
-          await execAsync(`sudo chmod -R a+rwx ${fileOrFolder}`).catch(() => {
-            logger.fail(`Failed to set permissions on ${fileOrFolder}`);
-          });
-        }
-      }),
-    );
-
-    await Promise.all(
-      files600.map(async (fileOrFolder) => {
-        if (await pathExists(fileOrFolder)) {
-          await execAsync(`sudo chmod 600 ${fileOrFolder}`).catch(() => {
-            logger.fail(`Failed to set permissions on ${fileOrFolder}`);
-          });
-        }
-      }),
-    );
+    for (const fileOrFolder of filesAndFolders) {
+      if (await pathExists(fileOrFolder)) {
+        this.logger.info(`Setting permissions on ${fileOrFolder}`);
+        await execAsync(`chmod -R a+rwx ${fileOrFolder}`).catch(() => {
+          logger.fail(`Failed to set permissions on ${fileOrFolder}`);
+        });
+        this.logger.info(`Successfully set permissions on ${fileOrFolder}`);
+      }
+    }
+
+    this.logger.info('Setting file permissions 600 on required files');
+
+    for (const fileOrFolder of files600) {
+      if (await pathExists(fileOrFolder)) {
+        this.logger.info(`Setting permissions on ${fileOrFolder}`);
+        await execAsync(`chmod 600 ${fileOrFolder}`).catch(() => {
+          logger.fail(`Failed to set permissions on ${fileOrFolder}`);
+        });
+        this.logger.info(`Successfully set permissions on ${fileOrFolder}`);
+      }
+    }
+  };
+
+  public cleanLogs = async () => {
+    try {
+      const { rootFolderHost } = getEnv();
+
+      await fs.promises.rm(path.join(rootFolderHost, 'logs'), { recursive: true, force: true });
+      await fs.promises.mkdir(path.join(rootFolderHost, 'logs'));
+
+      this.logger.info('Logs cleaned successfully');
+
+      return { success: true, message: '' };
+    } catch (e) {
+      return this.handleSystemError(e);
+    }
   };
 
   public systemInfo = async () => {
@@ -135,6 +156,8 @@ export class SystemExecutors {
 
       spinner.setMessage('Stopping containers...');
       spinner.start();
+
+      this.logger.info('Stopping main containers...');
       await execAsync('docker compose down --remove-orphans --rmi local');
 
       spinner.done('Tipi successfully stopped');
@@ -150,7 +173,7 @@ export class SystemExecutors {
    * This method will start Tipi.
    * It will copy the system files, generate the system env file, pull the images and start the containers.
    */
-  public start = async (sudo = true) => {
+  public start = async (sudo = true, killWatchers = true) => {
     const spinner = new TerminalSpinner('Starting Tipi...');
     try {
       const { isSudo } = getUserIds();
@@ -173,17 +196,21 @@ export class SystemExecutors {
         );
       }
 
+      this.logger.info('Killing other workers...');
+
+      if (killWatchers) {
+        await killOtherWorkers();
+      }
+
       if (!isSudo && sudo) {
         console.log(chalk.red('Tipi needs to run as root to start. Use sudo ./runtipi-cli start'));
         throw new Error('Tipi needs to run as root to start. Use sudo ./runtipi-cli start');
       }
 
-      if (sudo) {
-        await this.ensureFilePermissions(this.rootFolder);
-      }
-
       spinner.start();
       spinner.setMessage('Copying system files...');
+
+      this.logger.info('Copying system files...');
       await copySystemFiles();
 
       spinner.done('System files copied');
@@ -194,67 +221,67 @@ export class SystemExecutors {
 
       spinner.setMessage('Generating system env file...');
       spinner.start();
+      this.logger.info('Generating system env file...');
       const envMap = await generateSystemEnvFile();
       spinner.done('System env file generated');
 
       // Reload env variables after generating the env file
+      this.logger.info('Reloading env variables...');
       dotenv.config({ path: this.envFile, override: true });
 
-      // Stop and Remove container tipi if exists
-      spinner.setMessage('Stopping and removing containers...');
-      spinner.start();
-      await execAsync('docker rm -f tipi-db');
-      await execAsync('docker rm -f tipi-redis');
-      await execAsync('docker rm -f tipi-dashboard');
-      await execAsync('docker rm -f tipi-reverse-proxy');
-      spinner.done('Containers stopped and removed');
-
       // Pull images
       spinner.setMessage('Pulling images...');
       spinner.start();
-      await execAsync(`docker compose --env-file "${this.envFile}" pull`);
+      this.logger.info('Pulling new images...');
+      await execAsync(`docker compose --env-file ${this.envFile} pull`);
 
       spinner.done('Images pulled');
 
       // Start containers
       spinner.setMessage('Starting containers...');
       spinner.start();
-      await execAsync(`docker compose --env-file "${this.envFile}" up --detach --remove-orphans --build`);
+      this.logger.info('Starting containers...');
 
+      await execAsync(`docker compose --env-file ${this.envFile} up --detach --remove-orphans --build`);
       spinner.done('Containers started');
 
       // start watcher cli in the background
       spinner.setMessage('Starting watcher...');
       spinner.start();
 
+      this.logger.info('Generating TLS certificates...');
       await generateTlsCertificates({ domain: envMap.get('LOCAL_DOMAIN') });
 
-      const out = fs.openSync('./logs/watcher.log', 'a');
-      const err = fs.openSync('./logs/watcher.log', 'a');
-
-      await killOtherWorkers();
-
-      const subprocess = spawn('./runtipi-cli', [process.argv[1] as string, 'watch'], { cwd: this.rootFolder, detached: true, stdio: ['ignore', out, err] });
-      subprocess.unref();
+      if (killWatchers) {
+        this.logger.info('Starting watcher...');
+        const subprocess = spawn('./runtipi-cli', [process.argv[1] as string, 'watch'], { cwd: this.rootFolder, detached: true, stdio: ['ignore', 'ignore', 'ignore'] });
+        subprocess.unref();
+      }
 
       spinner.done('Watcher started');
 
+      this.logger.info('Starting queue...');
       const queue = new Queue('events', { connection: { host: '127.0.0.1', port: 6379, password: envMap.get('REDIS_PASSWORD') } });
+      this.logger.info('Obliterating queue...');
       await queue.obliterate({ force: true });
 
       // Initial jobs
+      this.logger.info('Adding initial jobs to queue...');
       await queue.add(`${Math.random().toString()}_system_info`, { type: 'system', command: 'system_info' } as SystemEvent);
       await queue.add(`${Math.random().toString()}_repo_clone`, { type: 'repo', command: 'clone', url: envMap.get('APPS_REPO_URL') } as SystemEvent);
 
       // Scheduled jobs
+      this.logger.info('Adding scheduled jobs to queue...');
       await queue.add(`${Math.random().toString()}_repo_update`, { type: 'repo', command: 'update', url: envMap.get('APPS_REPO_URL') } as SystemEvent, { repeat: { pattern: '*/30 * * * *' } });
       await queue.add(`${Math.random().toString()}_system_info`, { type: 'system', command: 'system_info' } as SystemEvent, { repeat: { pattern: '* * * * *' } });
 
+      this.logger.info('Closing queue...');
       await queue.close();
 
       spinner.setMessage('Running database migrations...');
       spinner.start();
 
+      this.logger.info('Running database migrations...');
       await runPostgresMigrations({
         postgresHost: '127.0.0.1',
         postgresDatabase: envMap.get('POSTGRES_DBNAME') as string,
@@ -267,6 +294,7 @@ export class SystemExecutors {
 
       // Start all apps
       const appExecutor = new AppExecutors();
+      this.logger.info('Starting all apps...');
       await appExecutor.startAllApps();
 
       console.log(
@@ -295,7 +323,7 @@ export class SystemExecutors {
   public restart = async () => {
     try {
       await this.stop();
-      await this.start();
+      await this.start(true, false);
       return { success: true, message: '' };
     } catch (e) {
       return this.handleSystemError(e);
@@ -325,14 +353,17 @@ export class SystemExecutors {
     try {
       spinner.start();
       let targetVersion = target;
+      this.logger.info(`Updating Tipi to version ${targetVersion}`);
 
       if (!targetVersion || targetVersion === 'latest') {
         spinner.setMessage('Fetching latest version...');
         const { data } = await axios.get<{ tag_name: string }>('https://api.github.com/repos/meienberger/runtipi/releases/latest');
+        this.logger.info(`Getting latest version from GitHub: ${data.tag_name}`);
         targetVersion = data.tag_name;
       }
 
       if (!semver.valid(targetVersion)) {
+        this.logger.error(`Invalid version: ${targetVersion}`);
         spinner.fail(`Invalid version: ${targetVersion}`);
         throw new Error(`Invalid version: ${targetVersion}`);
       }
@@ -347,13 +378,14 @@ export class SystemExecutors {
       const fileName = `runtipi-cli-${targetVersion}`;
       const savePath = path.join(rootFolderHost, fileName);
       const fileUrl = `https://github.com/meienberger/runtipi/releases/download/${targetVersion}/${assetName}`;
+      this.logger.info(`Downloading Tipi ${targetVersion} from ${fileUrl}`);
 
       spinner.done(`Target version: ${targetVersion}`);
       spinner.done(`Download url: ${fileUrl}`);
 
       await this.stop();
 
-      console.log(`Downloading Tipi ${targetVersion}...`);
+      this.logger.info(`Downloading Tipi ${targetVersion}...`);
 
       const bar = new cliProgress.SingleBar({}, cliProgress.Presets.rect);
       bar.start(100, 0);
@@ -364,6 +396,7 @@ export class SystemExecutors {
           url: fileUrl,
           responseType: 'stream',
           onDownloadProgress: (progress) => {
+            this.logger.info(`Download progress: ${Math.round((progress.loaded / (progress.total || 0)) * 100)}%`);
             bar.update(Math.round((progress.loaded / (progress.total || 0)) * 100));
           },
         }).then((response) => {
@@ -372,21 +405,25 @@ export class SystemExecutors {
 
           writer.on('error', (err) => {
             bar.stop();
+            this.logger.error(`Failed to download Tipi: ${err}`);
             spinner.fail(`\nFailed to download Tipi ${targetVersion}`);
             reject(err);
           });
 
           writer.on('finish', () => {
+            this.logger.info('Download complete');
             bar.stop();
             resolve('');
           });
         });
       }).catch((e) => {
+        this.logger.error(`Failed to download Tipi: ${e}`);
         spinner.fail(`\nFailed to download Tipi ${targetVersion}. Please make sure this version exists on GitHub.`);
         throw e;
       });
 
       spinner.done(`Tipi ${targetVersion} downloaded`);
+      this.logger.info(`Changing permissions on ${savePath}`);
       await fs.promises.chmod(savePath, 0o755);
 
       spinner.setMessage('Replacing old cli...');
@@ -394,15 +431,18 @@ export class SystemExecutors {
 
       // Delete old cli
       if (await pathExists(path.join(rootFolderHost, 'runtipi-cli'))) {
+        this.logger.info('Deleting old cli...');
         await fs.promises.unlink(path.join(rootFolderHost, 'runtipi-cli'));
       }
 
       // Delete VERSION file
       if (await pathExists(path.join(rootFolderHost, 'VERSION'))) {
+        this.logger.info('Deleting VERSION file...');
         await fs.promises.unlink(path.join(rootFolderHost, 'VERSION'));
       }
 
       // Rename downloaded cli to runtipi-cli
+      this.logger.info('Renaming new cli to runtipi-cli...');
       await fs.promises.rename(savePath, path.join(rootFolderHost, 'runtipi-cli'));
       spinner.done('Old cli replaced');
 
@@ -410,6 +450,7 @@ export class SystemExecutors {
       // eslint-disable-next-line no-promise-executor-return
       await new Promise((resolve) => setTimeout(resolve, 3000));
 
+      this.logger.info('Starting new cli...');
       const childProcess = spawn('./runtipi-cli', [process.argv[1] as string, 'start']);
 
       childProcess.stdout.on('data', (data) => {
@@ -420,7 +461,7 @@ export class SystemExecutors {
         process.stderr.write(data);
       });
 
-      spinner.done(`Tipi ${targetVersion} successfully updated. Please run './runtipi-cli start' to start Tipi again.`);
+      spinner.done(`Tipi ${targetVersion} successfully updated. Tipi is now starting, wait for this process to finish...`);
 
       return { success: true, message: 'Tipi updated' };
     } catch (e) {

+ 17 - 0
packages/cli/src/executors/system/system.helpers.ts

@@ -217,29 +217,41 @@ export const setEnvVariable = async (key: EnvKeys, value: string) => {
  * Copies the system files from the assets folder to the current working directory
  */
 export const copySystemFiles = async () => {
+  // Remove old unused files
+  if (await pathExists(path.join(process.cwd(), 'scripts'))) {
+    fileLogger.info('Removing old scripts folder');
+    await fs.promises.rmdir(path.join(process.cwd(), 'scripts'), { recursive: true });
+  }
+
   const assetsFolder = path.join('/snapshot', 'runtipi', 'packages', 'cli', 'assets');
 
   // Copy docker-compose.yml file
+  fileLogger.info('Copying file docker-compose.yml');
   await fs.promises.copyFile(path.join(assetsFolder, 'docker-compose.yml'), path.join(process.cwd(), 'docker-compose.yml'));
 
   // Copy VERSION file
+  fileLogger.info('Copying file VERSION');
   await fs.promises.copyFile(path.join(assetsFolder, 'VERSION'), path.join(process.cwd(), 'VERSION'));
 
   // Copy traefik folder from assets
+  fileLogger.info('Creating traefik folders');
   await fs.promises.mkdir(path.join(process.cwd(), 'traefik', 'dynamic'), { recursive: true });
   await fs.promises.mkdir(path.join(process.cwd(), 'traefik', 'shared'), { recursive: true });
   await fs.promises.mkdir(path.join(process.cwd(), 'traefik', 'tls'), { recursive: true });
 
+  fileLogger.info('Copying traefik files');
   await fs.promises.copyFile(path.join(assetsFolder, 'traefik', 'traefik.yml'), path.join(process.cwd(), 'traefik', 'traefik.yml'));
   await fs.promises.copyFile(path.join(assetsFolder, 'traefik', 'dynamic', 'dynamic.yml'), path.join(process.cwd(), 'traefik', 'dynamic', 'dynamic.yml'));
 
   // Create base folders
+  fileLogger.info('Creating base folders');
   await fs.promises.mkdir(path.join(process.cwd(), 'apps'), { recursive: true });
   await fs.promises.mkdir(path.join(process.cwd(), 'app-data'), { recursive: true });
   await fs.promises.mkdir(path.join(process.cwd(), 'state'), { recursive: true });
   await fs.promises.mkdir(path.join(process.cwd(), 'repos'), { recursive: true });
 
   // Create media folders
+  fileLogger.info('Creating media folders');
   await fs.promises.mkdir(path.join(process.cwd(), 'media', 'torrents', 'watch'), { recursive: true });
   await fs.promises.mkdir(path.join(process.cwd(), 'media', 'torrents', 'complete'), { recursive: true });
   await fs.promises.mkdir(path.join(process.cwd(), 'media', 'torrents', 'incomplete'), { recursive: true });
@@ -274,14 +286,17 @@ export const generateTlsCertificates = async (data: { domain?: string }) => {
 
   // If the certificate already exists, don't generate it again
   if (await pathExists(path.join(process.cwd(), 'traefik', 'tls', `${data.domain}.txt`))) {
+    fileLogger.info(`TLS certificate for ${data.domain} already exists`);
     return;
   }
 
   // Remove old certificates
   if (await pathExists(path.join(process.cwd(), 'traefik', 'tls', 'cert.pem'))) {
+    fileLogger.info('Removing old TLS certificate');
     await fs.promises.unlink(path.join(process.cwd(), 'traefik', 'tls', 'cert.pem'));
   }
   if (await pathExists(path.join(process.cwd(), 'traefik', 'tls', 'key.pem'))) {
+    fileLogger.info('Removing old TLS key');
     await fs.promises.unlink(path.join(process.cwd(), 'traefik', 'tls', 'key.pem'));
   }
 
@@ -289,7 +304,9 @@ export const generateTlsCertificates = async (data: { domain?: string }) => {
   const subjectAltName = `DNS:*.${data.domain},DNS:${data.domain}`;
 
   try {
+    fileLogger.info(`Generating TLS certificate for ${data.domain}`);
     await execAsync(`openssl req -x509 -newkey rsa:4096 -keyout traefik/tls/key.pem -out traefik/tls/cert.pem -days 365 -subj "${subject}" -addext "subjectAltName = ${subjectAltName}" -nodes`);
+    fileLogger.info(`Writing txt file for ${data.domain}`);
     await fs.promises.writeFile(path.join(process.cwd(), 'traefik', 'tls', `${data.domain}.txt`), '');
   } catch (error) {
     fileLogger.error(error);

+ 8 - 0
packages/cli/src/index.ts

@@ -61,6 +61,14 @@ const main = async () => {
       console.log(chalk.green('✓'), 'Password reset request created. Head back to the dashboard to set a new password.');
     });
 
+  program
+    .command('clean-logs')
+    .description('Clean logs')
+    .action(async () => {
+      const systemExecutors = new SystemExecutors();
+      await systemExecutors.cleanLogs();
+    });
+
   program.parse(process.argv);
 };
 

+ 12 - 9
packages/cli/src/services/watcher/watcher.ts

@@ -5,12 +5,13 @@ import { promisify } from 'util';
 import { AppExecutors, RepoExecutors, SystemExecutors } from '@/executors';
 import { getEnv } from '@/utils/environment/environment';
 import { getUserIds } from '@/utils/environment/user';
+import { fileLogger } from '@/utils/logger/file-logger';
 
 const execAsync = promisify(exec);
 
 const runCommand = async (jobData: unknown) => {
   const { gid, uid } = getUserIds();
-  console.log(`Running command with uid ${uid} and gid ${gid}`);
+  fileLogger.info(`Running command with uid ${uid} and gid ${gid}`);
 
   const { installApp, startApp, stopApp, uninstallApp, updateApp, regenerateAppEnv } = new AppExecutors();
   const { cloneRepo, pullRepo } = new RepoExecutors();
@@ -80,15 +81,17 @@ export const killOtherWorkers = async () => {
   const { stdout } = await execAsync('ps aux | grep "index.js watch" | grep -v grep | awk \'{print $2}\'');
   const { stdout: stdoutInherit } = await execAsync('ps aux | grep "runtipi-cli watch" | grep -v grep | awk \'{print $2}\'');
 
+  fileLogger.info(`Killing other workers with pids ${stdout} and ${stdoutInherit}`);
+
   const pids = stdout.split('\n').filter((pid: string) => pid !== '');
   const pidsInherit = stdoutInherit.split('\n').filter((pid: string) => pid !== '');
 
   pids.concat(pidsInherit).forEach((pid) => {
-    console.log(`Killing worker with pid ${pid}`);
+    fileLogger.info(`Killing worker with pid ${pid}`);
     try {
       process.kill(Number(pid));
     } catch (e) {
-      console.error(`Error killing worker with pid ${pid}: ${e}`);
+      fileLogger.error(`Error killing worker with pid ${pid}: ${e}`);
     }
   });
 };
@@ -100,27 +103,27 @@ export const startWorker = async () => {
   const worker = new Worker(
     'events',
     async (job) => {
-      console.log(`Processing job ${job.id} with data ${JSON.stringify(job.data)}`);
+      fileLogger.info(`Processing job ${job.id} with data ${JSON.stringify(job.data)}`);
       const { message, success } = await runCommand(job.data);
 
       return { success, stdout: message };
     },
-    { connection: { host: '127.0.0.1', port: 6379, password: getEnv().redisPassword } },
+    { connection: { host: '127.0.0.1', port: 6379, password: getEnv().redisPassword, connectTimeout: 60000 } },
   );
 
   worker.on('ready', () => {
-    console.log('Worker is ready');
+    fileLogger.info('Worker is ready');
   });
 
   worker.on('completed', (job) => {
-    console.log(`Job ${job.id} completed with result: ${JSON.stringify(job.returnvalue)}`);
+    fileLogger.info(`Job ${job.id} completed with result: ${JSON.stringify(job.returnvalue)}`);
   });
 
   worker.on('failed', (job) => {
-    console.error(`Job ${job?.id} failed with reason ${job?.failedReason}`);
+    fileLogger.error(`Job ${job?.id} failed with reason ${job?.failedReason}`);
   });
 
   worker.on('error', async (e) => {
-    console.error('An error occurred:', e);
+    fileLogger.error(`Worker error: ${e}`);
   });
 };

+ 3 - 2
packages/cli/src/utils/docker-helpers/docker-helpers.ts

@@ -10,8 +10,9 @@ const execAsync = promisify(exec);
 const composeUp = async (args: string[]) => {
   const { stdout, stderr } = await execAsync(`docker compose ${args.join(' ')}`);
 
-  fileLogger.info('stdout', stdout);
-  fileLogger.info('stderr', stderr);
+  if (stderr) {
+    fileLogger.error(stderr);
+  }
 
   return { stdout, stderr };
 };

+ 8 - 0
packages/shared/src/schemas/env-schemas.ts

@@ -43,6 +43,14 @@ export const envSchema = z.object({
       if (typeof value === 'boolean') return value;
       return value === 'true';
     }),
+  seePreReleaseVersions: z
+    .string()
+    .or(z.boolean())
+    .optional()
+    .transform((value) => {
+      if (typeof value === 'boolean') return value;
+      return value === 'true';
+    }),
 });
 
 export const settingsSchema = envSchema

+ 2 - 21
src/client/components/hoc/StatusProvider/StatusProvider.test.tsx

@@ -52,29 +52,10 @@ describe('Test: StatusProvider', () => {
     unmount();
   });
 
-  it('should render StatusScreen when system is UPDATING', async () => {
-    const { result, unmount } = renderHook(() => useSystemStore());
-    act(() => {
-      result.current.setStatus('UPDATING');
-    });
-
-    render(
-      <StatusProvider>
-        <div>system running</div>
-      </StatusProvider>,
-    );
-
-    await waitFor(() => {
-      expect(screen.getByText('Your system is updating...')).toBeInTheDocument();
-    });
-
-    unmount();
-  });
-
   it('should reload the page when system is RUNNING after being something else than RUNNING', async () => {
     const { result, unmount } = renderHook(() => useSystemStore());
     act(() => {
-      result.current.setStatus('UPDATING');
+      result.current.setStatus('RESTARTING');
     });
 
     render(
@@ -84,7 +65,7 @@ describe('Test: StatusProvider', () => {
     );
 
     await waitFor(() => {
-      expect(screen.getByText('Your system is updating...')).toBeInTheDocument();
+      expect(screen.getByText('Your system is restarting...')).toBeInTheDocument();
     });
 
     act(() => {

+ 0 - 7
src/client/components/hoc/StatusProvider/StatusProvider.tsx

@@ -23,9 +23,6 @@ export const StatusProvider: React.FC<IProps> = ({ children }) => {
     if (status === 'RESTARTING') {
       s.current = 'RESTARTING';
     }
-    if (status === 'UPDATING') {
-      s.current = 'UPDATING';
-    }
   }, [status, s, setPollStatus]);
 
   if (s.current === 'LOADING') {
@@ -36,9 +33,5 @@ export const StatusProvider: React.FC<IProps> = ({ children }) => {
     return <StatusScreen title="Your system is restarting..." subtitle="Please do not refresh this page" />;
   }
 
-  if (s.current === 'UPDATING') {
-    return <StatusScreen title="Your system is updating..." subtitle="Please do not refresh this page" />;
-  }
-
   return children;
 };

+ 1 - 2
src/client/messages/en.json

@@ -33,7 +33,7 @@
       "domain-already-in-use": "Domain {domain} is already in use by app {id}",
       "could-not-get-latest-version": "Could not get latest version",
       "current-version-is-latest": "Current version is already up to date",
-      "major-version-update": "The major version has changed. Please update manually (instructions on GitHub)",
+      "major-version-update": "The major version has changed. Please update manually (instructions in release notes)",
       "demo-mode-limit": "Only 6 apps can be installed in the demo mode. Please uninstall an other app to install a new one."
     },
     "success": {}
@@ -229,7 +229,6 @@
       "maintenance-title": "Maintenance",
       "maintenance-subtitle": "Common actions to perform on your instance",
       "restart": "Restart",
-      "update": "Update to {version}",
       "already-latest": "Already up to date"
     },
     "settings": {

+ 0 - 6
src/client/mocks/handlers.ts

@@ -9,12 +9,6 @@ export const handlers = [
     type: 'query',
     response: { current: '1.0.0', latest: '1.0.0', body: 'hello' },
   }),
-  getTRPCMock({
-    path: ['system', 'update'],
-    type: 'mutation',
-    response: true,
-    delay: 100,
-  }),
   getTRPCMock({
     path: ['system', 'restart'],
     type: 'mutation',

+ 0 - 28
src/client/modules/Settings/components/UpdateModal/UpdateModal.tsx

@@ -1,28 +0,0 @@
-import React from 'react';
-import { Button } from '../../../../components/ui/Button';
-import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader } from '../../../../components/ui/Dialog';
-
-interface IProps {
-  isOpen: boolean;
-  onClose: () => void;
-  onConfirm: () => void;
-  loading: boolean;
-}
-
-export const UpdateModal: React.FC<IProps> = ({ isOpen, onClose, onConfirm, loading }) => (
-  <Dialog open={isOpen} onOpenChange={onClose}>
-    <DialogContent size="sm">
-      <DialogHeader>
-        <h5 className="modal-title">Update Tipi</h5>
-      </DialogHeader>
-      <DialogDescription>
-        <div className="text-muted">Would you like to update Tipi to the latest version?</div>
-      </DialogDescription>
-      <DialogFooter>
-        <Button onClick={onConfirm} className="btn-success" loading={loading}>
-          Update
-        </Button>
-      </DialogFooter>
-    </DialogContent>
-  </Dialog>
-);

+ 0 - 1
src/client/modules/Settings/components/UpdateModal/index.ts

@@ -1 +0,0 @@
-export { UpdateModal } from './UpdateModal';

+ 0 - 52
src/client/modules/Settings/containers/GeneralActions/GeneralActions.test.tsx

@@ -14,58 +14,6 @@ describe('Test: GeneralActions', () => {
     expect(screen.getByText('Actions')).toBeInTheDocument();
   });
 
-  it('should show toast if update mutation fails', async () => {
-    // arrange
-    server.use(getTRPCMock({ path: ['system', 'getVersion'], response: { current: '1.0.0', latest: '2.0.0', body: '' } }));
-    server.use(getTRPCMockError({ path: ['system', 'update'], type: 'mutation', status: 500, message: 'Something went wrong' }));
-    render(<GeneralActions />);
-    await waitFor(() => {
-      expect(screen.getByText('Update to 2.0.0')).toBeInTheDocument();
-    });
-    const updateButton = screen.getByRole('button', { name: /Update/i });
-    fireEvent.click(updateButton);
-
-    // act
-    const updateButtonModal = screen.getByRole('button', { name: /Update/i });
-    fireEvent.click(updateButtonModal);
-
-    // assert
-    await waitFor(() => {
-      expect(screen.getByText(/Something went wrong/)).toBeInTheDocument();
-    });
-  });
-
-  it('should set poll status to true if update mutation succeeds', async () => {
-    // arrange
-    server.use(getTRPCMock({ path: ['system', 'getVersion'], response: { current: '1.0.0', latest: '2.0.0', body: '' } }));
-    server.use(getTRPCMock({ path: ['system', 'update'], type: 'mutation', response: true }));
-    const { result } = renderHook(() => useSystemStore());
-    result.current.setStatus('RUNNING');
-
-    render(
-      <StatusProvider>
-        <GeneralActions />
-      </StatusProvider>,
-    );
-    await waitFor(() => {
-      expect(screen.getByText('Update to 2.0.0')).toBeInTheDocument();
-    });
-    const updateButton = screen.getByRole('button', { name: /Update/i });
-    fireEvent.click(updateButton);
-
-    // act
-    const updateButtonModal = screen.getByRole('button', { name: /Update/i });
-    fireEvent.click(updateButtonModal);
-
-    result.current.setStatus('UPDATING');
-
-    // assert
-    await waitFor(() => {
-      expect(screen.getByText('Your system is updating...')).toBeInTheDocument();
-    });
-    expect(result.current.pollStatus).toBe(true);
-  });
-
   it('should show toast if restart mutation fails', async () => {
     // arrange
     server.use(getTRPCMockError({ path: ['system', 'restart'], type: 'mutation', status: 500, message: 'Something went badly' }));

+ 0 - 20
src/client/modules/Settings/containers/GeneralActions/GeneralActions.tsx

@@ -8,7 +8,6 @@ import { MessageKey } from '@/server/utils/errors';
 import { Button } from '../../../../components/ui/Button';
 import { useDisclosure } from '../../../../hooks/useDisclosure';
 import { RestartModal } from '../../components/RestartModal';
-import { UpdateModal } from '../../components/UpdateModal/UpdateModal';
 import { trpc } from '../../../../utils/trpc';
 import { useSystemStore } from '../../../../state/systemStore';
 
@@ -19,25 +18,10 @@ export const GeneralActions = () => {
   const [loading, setLoading] = React.useState(false);
   const { setPollStatus } = useSystemStore();
   const restartDisclosure = useDisclosure();
-  const updateDisclosure = useDisclosure();
 
   const defaultVersion = '0.0.0';
   const isLatest = semver.gte(versionQuery.data?.current || defaultVersion, versionQuery.data?.latest || defaultVersion);
 
-  const update = trpc.system.update.useMutation({
-    onMutate: () => {
-      setLoading(true);
-    },
-    onSuccess: async () => {
-      setPollStatus(true);
-    },
-    onError: (e) => {
-      updateDisclosure.close();
-      setLoading(false);
-      toast.error(t(e.data?.tError.message as MessageKey, { ...e.data?.tError?.variables }));
-    },
-  });
-
   const restart = trpc.system.restart.useMutation({
     onMutate: () => {
       setLoading(true);
@@ -71,9 +55,6 @@ export const GeneralActions = () => {
             </div>
           </div>
         )}
-        <Button onClick={updateDisclosure.open} className="mt-3 mr-2 btn-success">
-          {t('settings.actions.update', { version: versionQuery.data?.latest })}
-        </Button>
       </div>
     );
   };
@@ -92,7 +73,6 @@ export const GeneralActions = () => {
         </div>
       </div>
       <RestartModal isOpen={restartDisclosure.isOpen} onClose={restartDisclosure.close} onConfirm={() => restart.mutate()} loading={loading} />
-      <UpdateModal isOpen={updateDisclosure.isOpen} onClose={updateDisclosure.close} onConfirm={() => update.mutate()} loading={loading} />
     </>
   );
 };

+ 0 - 1
src/client/state/systemStore.ts

@@ -3,7 +3,6 @@ import { create } from 'zustand';
 const SYSTEM_STATUS = {
   RUNNING: 'RUNNING',
   RESTARTING: 'RESTARTING',
-  UPDATING: 'UPDATING',
   LOADING: 'LOADING',
 } as const;
 export type SystemStatus = (typeof SYSTEM_STATUS)[keyof typeof SYSTEM_STATUS];

+ 1 - 0
src/server/core/TipiConfig/TipiConfig.ts

@@ -42,6 +42,7 @@ export class TipiConfig {
       status: 'RUNNING',
       storagePath: conf.STORAGE_PATH,
       demoMode: conf.DEMO_MODE,
+      seePreReleaseVersions: false,
     };
 
     const fileConfig = readJsonFile('/runtipi/state/settings.json') || {};

+ 0 - 1
src/server/routers/system/system.router.ts

@@ -12,7 +12,6 @@ export const systemRouter = router({
   systemInfo: protectedProcedure.query(SystemServiceClass.systemInfo),
   getVersion: publicProcedure.query(SystemService.getVersion),
   restart: protectedProcedure.mutation(SystemService.restart),
-  update: protectedProcedure.mutation(SystemService.update),
   updateSettings: protectedProcedure.input(settingsSchema).mutation(({ input }) => TipiConfig.setSettings(input)),
   getSettings: protectedProcedure.query(TipiConfig.getSettings),
 });

+ 0 - 56
src/server/services/system/system.service.test.ts

@@ -160,59 +160,3 @@ describe('Test: restart', () => {
     await expect(SystemService.restart()).rejects.toThrow('server-messages.errors.not-allowed-in-demo');
   });
 });
-
-describe('Test: update', () => {
-  it('Should return true', async () => {
-    // Arrange
-    EventDispatcher.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: true });
-    setConfig('version', '0.0.1');
-    await cache.set('latestVersion', '0.0.2');
-
-    // Act
-    const update = await SystemService.update();
-
-    // Assert
-    expect(update).toBeTruthy();
-  });
-
-  it('Should throw an error if latest version is not set', async () => {
-    // Arrange
-    await cache.del('latestVersion');
-    server.use(
-      rest.get('https://api.github.com/repos/meienberger/runtipi/releases/latest', (_, res, ctx) => {
-        return res(ctx.json({ name: null }));
-      }),
-    );
-    setConfig('version', '0.0.1');
-
-    // Act & Assert
-    await expect(SystemService.update()).rejects.toThrow('server-messages.errors.could-not-get-latest-version');
-  });
-
-  it('Should throw if current version is higher than latest', async () => {
-    // Arrange
-    setConfig('version', '0.0.2');
-    await cache.set('latestVersion', '0.0.1');
-
-    // Act & Assert
-    await expect(SystemService.update()).rejects.toThrow('server-messages.errors.current-version-is-latest');
-  });
-
-  it('Should throw if current version is equal to latest', async () => {
-    // Arrange
-    setConfig('version', '0.0.1');
-    await cache.set('latestVersion', '0.0.1');
-
-    // Act & Assert
-    await expect(SystemService.update()).rejects.toThrow('server-messages.errors.current-version-is-latest');
-  });
-
-  it('Should throw an error if there is a major version difference', async () => {
-    // Arrange
-    setConfig('version', '0.0.1');
-    await cache.set('latestVersion', '1.0.0');
-
-    // Act & Assert
-    await expect(SystemService.update()).rejects.toThrow('server-messages.errors.major-version-update');
-  });
-});

+ 8 - 31
src/server/services/system/system.service.ts

@@ -1,4 +1,3 @@
-import semver from 'semver';
 import { z } from 'zod';
 import axios from 'redaxios';
 import { TranslatedError } from '@/server/utils/errors';
@@ -44,6 +43,14 @@ export class SystemServiceClass {
    */
   public getVersion = async () => {
     try {
+      const { seePreReleaseVersions } = TipiConfig.getConfig();
+
+      if (seePreReleaseVersions) {
+        const { data } = await axios.get<{ tag_name: string; body: string }[]>('https://api.github.com/repos/meienberger/runtipi/releases');
+
+        return { current: TipiConfig.getConfig().version, latest: data[0]?.tag_name, body: data[0]?.body };
+      }
+
       let version = await this.cache.get('latestVersion');
       let body = await this.cache.get('latestVersionBody');
 
@@ -76,36 +83,6 @@ export class SystemServiceClass {
     return info.data;
   };
 
-  public update = async (): Promise<boolean> => {
-    const { current, latest } = await this.getVersion();
-
-    if (TipiConfig.getConfig().NODE_ENV === 'development') {
-      throw new TranslatedError('server-messages.errors.not-allowed-in-dev');
-    }
-
-    if (!latest) {
-      throw new TranslatedError('server-messages.errors.could-not-get-latest-version');
-    }
-
-    if (semver.gt(current, latest)) {
-      throw new TranslatedError('server-messages.errors.current-version-is-latest');
-    }
-
-    if (semver.eq(current, latest)) {
-      throw new TranslatedError('server-messages.errors.current-version-is-latest');
-    }
-
-    if (semver.major(current) !== semver.major(latest)) {
-      throw new TranslatedError('server-messages.errors.major-version-update');
-    }
-
-    TipiConfig.setConfig('status', 'UPDATING');
-
-    this.dispatcher.dispatchEvent({ type: 'system', command: 'update', version: latest });
-
-    return true;
-  };
-
   public restart = async (): Promise<boolean> => {
     if (TipiConfig.getConfig().NODE_ENV === 'development') {
       throw new TranslatedError('server-messages.errors.not-allowed-in-dev');