Merge pull request #219 from meienberger/refactor/scripts

Refactor/scripts - Event based actions
This commit is contained in:
Nicolas Meienberger 2022-10-05 20:43:28 +00:00 committed by GitHub
commit 0aa930ebb1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
38 changed files with 1024 additions and 576 deletions

2
.gitignore vendored
View file

@ -1,6 +1,8 @@
*.swo
*.swp
.DS_Store
logs
.pnpm-debug.log
.env*

View file

@ -1,4 +1,4 @@
FROM node:18 AS build
FROM node:18-alpine3.16 AS build
RUN npm install node-gyp -g
@ -19,7 +19,7 @@ COPY ./packages/dashboard /dashboard
RUN npm run build
FROM ubuntu:22.04 as app
FROM node:18-alpine3.16 as app
WORKDIR /
@ -37,8 +37,6 @@ RUN curl -fsSL https://deb.nodesource.com/setup_16.x | bash -
RUN apt-get install -y nodejs
# Install dependencies
RUN apt-get install -y bash g++ make git
RUN npm install node-gyp -g
WORKDIR /api

View file

@ -1,23 +1,7 @@
FROM ubuntu:22.04
FROM node:18-alpine3.16
WORKDIR /
RUN apt-get update
# Install docker
RUN apt-get install -y ca-certificates curl gnupg lsb-release jq
RUN mkdir -p /etc/apt/keyrings
RUN curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
RUN echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list >/dev/null
RUN apt-get update
RUN apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
# Install node
RUN curl -fsSL https://deb.nodesource.com/setup_16.x | bash -
RUN apt-get install -y nodejs
# Install dependencies
RUN apt-get install -y bash g++ make git
RUN npm install node-gyp -g
WORKDIR /api

View file

@ -53,10 +53,13 @@ services:
volumes:
## Docker sock
- /var/run/docker.sock:/var/run/docker.sock:ro
- ${PWD}:/runtipi
- ${PWD}/apps:/runtipi/apps:ro
- ${PWD}/repos:/runtipi/repos:ro
- ${PWD}/state:/runtipi/state
- ${PWD}/packages/system-api/src:/api/src
- ${PWD}/logs:/app/logs
- ${STORAGE_PATH}:/app/storage
- ${PWD}/.env.dev:/runtipi/.env
# - /api/node_modules
environment:
INTERNAL_IP: ${INTERNAL_IP}

View file

@ -46,7 +46,9 @@ services:
volumes:
## Docker sock
- /var/run/docker.sock:/var/run/docker.sock:ro
- ${PWD}:/runtipi
- ${PWD}/apps:/runtipi/apps:ro
- ${PWD}/repos:/runtipi/repos:ro
- ${PWD}/state:/runtipi/state
- ${PWD}/logs:/app/logs
- ${STORAGE_PATH}:/app/storage
environment:

View file

@ -46,7 +46,9 @@ services:
volumes:
## Docker sock
- /var/run/docker.sock:/var/run/docker.sock:ro
- ${PWD}:/runtipi
- ${PWD}/apps:/runtipi/apps:ro
- ${PWD}/repos:/runtipi/repos:ro
- ${PWD}/state:/runtipi/state
- ${PWD}/logs:/app/logs
- ${STORAGE_PATH}:/app/storage
environment:

View file

@ -7,7 +7,7 @@
"commit": "git-cz",
"act:test-install": "act --container-architecture linux/amd64 -j test-install",
"act:docker": "act --container-architecture linux/amd64 --secret-file github.secrets -j build-images",
"start:dev": "docker-compose -f docker-compose.dev.yml --env-file .env.dev up --build",
"start:dev": "./scripts/start-dev.sh",
"start:rc": "docker-compose -f docker-compose.rc.yml --env-file .env up --build",
"start:prod": "docker-compose --env-file .env up --build",
"start:pg": "docker run --name test-db -p 5433:5432 -d --rm -e POSTGRES_PASSWORD=postgres postgres",

View file

@ -19,4 +19,7 @@ module.exports = {
'no-unused-vars': [1, { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-unused-vars': [1, { argsIgnorePattern: '^_' }],
},
globals: {
NodeJS: true,
},
};

View file

@ -1,6 +1,8 @@
node_modules/
dist/
.DS_Store
# testing
coverage/
logs/

View file

@ -11,6 +11,7 @@ const fs: {
copyFileSync: typeof copyFileSync;
copySync: typeof copyFileSync;
createFileSync: typeof createFileSync;
unlinkSync: typeof unlinkSync;
} = jest.genMockFromModule('fs-extra');
let mockFiles = Object.create(null);
@ -97,6 +98,16 @@ const resetAllMocks = () => {
mockFiles = Object.create(null);
};
const unlinkSync = (p: string) => {
if (mockFiles[p] instanceof Array) {
mockFiles[p].forEach((file: string) => {
delete mockFiles[path.join(p, file)];
});
}
delete mockFiles[p];
};
fs.unlinkSync = unlinkSync;
fs.readdirSync = readdirSync;
fs.existsSync = existsSync;
fs.readFileSync = readFileSync;

View file

@ -0,0 +1,221 @@
import fs from 'fs-extra';
import logger from '../../config/logger/logger';
export enum EventTypes {
// System events
RESTART = 'restart',
UPDATE = 'update',
CLONE_REPO = 'clone_repo',
UPDATE_REPO = 'update_repo',
APP = 'app',
SYSTEM_INFO = 'system_info',
}
type SystemEvent = {
id: string;
type: EventTypes;
args: string[];
creationDate: Date;
};
type EventStatusTypes = 'running' | 'success' | 'error' | 'waiting';
const WATCH_FILE = '/runtipi/state/events';
// File state example:
// restart 1631231231231 running "arg1 arg2"
class EventDispatcher {
private static instance: EventDispatcher | null;
private queue: SystemEvent[] = [];
private lock: SystemEvent | null = null;
private interval: NodeJS.Timer;
private intervals: NodeJS.Timer[] = [];
constructor() {
const timer = this.pollQueue();
this.interval = timer;
}
public static getInstance(): EventDispatcher {
if (!EventDispatcher.instance) {
EventDispatcher.instance = new EventDispatcher();
}
return EventDispatcher.instance;
}
/**
* Generate a random task id
* @returns - Random id
*/
private generateId() {
return Math.random().toString(36).substring(2, 9);
}
/**
* Collect lock status and clean queue if event is done
*/
private collectLockStatusAndClean() {
if (!this.lock) {
return;
}
const status = this.getEventStatus(this.lock.id);
if (status === 'running' || status === 'waiting') {
return;
}
this.clearEvent(this.lock.id);
this.lock = null;
}
/**
* Poll queue and run events
*/
private pollQueue() {
logger.info('EventDispatcher: Polling queue...');
if (!this.interval) {
const id = setInterval(() => {
this.runEvent();
this.collectLockStatusAndClean();
}, 1000);
this.intervals.push(id);
return id;
}
return this.interval;
}
/**
* Run event from the queue if there is no lock
*/
private async runEvent() {
if (this.lock) {
return;
}
const event = this.queue[0];
if (!event) {
return;
}
this.lock = event;
// Write event to state file
const args = event.args.join(' ');
const line = `${event.type} ${event.id} waiting ${args}`;
fs.writeFileSync(WATCH_FILE, `${line}`);
}
/**
* Check event status
* @param id - Event id
* @returns - Event status
*/
private getEventStatus(id: string): EventStatusTypes {
const event = this.queue.find((e) => e.id === id);
if (!event) {
return 'success';
}
// if event was created more than 3 minutes ago, it's an error
if (new Date().getTime() - event.creationDate.getTime() > 5 * 60 * 1000) {
return 'error';
}
const file = fs.readFileSync(WATCH_FILE, 'utf8');
const lines = file?.split('\n') || [];
const line = lines.find((l) => l.startsWith(`${event.type} ${event.id}`));
if (!line) {
return 'waiting';
}
const status = line.split(' ')[2] as EventStatusTypes;
return status;
}
/**
* Dispatch an event to the queue
* @param type - Event type
* @param args - Event arguments
* @returns - Event object
*/
public dispatchEvent(type: EventTypes, args?: string[]): SystemEvent {
const event: SystemEvent = {
id: this.generateId(),
type,
args: args || [],
creationDate: new Date(),
};
this.queue.push(event);
return event;
}
/**
* Clear event from queue
* @param id - Event id
*/
private clearEvent(id: string) {
this.queue = this.queue.filter((e) => e.id !== id);
if (fs.existsSync(`/app/logs/${id}.log`)) {
fs.unlinkSync(`/app/logs/${id}.log`);
}
fs.writeFileSync(WATCH_FILE, '');
}
/**
* Dispatch an event to the queue and wait for it to finish
* @param type - Event type
* @param args - Event arguments
* @returns - Promise that resolves when the event is done
*/
public async dispatchEventAsync(type: EventTypes, args?: string[]): Promise<{ success: boolean; stdout?: string }> {
const event = this.dispatchEvent(type, args);
return new Promise((resolve) => {
const interval = setInterval(() => {
this.intervals.push(interval);
const status = this.getEventStatus(event.id);
let log = '';
if (fs.existsSync(`/app/logs/${event.id}.log`)) {
log = fs.readFileSync(`/app/logs/${event.id}.log`, 'utf8');
}
if (status === 'success') {
clearInterval(interval);
resolve({ success: true, stdout: log });
} else if (status === 'error') {
clearInterval(interval);
resolve({ success: false, stdout: log });
}
}, 100);
});
}
public clearInterval() {
clearInterval(this.interval);
this.intervals.forEach((i) => clearInterval(i));
}
public clear() {
this.queue = [];
this.lock = null;
EventDispatcher.instance = null;
fs.writeFileSync(WATCH_FILE, '');
}
}
export const eventDispatcher = EventDispatcher.getInstance();
export default EventDispatcher;

View file

@ -0,0 +1,198 @@
import fs from 'fs-extra';
import { eventDispatcher, EventTypes } from '../EventDispatcher';
const WATCH_FILE = '/runtipi/state/events';
jest.mock('fs-extra');
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
beforeEach(() => {
eventDispatcher.clear();
fs.writeFileSync(WATCH_FILE, '');
fs.writeFileSync('/app/logs/123.log', 'test');
});
describe('EventDispatcher - dispatchEvent', () => {
it('should dispatch an event', () => {
const event = eventDispatcher.dispatchEvent(EventTypes.APP);
expect(event.id).toBeDefined();
});
it('should dispatch an event with args', () => {
const event = eventDispatcher.dispatchEvent(EventTypes.APP, ['--help']);
expect(event.id).toBeDefined();
});
it('Should put events into queue', async () => {
eventDispatcher.dispatchEvent(EventTypes.APP, ['--help']);
eventDispatcher.dispatchEvent(EventTypes.APP, ['--help']);
// @ts-ignore
const queue = eventDispatcher.queue;
expect(queue.length).toBe(2);
});
it('Should put first event into lock after 1 sec', async () => {
eventDispatcher.dispatchEvent(EventTypes.APP, ['--help']);
eventDispatcher.dispatchEvent(EventTypes.UPDATE, ['--help']);
// @ts-ignore
const queue = eventDispatcher.queue;
await wait(1050);
// @ts-ignore
const lock = eventDispatcher.lock;
expect(queue.length).toBe(2);
expect(lock).toBeDefined();
expect(lock?.type).toBe(EventTypes.APP);
});
it('Should clear event once its status is success', async () => {
// @ts-ignore
jest.spyOn(eventDispatcher, 'getEventStatus').mockReturnValueOnce('success');
eventDispatcher.dispatchEvent(EventTypes.APP, ['--help']);
await wait(1050);
// @ts-ignore
const queue = eventDispatcher.queue;
expect(queue.length).toBe(0);
});
it('Should clear event once its status is error', async () => {
// @ts-ignore
jest.spyOn(eventDispatcher, 'getEventStatus').mockReturnValueOnce('error');
eventDispatcher.dispatchEvent(EventTypes.APP, ['--help']);
await wait(1050);
// @ts-ignore
const queue = eventDispatcher.queue;
expect(queue.length).toBe(0);
});
});
describe('EventDispatcher - dispatchEventAsync', () => {
it('Should dispatch an event and wait for it to finish', async () => {
// @ts-ignore
jest.spyOn(eventDispatcher, 'getEventStatus').mockReturnValueOnce('success');
const { success } = await eventDispatcher.dispatchEventAsync(EventTypes.APP, ['--help']);
expect(success).toBe(true);
});
it('Should dispatch an event and wait for it to finish with error', async () => {
// @ts-ignore
jest.spyOn(eventDispatcher, 'getEventStatus').mockReturnValueOnce('error');
const { success } = await eventDispatcher.dispatchEventAsync(EventTypes.APP, ['--help']);
expect(success).toBe(false);
});
});
describe('EventDispatcher - runEvent', () => {
it('Should do nothing if there is a lock', async () => {
// @ts-ignore
eventDispatcher.lock = { id: '123', type: EventTypes.APP, args: [] };
// @ts-ignore
await eventDispatcher.runEvent();
// @ts-ignore
const file = fs.readFileSync(WATCH_FILE, 'utf8');
expect(file).toBe('');
});
it('Should do nothing if there is no event in queue', async () => {
// @ts-ignore
await eventDispatcher.runEvent();
// @ts-ignore
const file = fs.readFileSync(WATCH_FILE, 'utf8');
expect(file).toBe('');
});
});
describe('EventDispatcher - getEventStatus', () => {
it('Should return success if event is not in the queue', async () => {
// @ts-ignore
eventDispatcher.queue = [];
// @ts-ignore
const status = eventDispatcher.getEventStatus('123');
expect(status).toBe('success');
});
it('Should return error if event is expired', async () => {
const dateFiveMinutesAgo = new Date(new Date().getTime() - 5 * 60 * 10000);
// @ts-ignore
eventDispatcher.queue = [{ id: '123', type: EventTypes.APP, args: [], creationDate: dateFiveMinutesAgo }];
// @ts-ignore
const status = eventDispatcher.getEventStatus('123');
expect(status).toBe('error');
});
it('Should be waiting if line is not found in the file', async () => {
// @ts-ignore
eventDispatcher.queue = [{ id: '123', type: EventTypes.APP, args: [], creationDate: new Date() }];
// @ts-ignore
const status = eventDispatcher.getEventStatus('123');
expect(status).toBe('waiting');
});
});
describe('EventDispatcher - clearEvent', () => {
it('Should clear event', async () => {
// @ts-ignore
eventDispatcher.queue = [{ id: '123', type: EventTypes.APP, args: [], creationDate: new Date() }];
// @ts-ignore
eventDispatcher.clearEvent('123');
// @ts-ignore
const queue = eventDispatcher.queue;
expect(queue.length).toBe(0);
});
});
describe('EventDispatcher - pollQueue', () => {
it('Should not create a new interval if one already exists', async () => {
// @ts-ignore
eventDispatcher.interval = 123;
// @ts-ignore
const id = eventDispatcher.pollQueue();
// @ts-ignore
const interval = eventDispatcher.interval;
expect(interval).toBe(123);
expect(id).toBe(123);
clearInterval(interval);
clearInterval(id);
});
});
describe('EventDispatcher - collectLockStatusAndClean', () => {
it('Should do nothing if there is no lock', async () => {
// @ts-ignore
eventDispatcher.lock = null;
// @ts-ignore
eventDispatcher.collectLockStatusAndClean();
// @ts-ignore
const lock = eventDispatcher.lock;
expect(lock).toBeNull();
});
});

View file

@ -1,7 +1,7 @@
import cron from 'node-cron';
import * as repoHelpers from '../../../helpers/repo-helpers';
import { getConfig } from '../../config/TipiConfig';
import startJobs from '../jobs';
import { eventDispatcher, EventTypes } from '../../../core/config/EventDispatcher';
jest.mock('node-cron');
jest.mock('child_process');
@ -17,16 +17,21 @@ describe('Test: startJobs', () => {
startJobs();
expect(spy).toHaveBeenCalled();
expect(spy).toHaveBeenCalledWith('0 * * * *', expect.any(Function));
expect(spy).toHaveBeenCalledWith('*/30 * * * *', expect.any(Function));
spy.mockRestore();
});
it('Should update apps repo on cron trigger', () => {
const spy = jest.spyOn(repoHelpers, 'updateRepo');
const spy = jest.spyOn(eventDispatcher, 'dispatchEvent');
// Act
startJobs();
expect(spy).toHaveBeenCalledWith(getConfig().appsRepoUrl);
// Assert
expect(spy.mock.calls.length).toBe(2);
expect(spy.mock.calls[0]).toEqual([EventTypes.UPDATE_REPO, [getConfig().appsRepoUrl]]);
expect(spy.mock.calls[1]).toEqual([EventTypes.SYSTEM_INFO, []]);
spy.mockRestore();
});
});

