Merge pull request #219 from meienberger/refactor/scripts
Refactor/scripts - Event based actions
This commit is contained in:
commit
0aa930ebb1
38 changed files with 1024 additions and 576 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -1,6 +1,8 @@
|
|||
*.swo
|
||||
*.swp
|
||||
|
||||
.DS_Store
|
||||
|
||||
logs
|
||||
.pnpm-debug.log
|
||||
.env*
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -19,4 +19,7 @@ module.exports = {
|
|||
'no-unused-vars': [1, { argsIgnorePattern: '^_' }],
|
||||
'@typescript-eslint/no-unused-vars': [1, { argsIgnorePattern: '^_' }],
|
||||
},
|
||||
globals: {
|
||||
NodeJS: true,
|
||||
},
|
||||
};
|
||||
|
|
2
packages/system-api/.gitignore
vendored
2
packages/system-api/.gitignore
vendored
|
@ -1,6 +1,8 @@
|
|||
node_modules/
|
||||
dist/
|
||||
|
||||
.DS_Store
|
||||
|
||||
# testing
|
||||
coverage/
|
||||
logs/
|
||||
|
|
|
@ -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;
|
||||
|
|
221
packages/system-api/src/core/config/EventDispatcher.ts
Normal file
221
packages/system-api/src/core/config/EventDispatcher.ts
Normal 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;
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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, []);
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
};
|
|
@ -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 };
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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`);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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]);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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}`);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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');
|
||||
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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');
|
||||
|
||||
|
|
|
@ -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');
|
||||
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
9
scripts/start-dev.sh
Executable 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
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
114
scripts/watcher.sh
Executable 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
|
Loading…
Add table
Reference in a new issue