Merge pull request #677 from meienberger/release/2.0.3

Release/2.0.3
This commit is contained in:
Nicolas Meienberger 2023-09-02 22:52:29 +02:00 committed by GitHub
commit 7c40c7e0da
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 178 additions and 310 deletions

View file

@ -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

View file

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

View file

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

View file

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

View file

@ -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:

View file

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

View file

@ -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);
}

View file

@ -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}`);
});
}
}),
);
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}`);
}
}
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}`);
});
}
}),
);
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) {

View file

@ -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);

View file

@ -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);
};

View file

@ -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}`);
});
};

View file

@ -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 };
};

View file

@ -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

View file

@ -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(() => {

View file

@ -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;
};

View file

@ -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": {

View file

@ -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',

View file

@ -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>
);

View file

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

View file

@ -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' }));

View file

@ -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} />
</>
);
};

View file

@ -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];

View file

@ -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') || {};

View file

@ -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),
});

View file

@ -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');
});
});

View file

@ -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');