View file

@ -1,14 +1,19 @@
import cron from 'node-cron';
import logger from '../../config/logger/logger';
import { updateRepo } from '../../helpers/repo-helpers';
import { getConfig } from '../../core/config/TipiConfig';
import { eventDispatcher, EventTypes } from '../config/EventDispatcher';
const startJobs = () => {
logger.info('Starting cron jobs...');
cron.schedule('0 * * * *', () => {
logger.info('Updating apps repo...');
updateRepo(getConfig().appsRepoUrl);
// Every 30 minutes
cron.schedule('*/30 * * * *', async () => {
eventDispatcher.dispatchEvent(EventTypes.UPDATE_REPO, [getConfig().appsRepoUrl]);
});
// every minute
cron.schedule('* * * * *', () => {
eventDispatcher.dispatchEvent(EventTypes.SYSTEM_INFO, []);
});
};

View file

@ -1,99 +0,0 @@
import { faker } from '@faker-js/faker';
import childProcess from 'child_process';
import logger from '../../config/logger/logger';
import { cloneRepo, updateRepo } from '../repo-helpers';
jest.mock('child_process');
beforeEach(async () => {
jest.resetModules();
jest.resetAllMocks();
});
describe('Test: updateRepo', () => {
it('Should run update script', async () => {
const log = jest.spyOn(logger, 'info');
const spy = jest.spyOn(childProcess, 'execFile');
const url = faker.internet.url();
const stdout = faker.random.words();
// @ts-ignore
spy.mockImplementation((_path, _args, _, cb) => {
// @ts-ignore
if (cb) cb(null, stdout, null);
});
await updateRepo(url);
expect(spy).toHaveBeenCalledWith('/runtipi/scripts/git.sh', ['update', url], {}, expect.any(Function));
expect(log).toHaveBeenCalledWith(`Update result: ${stdout}`);
spy.mockRestore();
});
it('Should throw and log error if script failed', async () => {
const url = faker.internet.url();
const log = jest.spyOn(logger, 'error');
const spy = jest.spyOn(childProcess, 'execFile');
const randomWord = faker.random.word();
// @ts-ignore
spy.mockImplementation((_path, _args, _, cb) => {
// @ts-ignore
if (cb) cb(randomWord, null, null);
});
try {
await updateRepo(url);
} catch (e) {
expect(e).toBe(randomWord);
expect(log).toHaveBeenCalledWith(`Error updating repo: ${randomWord}`);
}
spy.mockRestore();
});
});
describe('Test: cloneRepo', () => {
it('Should run clone script', async () => {
const log = jest.spyOn(logger, 'info');
const spy = jest.spyOn(childProcess, 'execFile');
const url = faker.internet.url();
const stdout = faker.random.words();
// @ts-ignore
spy.mockImplementation((_path, _args, _, cb) => {
// @ts-ignore
if (cb) cb(null, stdout, null);
});
await cloneRepo(url);
expect(spy).toHaveBeenCalledWith('/runtipi/scripts/git.sh', ['clone', url], {}, expect.any(Function));
expect(log).toHaveBeenCalledWith(`Clone result ${stdout}`);
spy.mockRestore();
});
it('Should throw and log error if script failed', async () => {
const url = faker.internet.url();
const log = jest.spyOn(logger, 'error');
const spy = jest.spyOn(childProcess, 'execFile');
const randomWord = faker.random.word();
// @ts-ignore
spy.mockImplementation((_path, _args, _, cb) => {
// @ts-ignore
if (cb) cb(randomWord, null, null);
});
try {
await cloneRepo(url);
} catch (e) {
expect(e).toBe(randomWord);
expect(log).toHaveBeenCalledWith(`Error cloning repo: ${randomWord}`);
}
spy.mockRestore();
});
});

View file

@ -1,32 +0,0 @@
import Logger from '../config/logger/logger';
import { runScript } from '../modules/fs/fs.helpers';
export const updateRepo = (repo: string): Promise<void> => {
return new Promise((resolve, reject) => {
runScript('/runtipi/scripts/git.sh', ['update', repo], (err: string, stdout: string) => {
if (err) {
Logger.error(`Error updating repo: ${err}`);
reject(err);
}
Logger.info(`Update result: ${stdout}`);
resolve();
});
});
};
export const cloneRepo = (repo: string): Promise<void> => {
return new Promise((resolve, reject) => {
runScript('/runtipi/scripts/git.sh', ['clone', repo], (err: string, stdout: string) => {
if (err) {
Logger.error(`Error cloning repo: ${err}`);
reject(err);
}
Logger.info(`Clone result ${stdout}`);
resolve();
});
});
};

View file

@ -1,7 +1,6 @@
import { faker } from '@faker-js/faker';
import { AppCategoriesEnum, AppInfo, AppStatusEnum, FieldTypes } from '../apps.types';
import App from '../app.entity';
import { getConfig } from '../../../core/config/TipiConfig';
interface IProps {
installed?: boolean;
@ -73,8 +72,8 @@ const createApp = async (props: IProps) => {
MockFiles[`/app/storage/app-data/${appInfo.id}`] = '';
MockFiles[`/app/storage/app-data/${appInfo.id}/app.env`] = 'TEST=test\nAPP_PORT=3000\nTEST_FIELD=test';
MockFiles[`/app/storage/apps/${appInfo.id}/config.json`] = JSON.stringify(appInfo);
MockFiles[`/app/storage/apps/${appInfo.id}/metadata/description.md`] = 'md desc';
MockFiles[`/runtipi/apps/${appInfo.id}/config.json`] = JSON.stringify(appInfo);
MockFiles[`/runtipi/apps/${appInfo.id}/metadata/description.md`] = 'md desc';
}
return { appInfo, MockFiles, appEntity };

View file

@ -1,11 +1,10 @@
import { faker } from '@faker-js/faker';
import fs from 'fs-extra';
import childProcess from 'child_process';
import { DataSource } from 'typeorm';
import logger from '../../../config/logger/logger';
import { setupConnection, teardownConnection } from '../../../test/connection';
import App from '../app.entity';
import { checkAppRequirements, checkEnvFile, generateEnvFile, getAppInfo, getAvailableApps, getEnvMap, getUpdateInfo, runAppScript } from '../apps.helpers';
import { checkAppRequirements, checkEnvFile, generateEnvFile, getAppInfo, getAvailableApps, getEnvMap, getUpdateInfo } from '../apps.helpers';
import { AppInfo } from '../apps.types';
import { createApp } from './apps.factory';
@ -108,48 +107,6 @@ describe('checkEnvFile', () => {
});
});
describe('Test: runAppScript', () => {
let app1: AppInfo;
beforeEach(async () => {
const app1create = await createApp({ installed: true });
app1 = app1create.appInfo;
// @ts-ignore
fs.__createMockFiles(app1create.MockFiles);
});
it('Should run the app script', async () => {
const { MockFiles } = await createApp({ installed: true });
// @ts-ignore
fs.__createMockFiles(MockFiles);
await runAppScript(['install', app1.id]);
});
it('Should log the error if the script fails', async () => {
const log = jest.spyOn(logger, 'error');
const spy = jest.spyOn(childProcess, 'execFile');
const randomWord = faker.random.word();
// @ts-ignore
spy.mockImplementation((_path, _args, _, cb) => {
// @ts-ignore
if (cb) cb(randomWord, null, null);
});
try {
await runAppScript(['install', app1.id]);
expect(true).toBe(false);
} catch (e: any) {
expect(e).toBe(randomWord);
expect(log).toHaveBeenCalledWith(`Error running app script: ${randomWord}`);
}
log.mockRestore();
spy.mockRestore();
});
});
describe('Test: generateEnvFile', () => {
let app1: AppInfo;
let appEntity1: App;
@ -311,7 +268,7 @@ describe('Test: getAppInfo', () => {
id: faker.random.alphaNumeric(32),
};
fs.writeFileSync(`/app/storage/apps/${appInfo.id}/config.json`, JSON.stringify(newConfig));
fs.writeFileSync(`/runtipi/apps/${appInfo.id}/config.json`, JSON.stringify(newConfig));
const app = await getAppInfo(appInfo.id, appEntity.status);

View file

@ -10,6 +10,7 @@ import { createUser } from '../../auth/__tests__/user.factory';
import User from '../../auth/user.entity';
import { installAppMutation, startAppMutation, stopAppMutation, uninstallAppMutation, updateAppConfigMutation, updateAppMutation } from '../../../test/mutations';
import { faker } from '@faker-js/faker';
import EventDispatcher from '../../../core/config/EventDispatcher';
jest.mock('fs');
jest.mock('child_process');
@ -36,6 +37,7 @@ beforeEach(async () => {
jest.resetModules();
jest.resetAllMocks();
jest.restoreAllMocks();
EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValue({ success: true });
await App.clear();
await User.clear();
});

View file

@ -1,13 +1,12 @@
import AppsService from '../apps.service';
import fs from 'fs-extra';
import childProcess from 'child_process';
import { AppInfo, AppStatusEnum } from '../apps.types';
import App from '../app.entity';
import { createApp } from './apps.factory';
import { setupConnection, teardownConnection } from '../../../test/connection';
import { DataSource } from 'typeorm';
import { getEnvMap } from '../apps.helpers';
import { getConfig } from '../../../core/config/TipiConfig';
import EventDispatcher, { eventDispatcher, EventTypes } from '../../../core/config/EventDispatcher';
jest.mock('fs-extra');
jest.mock('child_process');
@ -23,6 +22,7 @@ beforeEach(async () => {
jest.resetModules();
jest.resetAllMocks();
jest.restoreAllMocks();
EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValue({ success: true });
await App.clear();
});
@ -42,6 +42,7 @@ describe('Install app', () => {
});
it('Should correctly generate env file for app', async () => {
// EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: true });
await AppsService.installApp(app1.id, { TEST_FIELD: 'test' });
const envFile = fs.readFileSync(`/app/storage/app-data/${app1.id}/app.env`).toString();
@ -59,39 +60,28 @@ describe('Install app', () => {
expect(app!.status).toBe(AppStatusEnum.RUNNING);
});
it('Should correctly run app script', async () => {
const spy = jest.spyOn(childProcess, 'execFile');
await AppsService.installApp(app1.id, { TEST_FIELD: 'test' });
expect(spy.mock.lastCall).toEqual([`${getConfig().rootFolder}/scripts/app.sh`, ['install', app1.id], {}, expect.any(Function)]);
spy.mockRestore();
});
it('Should start app if already installed', async () => {
const spy = jest.spyOn(childProcess, 'execFile');
const spy = jest.spyOn(eventDispatcher, 'dispatchEventAsync');
await AppsService.installApp(app1.id, { TEST_FIELD: 'test' });
await AppsService.installApp(app1.id, { TEST_FIELD: 'test' });
expect(spy.mock.calls.length).toBe(2);
expect(spy.mock.calls[0]).toEqual(['/runtipi/scripts/app.sh', ['install', app1.id], {}, expect.any(Function)]);
expect(spy.mock.calls[1]).toEqual(['/runtipi/scripts/app.sh', ['start', app1.id], {}, expect.any(Function)]);
expect(spy.mock.calls[0]).toEqual([EventTypes.APP, ['install', app1.id]]);
expect(spy.mock.calls[1]).toEqual([EventTypes.APP, ['start', app1.id]]);
spy.mockRestore();
});
it('Should delete app if install script fails', async () => {
const spy = jest.spyOn(childProcess, 'execFile');
spy.mockImplementation(() => {
throw new Error('Test error');
});
// Arrange
EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'error' });
await expect(AppsService.installApp(app1.id, { TEST_FIELD: 'test' })).rejects.toThrow('Test error');
await expect(AppsService.installApp(app1.id, { TEST_FIELD: 'test' })).rejects.toThrow(`App ${app1.id} failed to install\nstdout: error`);
const app = await App.findOne({ where: { id: app1.id } });
expect(app).toBeNull();
spy.mockRestore();
});
it('Should throw if required form fields are missing', async () => {
@ -112,7 +102,7 @@ describe('Install app', () => {
it('Should correctly copy app from repos to apps folder', async () => {
await AppsService.installApp(app1.id, { TEST_FIELD: 'test' });
const appFolder = fs.readdirSync(`/app/storage/apps/${app1.id}`);
const appFolder = fs.readdirSync(`/runtipi/apps/${app1.id}`);
expect(appFolder).toBeDefined();
expect(appFolder.indexOf('docker-compose.yml')).toBeGreaterThanOrEqual(0);
@ -121,19 +111,19 @@ describe('Install app', () => {
it('Should cleanup any app folder existing before install', async () => {
const { MockFiles, appInfo } = await createApp({});
app1 = appInfo;
MockFiles[`/app/storage/apps/${appInfo.id}/docker-compose.yml`] = 'test';
MockFiles[`/app/storage/apps/${appInfo.id}/test.yml`] = 'test';
MockFiles[`/app/storage/apps/${appInfo.id}`] = ['test.yml', 'docker-compose.yml'];
MockFiles[`/runtipi/apps/${appInfo.id}/docker-compose.yml`] = 'test';
MockFiles[`/runtipi/apps/${appInfo.id}/test.yml`] = 'test';
MockFiles[`/runtipi/apps/${appInfo.id}`] = ['test.yml', 'docker-compose.yml'];
// @ts-ignore
fs.__createMockFiles(MockFiles);
expect(fs.existsSync(`/app/storage/apps/${app1.id}/test.yml`)).toBe(true);
expect(fs.existsSync(`/runtipi/apps/${app1.id}/test.yml`)).toBe(true);
await AppsService.installApp(app1.id, { TEST_FIELD: 'test' });
expect(fs.existsSync(`/app/storage/apps/${app1.id}/test.yml`)).toBe(false);
expect(fs.existsSync(`/app/storage/apps/${app1.id}/docker-compose.yml`)).toBe(true);
expect(fs.existsSync(`/runtipi/apps/${app1.id}/test.yml`)).toBe(false);
expect(fs.existsSync(`/runtipi/apps/${app1.id}/docker-compose.yml`)).toBe(true);
});
it('Should throw if app is exposed and domain is not provided', async () => {
@ -175,56 +165,51 @@ describe('Uninstall app', () => {
});
it('App should be installed by default', async () => {
// Act
const app = await App.findOne({ where: { id: app1.id } });
// Assert
expect(app).toBeDefined();
expect(app!.id).toBe(app1.id);
expect(app!.status).toBe(AppStatusEnum.RUNNING);
});
it('Should correctly remove app from database', async () => {
// Act
await AppsService.uninstallApp(app1.id);
const app = await App.findOne({ where: { id: app1.id } });
// Assert
expect(app).toBeNull();
});
it('Should correctly run app script', async () => {
const spy = jest.spyOn(childProcess, 'execFile');
await AppsService.uninstallApp(app1.id);
expect(spy.mock.lastCall).toEqual([`${getConfig().rootFolder}/scripts/app.sh`, ['uninstall', app1.id], {}, expect.any(Function)]);
spy.mockRestore();
});
it('Should stop app if it is running', async () => {
const spy = jest.spyOn(childProcess, 'execFile');
// Arrange
const spy = jest.spyOn(eventDispatcher, 'dispatchEventAsync');
// Act
await AppsService.uninstallApp(app1.id);
// Assert
expect(spy.mock.calls.length).toBe(2);
expect(spy.mock.calls[0]).toEqual([`${getConfig().rootFolder}/scripts/app.sh`, ['stop', app1.id], {}, expect.any(Function)]);
expect(spy.mock.calls[1]).toEqual([`${getConfig().rootFolder}/scripts/app.sh`, ['uninstall', app1.id], {}, expect.any(Function)]);
expect(spy.mock.calls[0]).toEqual([EventTypes.APP, ['stop', app1.id]]);
expect(spy.mock.calls[1]).toEqual([EventTypes.APP, ['uninstall', app1.id]]);
spy.mockRestore();
});
it('Should throw if app is not installed', async () => {
// Act & Assert
await expect(AppsService.uninstallApp('any')).rejects.toThrowError('App any not found');
});
it('Should throw if uninstall script fails', async () => {
// Update app
// Arrange
EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'test' });
await App.update({ id: app1.id }, { status: AppStatusEnum.UPDATING });
const spy = jest.spyOn(childProcess, 'execFile');
spy.mockImplementation(() => {
throw new Error('Test error');
});
await expect(AppsService.uninstallApp(app1.id)).rejects.toThrow('Test error');
// Act & Assert
await expect(AppsService.uninstallApp(app1.id)).rejects.toThrow(`App ${app1.id} failed to uninstall\nstdout: test`);
const app = await App.findOne({ where: { id: app1.id } });
expect(app!.status).toBe(AppStatusEnum.STOPPED);
});
@ -240,12 +225,12 @@ describe('Start app', () => {
fs.__createMockFiles(Object.assign(app1create.MockFiles));
});
it('Should correctly run app script', async () => {
const spy = jest.spyOn(childProcess, 'execFile');
it('Should correctly dispatch event', async () => {
const spy = jest.spyOn(eventDispatcher, 'dispatchEventAsync');
await AppsService.startApp(app1.id);
expect(spy.mock.lastCall).toEqual([`${getConfig().rootFolder}/scripts/app.sh`, ['start', app1.id], {}, expect.any(Function)]);
expect(spy.mock.lastCall).toEqual([EventTypes.APP, ['start', app1.id]]);
spy.mockRestore();
});
@ -255,7 +240,7 @@ describe('Start app', () => {
});
it('Should restart if app is already running', async () => {
const spy = jest.spyOn(childProcess, 'execFile');
const spy = jest.spyOn(eventDispatcher, 'dispatchEventAsync');
await AppsService.startApp(app1.id);
expect(spy.mock.calls.length).toBe(1);
@ -276,12 +261,11 @@ describe('Start app', () => {
});
it('Should throw if start script fails', async () => {
const spy = jest.spyOn(childProcess, 'execFile');
spy.mockImplementation(() => {
throw new Error('Test error');
});
// Arrange
EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'test' });
await expect(AppsService.startApp(app1.id)).rejects.toThrow('Test error');
// Act & Assert
await expect(AppsService.startApp(app1.id)).rejects.toThrow(`App ${app1.id} failed to start\nstdout: test`);
const app = await App.findOne({ where: { id: app1.id } });
expect(app!.status).toBe(AppStatusEnum.STOPPED);
});
@ -297,12 +281,12 @@ describe('Stop app', () => {
fs.__createMockFiles(Object.assign(app1create.MockFiles));
});
it('Should correctly run app script', async () => {
const spy = jest.spyOn(childProcess, 'execFile');
it('Should correctly dispatch stop event', async () => {
const spy = jest.spyOn(eventDispatcher, 'dispatchEventAsync');
await AppsService.stopApp(app1.id);
expect(spy.mock.lastCall).toEqual([`${getConfig().rootFolder}/scripts/app.sh`, ['stop', app1.id], {}, expect.any(Function)]);
expect(spy.mock.lastCall).toEqual([EventTypes.APP, ['stop', app1.id]]);
});
it('Should throw if app is not installed', async () => {
@ -310,12 +294,11 @@ describe('Stop app', () => {
});
it('Should throw if stop script fails', async () => {
const spy = jest.spyOn(childProcess, 'execFile');
spy.mockImplementation(() => {
throw new Error('Test error');
});
// Arrange
EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'test' });
await expect(AppsService.stopApp(app1.id)).rejects.toThrow('Test error');
// Act & Assert
await expect(AppsService.stopApp(app1.id)).rejects.toThrow(`App ${app1.id} failed to stop\nstdout: test`);
const app = await App.findOne({ where: { id: app1.id } });
expect(app!.status).toBe(AppStatusEnum.RUNNING);
});
@ -464,19 +447,19 @@ describe('Start all apps', () => {
});
it('Should correctly start all apps', async () => {
const spy = jest.spyOn(childProcess, 'execFile');
const spy = jest.spyOn(eventDispatcher, 'dispatchEventAsync');
await AppsService.startAllApps();
expect(spy.mock.calls.length).toBe(2);
expect(spy.mock.calls).toEqual([
[`${getConfig().rootFolder}/scripts/app.sh`, ['start', app1.id], {}, expect.any(Function)],
[`${getConfig().rootFolder}/scripts/app.sh`, ['start', app2.id], {}, expect.any(Function)],
[EventTypes.APP, ['start', app1.id]],
[EventTypes.APP, ['start', app2.id]],
]);
});
it('Should not start app which has not status RUNNING', async () => {
const spy = jest.spyOn(childProcess, 'execFile');
const spy = jest.spyOn(eventDispatcher, 'dispatchEventAsync');
await createApp({ installed: true, status: AppStatusEnum.STOPPED });
await AppsService.startAllApps();
@ -487,16 +470,14 @@ describe('Start all apps', () => {
});
it('Should put app status to STOPPED if start script fails', async () => {
const spy = jest.spyOn(childProcess, 'execFile');
spy.mockImplementation(() => {
throw new Error('Test error');
});
// Arrange
EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'error' });
// Act
await AppsService.startAllApps();
const apps = await App.find();
expect(spy.mock.calls.length).toBe(2);
// Assert
expect(apps.length).toBe(2);
expect(apps[0].status).toBe(AppStatusEnum.STOPPED);
expect(apps[1].status).toBe(AppStatusEnum.STOPPED);
@ -529,12 +510,10 @@ describe('Update app', () => {
});
it('Should throw if update script fails', async () => {
const spy = jest.spyOn(childProcess, 'execFile');
spy.mockImplementation(() => {
throw new Error('Test error');
});
// Arrange
EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'error' });
await expect(AppsService.updateApp(app1.id)).rejects.toThrow('Test error');
await expect(AppsService.updateApp(app1.id)).rejects.toThrow(`App ${app1.id} failed to update\nstdout: error`);
const app = await App.findOne({ where: { id: app1.id } });
expect(app!.status).toBe(AppStatusEnum.STOPPED);
});

View file

@ -1,5 +1,5 @@
import portUsed from 'tcp-port-used';
import { fileExists, getSeed, readdirSync, readFile, readJsonFile, runScript, writeFile } from '../fs/fs.helpers';
import { fileExists, getSeed, readdirSync, readFile, readJsonFile, writeFile } from '../fs/fs.helpers';
import InternalIp from 'internal-ip';
import crypto from 'crypto';
import { AppInfo, AppStatusEnum } from './apps.types';
@ -43,7 +43,7 @@ export const getEnvMap = (appName: string): Map<string, string> => {
};
export const checkEnvFile = (appName: string) => {
const configFile: AppInfo | null = readJsonFile(`/app/storage/apps/${appName}/config.json`);
const configFile: AppInfo | null = readJsonFile(`/runtipi/apps/${appName}/config.json`);
const envMap = getEnvMap(appName);
configFile?.form_fields?.forEach((field) => {
@ -56,19 +56,6 @@ export const checkEnvFile = (appName: string) => {
});
};
export const runAppScript = async (params: string[]): Promise<void> => {
return new Promise((resolve, reject) => {
runScript('/runtipi/scripts/app.sh', [...params], (err: string) => {
if (err) {
logger.error(`Error running app script: ${err}`);
reject(err);
}
resolve();
});
});
};
const getEntropy = (name: string, length: number) => {
const hash = crypto.createHash('sha256');
hash.update(name + getSeed());
@ -76,7 +63,7 @@ const getEntropy = (name: string, length: number) => {
};
export const generateEnvFile = (app: App) => {
const configFile: AppInfo | null = readJsonFile(`/app/storage/apps/${app.id}/config.json`);
const configFile: AppInfo | null = readJsonFile(`/runtipi/apps/${app.id}/config.json`);
if (!configFile) {
throw new Error(`App ${app.id} not found`);
@ -145,9 +132,9 @@ export const getAppInfo = (id: string, status?: AppStatusEnum): AppInfo | null =
// Check if app is installed
const installed = typeof status !== 'undefined' && status !== AppStatusEnum.MISSING;
if (installed && fileExists(`/app/storage/apps/${id}/config.json`)) {
const configFile: AppInfo = readJsonFile(`/app/storage/apps/${id}/config.json`);
configFile.description = readFile(`/app/storage/apps/${id}/metadata/description.md`).toString();
if (installed && fileExists(`/runtipi/apps/${id}/config.json`)) {
const configFile: AppInfo = readJsonFile(`/runtipi/apps/${id}/config.json`);
configFile.description = readFile(`/runtipi/apps/${id}/metadata/description.md`).toString();
return configFile;
} else if (fileExists(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}/config.json`)) {
const configFile: AppInfo = readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}/config.json`);

View file

@ -1,14 +1,18 @@
import validator from 'validator';
import { createFolder, ensureAppFolder, readFile, readJsonFile } from '../fs/fs.helpers';
import { checkAppRequirements, checkEnvFile, generateEnvFile, getAvailableApps, runAppScript } from './apps.helpers';
import { checkAppRequirements, checkEnvFile, generateEnvFile, getAvailableApps } from './apps.helpers';
import { AppInfo, AppStatusEnum, ListAppsResonse } from './apps.types';
import App from './app.entity';
import logger from '../../config/logger/logger';
import { Not } from 'typeorm';
import { getConfig } from '../../core/config/TipiConfig';
import { eventDispatcher, EventTypes } from '../../core/config/EventDispatcher';
const sortApps = (a: AppInfo, b: AppInfo) => a.name.localeCompare(b.name);
/**
* Start all apps which had the status RUNNING in the database
*/
const startAllApps = async (): Promise<void> => {
const apps = await App.find({ where: { status: AppStatusEnum.RUNNING } });
@ -22,8 +26,13 @@ const startAllApps = async (): Promise<void> => {
await App.update({ id: app.id }, { status: AppStatusEnum.STARTING });
await runAppScript(['start', app.id]);
await App.update({ id: app.id }, { status: AppStatusEnum.RUNNING });
eventDispatcher.dispatchEventAsync(EventTypes.APP, ['start', app.id]).then(({ success }) => {
if (success) {
App.update({ id: app.id }, { status: AppStatusEnum.RUNNING });
} else {
App.update({ id: app.id }, { status: AppStatusEnum.STOPPED });
}
});
} catch (e) {
await App.update({ id: app.id }, { status: AppStatusEnum.STOPPED });
logger.error(e);
@ -32,6 +41,11 @@ const startAllApps = async (): Promise<void> => {
);
};
/**
* Start an app
* @param appName - id of the app to start
* @returns - the app entity
*/
const startApp = async (appName: string): Promise<App> => {
let app = await App.findOne({ where: { id: appName } });
@ -40,20 +54,18 @@ const startApp = async (appName: string): Promise<App> => {
}
ensureAppFolder(appName);
// Regenerate env file
generateEnvFile(app);
checkEnvFile(appName);
await App.update({ id: appName }, { status: AppStatusEnum.STARTING });
// Run script
try {
await runAppScript(['start', appName]);
const { success, stdout } = await eventDispatcher.dispatchEventAsync(EventTypes.APP, ['start', app.id]);
if (success) {
await App.update({ id: appName }, { status: AppStatusEnum.RUNNING });
} catch (e) {
} else {
await App.update({ id: appName }, { status: AppStatusEnum.STOPPED });
throw e;
throw new Error(`App ${appName} failed to start\nstdout: ${stdout}`);
}
app = (await App.findOne({ where: { id: appName } })) as App;
@ -61,6 +73,14 @@ const startApp = async (appName: string): Promise<App> => {
return app;
};
/**
* Given parameters, create a new app and start it
* @param id - id of the app to stop
* @param form - form data
* @param exposed - if the app should be exposed
* @param domain - domain to expose the app on
* @returns - the app entity
*/
const installApp = async (id: string, form: Record<string, string>, exposed?: boolean, domain?: string): Promise<App> => {
let app = await App.findOne({ where: { id } });
@ -85,7 +105,7 @@ const installApp = async (id: string, form: Record<string, string>, exposed?: bo
// Create app folder
createFolder(`/app/storage/app-data/${id}`);
const appInfo: AppInfo | null = await readJsonFile(`/app/storage/apps/${id}/config.json`);
const appInfo: AppInfo | null = await readJsonFile(`/runtipi/apps/${id}/config.json`);
if (!appInfo?.exposable && exposed) {
throw new Error(`App ${id} is not exposable`);
@ -104,11 +124,11 @@ const installApp = async (id: string, form: Record<string, string>, exposed?: bo
generateEnvFile(app);
// Run script
try {
await runAppScript(['install', id]);
} catch (e) {
const { success, stdout } = await eventDispatcher.dispatchEventAsync(EventTypes.APP, ['install', id]);
if (!success) {
await App.delete({ id });
throw e;
throw new Error(`App ${id} failed to install\nstdout: ${stdout}`);
}
}
@ -118,6 +138,10 @@ const installApp = async (id: string, form: Record<string, string>, exposed?: bo
return app;
};
/**
* List all apps available for installation
* @returns - list of all apps available
*/
const listApps = async (): Promise<ListAppsResonse> => {
const folders: string[] = await getAvailableApps();
@ -138,6 +162,14 @@ const listApps = async (): Promise<ListAppsResonse> => {
return { apps: apps.sort(sortApps), total: apps.length };
};
/**
* Given parameters, updates an app config and regenerates the env file
* @param id - id of the app to stop
* @param form - form data
* @param exposed - if the app should be exposed
* @param domain - domain to expose the app on
* @returns - the app entity
*/
const updateAppConfig = async (id: string, form: Record<string, string>, exposed?: boolean, domain?: string): Promise<App> => {
if (exposed && !domain) {
throw new Error('Domain is required if app is exposed');
@ -147,7 +179,7 @@ const updateAppConfig = async (id: string, form: Record<string, string>, exposed
throw new Error(`Domain ${domain} is not valid`);
}
const appInfo: AppInfo | null = await readJsonFile(`/app/storage/apps/${id}/config.json`);
const appInfo: AppInfo | null = await readJsonFile(`/runtipi/apps/${id}/config.json`);
if (!appInfo?.exposable && exposed) {
throw new Error(`App ${id} is not exposable`);
@ -175,6 +207,11 @@ const updateAppConfig = async (id: string, form: Record<string, string>, exposed
return app;
};
/**
* Stops an app
* @param id - id of the app to stop
* @returns - the app entity
*/
const stopApp = async (id: string): Promise<App> => {
let app = await App.findOne({ where: { id } });
@ -183,16 +220,18 @@ const stopApp = async (id: string): Promise<App> => {
}
ensureAppFolder(id);
generateEnvFile(app);
// Run script
await App.update({ id }, { status: AppStatusEnum.STOPPING });
try {
await runAppScript(['stop', id]);
const { success, stdout } = await eventDispatcher.dispatchEventAsync(EventTypes.APP, ['stop', id]);
if (success) {
await App.update({ id }, { status: AppStatusEnum.STOPPED });
} catch (e) {
} else {
await App.update({ id }, { status: AppStatusEnum.RUNNING });
throw e;
throw new Error(`App ${id} failed to stop\nstdout: ${stdout}`);
}
app = (await App.findOne({ where: { id } })) as App;
@ -200,6 +239,11 @@ const stopApp = async (id: string): Promise<App> => {
return app;
};
/**
* Uninstalls an app
* @param id - id of the app to uninstall
* @returns - the app entity
*/
const uninstallApp = async (id: string): Promise<App> => {
let app = await App.findOne({ where: { id } });
@ -211,14 +255,15 @@ const uninstallApp = async (id: string): Promise<App> => {
}
ensureAppFolder(id);
generateEnvFile(app);
await App.update({ id }, { status: AppStatusEnum.UNINSTALLING });
// Run script
try {
await runAppScript(['uninstall', id]);
} catch (e) {
const { success, stdout } = await eventDispatcher.dispatchEventAsync(EventTypes.APP, ['uninstall', id]);
if (!success) {
await App.update({ id }, { status: AppStatusEnum.STOPPED });
throw e;
throw new Error(`App ${id} failed to uninstall\nstdout: ${stdout}`);
}
await App.delete({ id });
@ -226,6 +271,11 @@ const uninstallApp = async (id: string): Promise<App> => {
return { id, status: AppStatusEnum.MISSING, config: {} } as App;
};
/**
* Get an app entity
* @param id - id of the app
* @returns - the app entity
*/
const getApp = async (id: string): Promise<App> => {
let app = await App.findOne({ where: { id } });
@ -236,6 +286,11 @@ const getApp = async (id: string): Promise<App> => {
return app;
};
/**
* Updates an app to the latest version from repository
* @param id - id of the app
* @returns - the app entity
*/
const updateApp = async (id: string) => {
let app = await App.findOne({ where: { id } });
@ -244,21 +299,21 @@ const updateApp = async (id: string) => {
}
ensureAppFolder(id);
generateEnvFile(app);
await App.update({ id }, { status: AppStatusEnum.UPDATING });
// Run script
try {
await runAppScript(['update', id]);
const appInfo: AppInfo | null = await readJsonFile(`/app/storage/apps/${id}/config.json`);
const { success, stdout } = await eventDispatcher.dispatchEventAsync(EventTypes.APP, ['update', id]);
if (success) {
const appInfo: AppInfo | null = await readJsonFile(`/runtipi/apps/${id}/config.json`);
await App.update({ id }, { status: AppStatusEnum.RUNNING, version: Number(appInfo?.tipi_version) });
} catch (e) {
logger.error(e);
throw e;
} finally {
} else {
await App.update({ id }, { status: AppStatusEnum.STOPPED });
throw new Error(`App ${id} failed to update\nstdout: ${stdout}`);
}
await App.update({ id }, { status: AppStatusEnum.STOPPED });
app = (await App.findOne({ where: { id } })) as App;
return app;

View file

@ -1,5 +1,4 @@
import childProcess from 'child_process';
import { readJsonFile, readFile, readdirSync, fileExists, writeFile, createFolder, deleteFolder, runScript, getSeed, ensureAppFolder } from '../fs.helpers';
import { readJsonFile, readFile, readdirSync, fileExists, writeFile, createFolder, deleteFolder, getSeed, ensureAppFolder } from '../fs.helpers';
import fs from 'fs-extra';
import { getConfig } from '../../../core/config/TipiConfig';
import { faker } from '@faker-js/faker';
@ -131,17 +130,6 @@ describe('Test: deleteFolder', () => {
});
});
describe('Test: runScript', () => {
it('should run the script', () => {
const spy = jest.spyOn(childProcess, 'execFile');
const callback = jest.fn();
runScript('/test', [], callback);
expect(spy).toHaveBeenCalledWith('/test', [], {}, callback);
});
});
describe('Test: getSeed', () => {
it('should return the seed', () => {
const mockFiles = {
@ -169,15 +157,15 @@ describe('Test: ensureAppFolder', () => {
ensureAppFolder('test');
// Assert
const files = fs.readdirSync('/app/storage/apps/test');
const files = fs.readdirSync('/runtipi/apps/test');
expect(files).toEqual(['test.yml']);
});
it('should not copy the folder if it already exists', () => {
const mockFiles = {
[`/runtipi/repos/${getConfig().appsRepoId}/apps/test`]: ['test.yml'],
['/app/storage/apps/test']: ['docker-compose.yml'],
['/app/storage/apps/test/docker-compose.yml']: 'test',
['/runtipi/apps/test']: ['docker-compose.yml'],
['/runtipi/apps/test/docker-compose.yml']: 'test',
};
// @ts-ignore
@ -187,15 +175,15 @@ describe('Test: ensureAppFolder', () => {
ensureAppFolder('test');
// Assert
const files = fs.readdirSync('/app/storage/apps/test');
const files = fs.readdirSync('/runtipi/apps/test');
expect(files).toEqual(['docker-compose.yml']);
});
it('Should overwrite the folder if clean up is true', () => {
const mockFiles = {
[`/runtipi/repos/${getConfig().appsRepoId}/apps/test`]: ['test.yml'],
['/app/storage/apps/test']: ['docker-compose.yml'],
['/app/storage/apps/test/docker-compose.yml']: 'test',
['/runtipi/apps/test']: ['docker-compose.yml'],
['/runtipi/apps/test/docker-compose.yml']: 'test',
};
// @ts-ignore
@ -205,7 +193,7 @@ describe('Test: ensureAppFolder', () => {
ensureAppFolder('test', true);
// Assert
const files = fs.readdirSync('/app/storage/apps/test');
const files = fs.readdirSync('/runtipi/apps/test');
expect(files).toEqual(['test.yml']);
});
@ -214,7 +202,7 @@ describe('Test: ensureAppFolder', () => {
const randomFileName = `${faker.random.word()}.yml`;
const mockFiles = {
[`/runtipi/repos/${getConfig().appsRepoId}/apps/test`]: [randomFileName],
['/app/storage/apps/test']: ['test.yml'],
['/runtipi/apps/test']: ['test.yml'],
};
// @ts-ignore
@ -224,7 +212,7 @@ describe('Test: ensureAppFolder', () => {
ensureAppFolder('test');
// Assert
const files = fs.readdirSync('/app/storage/apps/test');
const files = fs.readdirSync('/runtipi/apps/test');
expect(files).toEqual([randomFileName]);
});
});

View file

@ -1,14 +1,9 @@
import fs from 'fs-extra';
import childProcess from 'child_process';
import { getConfig } from '../../core/config/TipiConfig';
export const readJsonFile = (path: string): any => {
try {
const rawFile = fs.readFileSync(path)?.toString();
if (!rawFile) {
return null;
}
const rawFile = fs.readFileSync(path).toString();
return JSON.parse(rawFile);
} catch (e) {
@ -37,21 +32,21 @@ export const createFolder = (path: string) => {
};
export const deleteFolder = (path: string) => fs.rmSync(path, { recursive: true });
export const runScript = (path: string, args: string[], callback?: any) => childProcess.execFile(path, args, {}, callback);
export const getSeed = () => {
const seed = readFile('/runtipi/state/seed');
return seed.toString();
};
export const ensureAppFolder = (appName: string, cleanup = false) => {
if (cleanup && fileExists(`/app/storage/apps/${appName}`)) {
deleteFolder(`/app/storage/apps/${appName}`);
if (cleanup && fileExists(`/runtipi/apps/${appName}`)) {
deleteFolder(`/runtipi/apps/${appName}`);
}
if (!fileExists(`/app/storage/apps/${appName}/docker-compose.yml`)) {
if (fileExists(`/app/storage/apps/${appName}`)) deleteFolder(`/app/storage/apps/${appName}`);
if (!fileExists(`/runtipi/apps/${appName}/docker-compose.yml`)) {
if (fileExists(`/runtipi/apps/${appName}`)) {
deleteFolder(`/runtipi/apps/${appName}`);
}
// Copy from apps repo
fs.copySync(`/runtipi/repos/${getConfig().appsRepoId}/apps/${appName}`, `/app/storage/apps/${appName}`);
fs.copySync(`/runtipi/repos/${getConfig().appsRepoId}/apps/${appName}`, `/runtipi/apps/${appName}`);
}
};

View file

@ -12,6 +12,7 @@ import { systemInfoQuery, versionQuery } from '../../../test/queries';
import User from '../../auth/user.entity';
import { createUser } from '../../auth/__tests__/user.factory';
import { SystemInfoResponse } from '../system.types';
import EventDispatcher from '../../../core/config/EventDispatcher';
jest.mock('fs-extra');
jest.mock('axios');
@ -133,38 +134,50 @@ describe('Test: restart', () => {
});
it('Should return true', async () => {
// Arrange
EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: true });
// Act
const user = await createUser();
const { data } = await gcall<{ restart: boolean }>({ source: restartMutation, userId: user.id });
// Assert
expect(data?.restart).toBeDefined();
expect(data?.restart).toBe(true);
});
it("Should return an error if user doesn't exist", async () => {
// Arrange
const { data, errors } = await gcall<{ restart: boolean }>({
source: restartMutation,
userId: 1,
});
// Assert
expect(errors?.[0].message).toBe('Access denied! You need to be authorized to perform this action!');
expect(data?.restart).toBeUndefined();
});
it('Should throw an error if no userId is not provided', async () => {
// Arrange
const { data, errors } = await gcall<{ restart: boolean }>({ source: restartMutation });
// Assert
expect(errors?.[0].message).toBe('Access denied! You need to be authorized to perform this action!');
expect(data?.restart).toBeUndefined();
});
it('Should set app status to restarting', async () => {
// Arrange
EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: true });
const spy = jest.spyOn(TipiConfig, 'setConfig');
const user = await createUser();
// Act
await gcall<{ restart: boolean }>({ source: restartMutation, userId: user.id });
// Assert
expect(spy).toHaveBeenCalledTimes(2);
expect(spy).toHaveBeenNthCalledWith(1, 'status', 'RESTARTING');
expect(spy).toHaveBeenNthCalledWith(2, 'status', 'RUNNING');
@ -180,35 +193,47 @@ describe('Test: update', () => {
});
it('Should return true', async () => {
// Arrange
EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: true });
const user = await createUser();
// Act
const { data } = await gcall<{ update: boolean }>({ source: updateMutation, userId: user.id });
// Assert
expect(data?.update).toBeDefined();
expect(data?.update).toBe(true);
});
it("Should return an error if user doesn't exist", async () => {
// Act
const { data, errors } = await gcall<{ update: boolean }>({ source: updateMutation, userId: 1 });
// Assert
expect(errors?.[0].message).toBe('Access denied! You need to be authorized to perform this action!');
expect(data?.update).toBeUndefined();
});
it('Should throw an error if no userId is not provided', async () => {
// Act
const { data, errors } = await gcall<{ update: boolean }>({ source: updateMutation });
// Assert
expect(errors?.[0].message).toBe('Access denied! You need to be authorized to perform this action!');
expect(data?.update).toBeUndefined();
});
it('Should set app status to updating', async () => {
// Arrange
EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: true });
const spy = jest.spyOn(TipiConfig, 'setConfig');
const user = await createUser();
// Act
await gcall<{ update: boolean }>({ source: updateMutation, userId: user.id });
// Assert
expect(spy).toHaveBeenCalledTimes(2);
expect(spy).toHaveBeenNthCalledWith(1, 'status', 'UPDATING');
expect(spy).toHaveBeenNthCalledWith(2, 'status', 'RUNNING');

View file

@ -1,15 +1,14 @@
import fs from 'fs-extra';
import semver from 'semver';
import childProcess from 'child_process';
import axios from 'axios';
import SystemService from '../system.service';
import { faker } from '@faker-js/faker';
import TipiCache from '../../../config/TipiCache';
import { setConfig } from '../../../core/config/TipiConfig';
import logger from '../../../config/logger/logger';
import EventDispatcher from '../../../core/config/EventDispatcher';
jest.mock('fs-extra');
jest.mock('child_process');
jest.mock('axios');
beforeEach(async () => {
@ -21,14 +20,14 @@ describe('Test: systemInfo', () => {
it('Should throw if system-info.json does not exist', () => {
try {
SystemService.systemInfo();
} catch (e) {
} catch (e: any) {
expect(e).toBeDefined();
// @ts-ignore
expect(e.message).toBe('Error parsing system info');
}
});
it('It should return system info', async () => {
// Arrange
const info = {
cpu: { load: 0.1 },
memory: { available: 1000, total: 2000, used: 1000 },
@ -42,8 +41,10 @@ describe('Test: systemInfo', () => {
// @ts-ignore
fs.__createMockFiles(MockFiles);
// Act
const systemInfo = SystemService.systemInfo();
// Assert
expect(systemInfo).toBeDefined();
expect(systemInfo.cpu).toBeDefined();
expect(systemInfo.memory).toBeDefined();
@ -60,11 +61,15 @@ describe('Test: getVersion', () => {
});
it('It should return version', async () => {
// Arrange
const spy = jest.spyOn(axios, 'get').mockResolvedValue({
data: { name: `v${faker.random.numeric(1)}.${faker.random.numeric(1)}.${faker.random.numeric()}` },
});
// Act
const version = await SystemService.getVersion();
// Assert
expect(version).toBeDefined();
expect(version.current).toBeDefined();
expect(semver.valid(version.latest)).toBeTruthy();
@ -85,17 +90,20 @@ describe('Test: getVersion', () => {
});
it('Should return cached version', async () => {
// Arrange
const spy = jest.spyOn(axios, 'get').mockResolvedValue({
data: { name: `v${faker.random.numeric(1)}.${faker.random.numeric(1)}.${faker.random.numeric()}` },
});
const version = await SystemService.getVersion();
// Act
const version = await SystemService.getVersion();
const version2 = await SystemService.getVersion();
// Assert
expect(version).toBeDefined();
expect(version.current).toBeDefined();
expect(semver.valid(version.latest)).toBeTruthy();
const version2 = await SystemService.getVersion();
expect(version2.latest).toBe(version.latest);
expect(version2.current).toBeDefined();
expect(semver.valid(version2.latest)).toBeTruthy();
@ -108,88 +116,98 @@ describe('Test: getVersion', () => {
describe('Test: restart', () => {
it('Should return true', async () => {
// Arrange
EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: true });
// Act
const restart = await SystemService.restart();
// Assert
expect(restart).toBeTruthy();
});
it('Should log error if fails', async () => {
// @ts-ignore
const spy = jest.spyOn(childProcess, 'execFile').mockImplementation((_path, _args, _, cb) => {
// @ts-ignore
if (cb) cb('error', null, null);
});
// Arrange
EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'fake' });
const log = jest.spyOn(logger, 'error');
// Act
const restart = await SystemService.restart();
expect(restart).toBeTruthy();
expect(log).toHaveBeenCalledWith('Error restarting: error');
spy.mockRestore();
// Assert
expect(restart).toBeFalsy();
expect(log).toHaveBeenCalledWith('Error restarting system: fake');
log.mockRestore();
});
});
describe('Test: update', () => {
it('Should return true', async () => {
// Arrange
EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: true });
setConfig('version', '0.0.1');
TipiCache.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
TipiCache.del('latestVersion');
const spy = jest.spyOn(axios, 'get').mockResolvedValue({
data: { name: null },
});
setConfig('version', '0.0.1');
// Act & Assert
await expect(SystemService.update()).rejects.toThrow('Could not get latest version');
spy.mockRestore();
});
it('Should throw if current version is higher than latest', async () => {
// Arrange
setConfig('version', '0.0.2');
TipiCache.set('latestVersion', '0.0.1');
// Act & Assert
await expect(SystemService.update()).rejects.toThrow('Current version is newer than latest version');
});
it('Should throw if current version is equal to latest', async () => {
// Arrange
setConfig('version', '0.0.1');
TipiCache.set('latestVersion', '0.0.1');
// Act & Assert
await expect(SystemService.update()).rejects.toThrow('Current version is already up to date');
});
it('Should throw an error if there is a major version difference', async () => {
// Arrange
setConfig('version', '0.0.1');
TipiCache.set('latestVersion', '1.0.0');
// Act & Assert
await expect(SystemService.update()).rejects.toThrow('The major version has changed. Please update manually');
});
it('Should log error if fails', async () => {
// @ts-ignore
const spy = jest.spyOn(childProcess, 'execFile').mockImplementation((_path, _args, _, cb) => {
// @ts-ignore
if (cb) cb('error', null, null);
});
// Arrange
EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'fake2' });
const log = jest.spyOn(logger, 'error');
// Act
setConfig('version', '0.0.1');
TipiCache.set('latestVersion', '0.0.2');
const update = await SystemService.update();
expect(update).toBeTruthy();
expect(log).toHaveBeenCalledWith('Error updating: error');
spy.mockRestore();
// Assert
expect(update).toBeFalsy();
expect(log).toHaveBeenCalledWith('Error updating system: fake2');
log.mockRestore();
});
});

View file

@ -4,7 +4,8 @@ import semver from 'semver';
import logger from '../../config/logger/logger';
import TipiCache from '../../config/TipiCache';
import { getConfig, setConfig } from '../../core/config/TipiConfig';
import { readJsonFile, runScript } from '../fs/fs.helpers';
import { readJsonFile } from '../fs/fs.helpers';
import { eventDispatcher, EventTypes } from '../../core/config/EventDispatcher';
const systemInfoSchema = z.object({
cpu: z.object({
@ -57,12 +58,12 @@ const getVersion = async (): Promise<{ current: string; latest?: string }> => {
const restart = async (): Promise<boolean> => {
setConfig('status', 'RESTARTING');
runScript('/runtipi/scripts/system.sh', ['restart'], (err: string) => {
setConfig('status', 'RUNNING');
if (err) {
logger.error(`Error restarting: ${err}`);
}
});
const { success, stdout } = await eventDispatcher.dispatchEventAsync(EventTypes.RESTART);
if (!success) {
logger.error(`Error restarting system: ${stdout}`);
return false;
}
setConfig('status', 'RUNNING');
@ -90,12 +91,12 @@ const update = async (): Promise<boolean> => {
setConfig('status', 'UPDATING');
runScript('/runtipi/scripts/system.sh', ['update'], (err: string) => {
setConfig('status', 'RUNNING');
if (err) {
logger.error(`Error updating: ${err}`);
}
});
const { success, stdout } = await eventDispatcher.dispatchEventAsync(EventTypes.UPDATE);
if (!success) {
logger.error(`Error updating system: ${stdout}`);
return false;
}
setConfig('status', 'RUNNING');

View file

@ -14,11 +14,11 @@ import datasource from './config/datasource';
import appsService from './modules/apps/apps.service';
import { runUpdates } from './core/updates/run';
import recover from './core/updates/recover-migrations';
import { cloneRepo, updateRepo } from './helpers/repo-helpers';
import startJobs from './core/jobs/jobs';
import { applyJsonConfig, getConfig, setConfig } from './core/config/TipiConfig';
import { ZodError } from 'zod';
import systemController from './modules/system/system.controller';
import { eventDispatcher, EventTypes } from './core/config/EventDispatcher';
let corsOptions = {
credentials: true,
@ -53,6 +53,7 @@ const applyCustomConfig = () => {
const main = async () => {
try {
eventDispatcher.clear();
applyCustomConfig();
const app = express();
@ -93,8 +94,9 @@ const main = async () => {
await runUpdates();
httpServer.listen(port, async () => {
await cloneRepo(getConfig().appsRepoUrl);
await updateRepo(getConfig().appsRepoUrl);
await eventDispatcher.dispatchEventAsync(EventTypes.CLONE_REPO, [getConfig().appsRepoUrl]);
await eventDispatcher.dispatchEventAsync(EventTypes.UPDATE_REPO, [getConfig().appsRepoUrl]);
startJobs();
setConfig('status', 'RUNNING');

View file

@ -1,5 +1,11 @@
import { eventDispatcher } from '../core/config/EventDispatcher';
jest.mock('../config/logger/logger', () => ({
error: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
}));
afterAll(() => {
eventDispatcher.clearInterval();
});

View file

@ -49,7 +49,7 @@ else
cp -r "${ROOT_FOLDER}/repos/${REPO_ID}/apps/${app}"/* "${app_dir}"
fi
app_data_dir="/app/storage/app-data/${app}"
app_data_dir="${STORAGE_PATH}/app-data/${app}"
if [[ -z "${app}" ]] || [[ ! -d "${app_dir}" ]]; then
echo "Error: \"${app}\" is not a valid app"
@ -110,7 +110,10 @@ if [[ "$command" = "install" ]]; then
# Write to file script.log
write_log "Installing app ${app}..."
compose "${app}" pull
if ! compose "${app}" pull; then
write_log "Failed to pull app ${app}"
exit 1
fi
# Copy default data dir to app data dir if it exists
if [[ -d "${app_dir}/data" ]]; then
@ -120,20 +123,30 @@ if [[ "$command" = "install" ]]; then
# Remove all .gitkeep files from app data dir
find "${app_data_dir}" -name ".gitkeep" -exec rm -f {} \;
chown -R "1000:1000" "${app_data_dir}"
chmod -R a+rwx "${app_data_dir}"
compose "${app}" up -d
exit
if ! compose "${app}" up -d; then
write_log "Failed to start app ${app}"
exit 1
fi
exit 0
fi
# Removes images and destroys all data for an app
if [[ "$command" = "uninstall" ]]; then
echo "Removing images for app ${app}..."
write_log "Removing images for app ${app}..."
compose "${app}" up --detach
compose "${app}" down --rmi all --remove-orphans
if ! compose "${app}" up --detach; then
write_log "Failed to uninstall app ${app}"
exit 1
fi
if ! compose "${app}" down --rmi all --remove-orphans; then
write_log "Failed to uninstall app ${app}"
exit 1
fi
echo "Deleting app data for app ${app}..."
write_log "Deleting app data for app ${app}..."
if [[ -d "${app_data_dir}" ]]; then
rm -rf "${app_data_dir}"
fi
@ -142,14 +155,21 @@ if [[ "$command" = "uninstall" ]]; then
rm -rf "${app_dir}"
fi
echo "Successfully uninstalled app ${app}"
write_log "Successfully uninstalled app ${app}"
exit
fi
# Update an app
if [[ "$command" = "update" ]]; then
compose "${app}" up --detach
compose "${app}" down --rmi all --remove-orphans
if ! compose "${app}" up --detach; then
write_log "Failed to update app ${app}"
exit 1
fi
if ! compose "${app}" down --rmi all --remove-orphans; then
write_log "Failed to update app ${app}"
exit 1
fi
# Remove app
if [[ -d "${app_dir}" ]]; then
@ -160,27 +180,38 @@ if [[ "$command" = "update" ]]; then
cp -r "${ROOT_FOLDER}/repos/${REPO_ID}/apps/${app}" "${app_dir}"
compose "${app}" pull
exit
exit 0
fi
# Stops an installed app
if [[ "$command" = "stop" ]]; then
echo "Stopping app ${app}..."
compose "${app}" rm --force --stop
exit
write_log "Stopping app ${app}..."
if ! compose "${app}" rm --force --stop; then
write_log "Failed to stop app ${app}"
exit 1
fi
exit 0
fi
# Starts an installed app
if [[ "$command" = "start" ]]; then
echo "Starting app ${app}..."
compose "${app}" up --detach
exit
write_log "Starting app ${app}..."
if ! compose "${app}" up --detach; then
write_log "Failed to start app ${app}"
exit 1
fi
exit 0
fi
# Passes all arguments to Docker Compose
if [[ "$command" = "compose" ]]; then
compose "${app}" "${args}"
exit
if ! compose "${app}" "${args}"; then
write_log "Failed to run compose command for app ${app}"
exit 1
fi
exit 0
fi
exit 1

View file

@ -10,7 +10,7 @@ function get_json_field() {
function write_log() {
local message="$1"
local log_file="/app/logs/script.log"
local log_file="${PWD}/logs/script.log"
echo "$(date) - ${message}" >>"${log_file}"
}
@ -29,9 +29,6 @@ function derive_entropy() {
}
function ensure_pwd() {
# # Ensure PWD ends with /runtipi
cd /runtipi || echo ""
if [[ $(basename "$(pwd)") != "runtipi" ]] || [[ ! -f "${BASH_SOURCE[0]}" ]]; then
echo "Please run this script from the runtipi directory"
exit 1
@ -69,3 +66,18 @@ function clean_logs() {
done
fi
}
function kill_watcher() {
watcher_pid=$(pgrep -f "runtipi/state/events")
# kill it if it's running
if [[ -n $watcher_pid ]]; then
# If multiline kill each pid
if [[ $watcher_pid == *" "* ]]; then
for pid in $watcher_pid; do
kill -9 $pid
done
else
kill -9 $watcher_pid
fi
fi
}

View file

@ -1,5 +1,34 @@
#!/usr/bin/env bash
OS="$(cat /etc/[A-Za-z]*[_-][rv]e[lr]* | grep "^ID=" | cut -d= -f2 | uniq | tr '[:upper:]' '[:lower:]' | tr -d '"')"
SUB_OS="$(cat /etc/[A-Za-z]*[_-][rv]e[lr]* | grep "^ID_LIKE=" | cut -d= -f2 | uniq | tr '[:upper:]' '[:lower:]' | tr -d '"')"
function install_generic() {
local dependency="${1}"
local os="${2}"
if [[ "${os}" == "debian" ]]; then
sudo apt-get update
sudo apt-get install -y "${dependency}"
return 0
elif [[ "${os}" == "ubuntu" || "${os}" == "pop" ]]; then
sudo apt-get update
sudo apt-get install -y "${dependency}"
return 0
elif [[ "${os}" == "centos" ]]; then
sudo yum install -y --allowerasing "${dependency}"
return 0
elif [[ "${os}" == "fedora" ]]; then
sudo dnf -y install "${dependency}"
return 0
elif [[ "${os}" == "arch" ]]; then
sudo pacman -Sy --noconfirm "${dependency}"
return 0
else
return 1
fi
}
function install_docker() {
local os="${1}"
echo "Installing docker for os ${os}" >/dev/tty
@ -42,12 +71,6 @@ function install_docker() {
sudo pacman -Sy --noconfirm docker docker-compose
sudo systemctl start docker.service
sudo systemctl enable docker.service
if ! command -v crontab >/dev/null; then
sudo pacman -Sy --noconfirm cronie
systemctl enable --now cronie.service
fi
return 0
else
return 1
@ -74,65 +97,12 @@ function update_docker() {
return 0
elif [[ "${os}" == "arch" ]]; then
sudo pacman -Sy --noconfirm docker docker-compose
if ! command -v crontab >/dev/null; then
sudo pacman -Sy --noconfirm cronie
systemctl enable --now cronie.service
fi
return 0
else
return 1
fi
}
function install_jq() {
local os="${1}"
echo "Installing jq for os ${os}" >/dev/tty
if [[ "${os}" == "debian" || "${os}" == "ubuntu" || "${os}" == "pop" ]]; then
sudo apt-get update
sudo apt-get install -y jq
return 0
elif [[ "${os}" == "centos" ]]; then
sudo yum install -y jq
return 0
elif [[ "${os}" == "fedora" ]]; then
sudo dnf -y install jq
return 0
elif [[ "${os}" == "arch" ]]; then
sudo pacman -Sy --noconfirm jq
return 0
else
return 1
fi
}
function install_openssl() {
local os="${1}"
echo "Installing openssl for os ${os}" >/dev/tty
if [[ "${os}" == "debian" || "${os}" == "ubuntu" || "${os}" == "pop" ]]; then
sudo apt-get update
sudo apt-get install -y openssl
return 0
elif [[ "${os}" == "centos" ]]; then
sudo yum install -y openssl
return 0
elif [[ "${os}" == "fedora" ]]; then
sudo dnf -y install openssl
return 0
elif [[ "${os}" == "arch" ]]; then
sudo pacman -Sy --noconfirm openssl
return 0
else
return 1
fi
}
OS="$(cat /etc/[A-Za-z]*[_-][rv]e[lr]* | grep "^ID=" | cut -d= -f2 | uniq | tr '[:upper:]' '[:lower:]' | tr -d '"')"
SUB_OS="$(cat /etc/[A-Za-z]*[_-][rv]e[lr]* | grep "^ID_LIKE=" | cut -d= -f2 | uniq | tr '[:upper:]' '[:lower:]' | tr -d '"')"
if command -v docker >/dev/null; then
echo "Docker is already installed, ensuring Docker is fully up to date"
@ -173,42 +143,31 @@ else
fi
fi
if ! command -v jq >/dev/null; then
install_jq "${OS}"
jq_result=$?
function check_dependency_and_install() {
local dependency="${1}"
if [[ jq_result -eq 0 ]]; then
echo "jq installed"
else
echo "Your system ${OS} is not supported trying with sub_os ${SUB_OS}"
install_jq "${SUB_OS}"
jq_sub_result=$?
if ! command -v fswatch >/dev/null; then
echo "Installing ${dependency}"
install_generic "${dependency}" "${OS}"
install_result=$?
if [[ jq_sub_result -eq 0 ]]; then
echo "jq installed"
if [[ install_result -eq 0 ]]; then
echo "${dependency} installed"
else
echo "Your system ${SUB_OS} is not supported please install jq manually"
exit 1
echo "Your system ${OS} is not supported trying with sub_os ${SUB_OS}"
install_generic "${dependency}" "${SUB_OS}"
install_sub_result=$?
if [[ install_sub_result -eq 0 ]]; then
echo "${dependency} installed"
else
echo "Your system ${SUB_OS} is not supported please install ${dependency} manually"
exit 1
fi
fi
fi
fi
}
if ! command -v openssl >/dev/null; then
install_openssl "${OS}"
openssl_result=$?
if [[ openssl_result -eq 0 ]]; then
echo "openssl installed"
else
echo "Your system ${OS} is not supported trying with sub_os ${SUB_OS}"
install_openssl "${SUB_OS}"
openssl_sub_result=$?
if [[ openssl_sub_result -eq 0 ]]; then
echo "openssl installed"
else
echo "Your system ${SUB_OS} is not supported please install openssl manually"
exit 1
fi
fi
fi
check_dependency_and_install "jq"
check_dependency_and_install "fswatch"
check_dependency_and_install "openssl"

View file

@ -1,13 +1,8 @@
#!/usr/bin/env bash
# Don't break if command fails
cd /runtipi || echo ""
source "${BASH_SOURCE%/*}/common.sh"
# Ensure PWD ends with /runtipi
if [[ $(basename "$(pwd)") != "runtipi" ]] || [[ ! -f "${BASH_SOURCE[0]}" ]]; then
echo "Please make sure this script is executed from runtipi/"
exit 1
fi
ensure_pwd
ROOT_FOLDER="${PWD}"
@ -28,17 +23,22 @@ if [[ "$command" = "clone" ]]; then
repo="$2"
repo_hash=$(get_hash "${repo}")
echo "Cloning ${repo} to ${ROOT_FOLDER}/repos/${repo_hash}"
write_log "Cloning ${repo} to ${ROOT_FOLDER}/repos/${repo_hash}"
repo_dir="${ROOT_FOLDER}/repos/${repo_hash}"
if [ -d "${repo_dir}" ]; then
echo "Repo already exists"
write_log "Repo already exists"
exit 0
fi
echo "Cloning ${repo} to ${repo_dir}"
git clone "${repo}" "${repo_dir}"
echo "Done"
exit
write_log "Cloning ${repo} to ${repo_dir}"
if ! git clone "${repo}" "${repo_dir}"; then
write_log "Failed to clone repo"
exit 1
fi
write_log "Done"
exit 0
fi
# Update a repo
@ -47,15 +47,21 @@ if [[ "$command" = "update" ]]; then
repo_hash=$(get_hash "${repo}")
repo_dir="${ROOT_FOLDER}/repos/${repo_hash}"
if [ ! -d "${repo_dir}" ]; then
echo "Repo does not exist"
exit 0
write_log "Repo does not exist"
exit 1
fi
echo "Updating ${repo} in ${repo_hash}"
write_log "Updating ${repo} in ${repo_hash}"
cd "${repo_dir}" || exit
git pull origin master
echo "Done"
exit
if ! git pull origin master; then
write_log "Failed to update repo"
exit 1
fi
cd "${ROOT_FOLDER}" || exit
write_log "Done"
exit 0
fi
if [[ "$command" = "get_hash" ]]; then

9
scripts/start-dev.sh Executable file
View file

@ -0,0 +1,9 @@
#!/usr/bin/env bash
source "${BASH_SOURCE%/*}/common.sh"
ROOT_FOLDER="${PWD}"
kill_watcher
"${ROOT_FOLDER}/scripts/watcher.sh" &
docker compose -f docker-compose.dev.yml --env-file "${ROOT_FOLDER}/.env.dev" up --build

View file

@ -4,8 +4,6 @@ set -e # Exit immediately if a command exits with a non-zero status.
source "${BASH_SOURCE%/*}/common.sh"
write_log "Starting Tipi..."
ROOT_FOLDER="${PWD}"
# Cleanup and ensure environment
@ -105,6 +103,9 @@ fi
# Configure Tipi
"${ROOT_FOLDER}/scripts/configure.sh"
kill_watcher
"${ROOT_FOLDER}/scripts/watcher.sh" &
# Copy the config sample if it isn't here
if [[ ! -f "${STATE_FOLDER}/apps.json" ]]; then
cp "${ROOT_FOLDER}/templates/config-sample.json" "${STATE_FOLDER}/config.json"
@ -201,12 +202,6 @@ mv -f "$ENV_FILE" "$ROOT_FOLDER/.env"
echo "Running system-info.sh..."
bash "${ROOT_FOLDER}/scripts/system-info.sh"
# Add crontab to run system-info.sh every minute
! (crontab -l | grep -q "${ROOT_FOLDER}/scripts/system-info.sh") && (
crontab -l
echo "* * * * * ${ROOT_FOLDER}/scripts/system-info.sh"
) | crontab -
## Don't run if config-only
if [[ ! $ci == "true" ]]; then

View file

@ -3,10 +3,12 @@ set -euo pipefail
source "${BASH_SOURCE%/*}/common.sh"
ensure_root
ensure_pwd
ensure_root
ROOT_FOLDER="${PWD}"
ENV_FILE="${ROOT_FOLDER}/.env"
STORAGE_PATH=$(grep -v '^#' "${ENV_FILE}" | xargs -n 1 | grep STORAGE_PATH | cut -d '=' -f2)
export DOCKER_CLIENT_TIMEOUT=240
export COMPOSE_HTTP_TIMEOUT=240
@ -18,9 +20,9 @@ if [ "$(find "${apps_folder}" -maxdepth 1 -type d | wc -l)" -gt 1 ]; then
for app_name in "${apps_names[@]}"; do
# if folder ${ROOT_FOLDER}/app-data/app_name exists, then stop app
if [[ -d "${ROOT_FOLDER}/app-data/${app_name}" ]]; then
if [[ -d "${STORAGE_PATH}/app-data/${app_name}" ]]; then
echo "Stopping ${app_name}"
"${ROOT_FOLDER}/scripts/app.sh" stop $app_name
"${ROOT_FOLDER}/scripts/app.sh" stop "$app_name"
fi
done
else

View file

@ -1,9 +1,10 @@
#!/usr/bin/env bash
set -e # Exit immediately if a command exits with a non-zero status.
source "${BASH_SOURCE%/*}/common.sh"
ensure_pwd
# if not on linux exit
if [[ "$(uname)" != "Linux" ]]; then
exit 0
fi
ROOT_FOLDER="$(pwd)"
STATE_FOLDER="${ROOT_FOLDER}/state"

114
scripts/watcher.sh Executable file
View file

@ -0,0 +1,114 @@
#!/usr/bin/env bash
source "${BASH_SOURCE%/*}/common.sh"
ROOT_FOLDER="${PWD}"
WATCH_FILE="${ROOT_FOLDER}/state/events"
function clean_events() {
echo "Cleaning events..."
echo "" >"$WATCH_FILE"
}
function set_status() {
local id=$1
local status=$2
write_log "Setting status for ${id} to ${status}"
# Update the status of the event
sed -i '' "s/${id} [a-z]*/${id} ${status}/g" "${WATCH_FILE}"
}
function run_command() {
local command_path="${1}"
local id=$2
shift 2
set_status "$id" "running"
$command_path "$@" >>"${ROOT_FOLDER}/logs/${id}.log" 2>&1
local result=$?
echo "Command ${command_path} exited with code ${result}"
if [[ $result -eq 0 ]]; then
set_status "$id" "success"
else
set_status "$id" "error"
fi
}
function select_command() {
# Example command:
# clone_repo id waiting "args"
local command=$(echo "$1" | cut -d ' ' -f 1)
local id=$(echo "$1" | cut -d ' ' -f 2)
local status=$(echo "$1" | cut -d ' ' -f 3)
local args=$(echo "$1" | cut -d ' ' -f 4-)
if [[ "$status" != "waiting" ]]; then
return 0
fi
write_log "Executing command ${command}"
if [ -z "$command" ]; then
return 0
fi
if [ "$command" = "clone_repo" ]; then
run_command "${ROOT_FOLDER}/scripts/git.sh" "$id" "clone" "$args"
return 0
fi
if [ "$command" = "update_repo" ]; then
run_command "${ROOT_FOLDER}/scripts/git.sh" "$id" "update" "$args"
return 0
fi
if [ "$command" = "app" ]; then
local arg1=$(echo "$args" | cut -d ' ' -f 1)
local arg2=$(echo "$args" | cut -d ' ' -f 2)
# Args example: start filebrowser
run_command "${ROOT_FOLDER}/scripts/app.sh" "$id" "$arg1" "$arg2"
return 0
fi
if [ "$command" = "system_info" ]; then
run_command "${ROOT_FOLDER}/scripts/system-info.sh" "$id"
return 0
fi
if [ "$command" = "update" ]; then
run_command "${ROOT_FOLDER}/scripts/system.sh" "$id" "update"
return 0
fi
if [ "$command" = "restart" ]; then
run_command "${ROOT_FOLDER}/scripts/system.sh" "$id" "restart"
return 0
fi
echo "Unknown command ${command}"
return 0
}
check_running
write_log "Listening for events in ${WATCH_FILE}..."
clean_events
# Listen in for changes in the WATCH_FILE
fswatch -0 "${WATCH_FILE}" | while read -d ""; do
# Read the command from the last line of the file
command=$(tail -n 1 "${WATCH_FILE}")
status=$(echo "$command" | cut -d ' ' -f 3)
if [ -z "$command" ] || [ "$status" != "waiting" ]; then
continue
else
select_command "$command"
fi
done