chore: cleanup system-api from now un-used files

This commit is contained in:
Nicolas Meienberger 2023-02-02 18:22:18 +01:00 committed by Nicolas Meienberger
parent 79f1da00d0
commit 36a6483ff7
40 changed files with 2 additions and 3535 deletions

View file

@ -1,26 +0,0 @@
import * as dotenv from 'dotenv';
import { DataSource } from 'typeorm';
import App from '../modules/apps/app.entity';
import User from '../modules/auth/user.entity';
import Update from '../modules/system/update.entity';
import { __prod__ } from './constants/constants';
if (process.env.NODE_ENV !== 'production') {
dotenv.config({ path: '.env.dev' });
} else {
dotenv.config({ path: '.env' });
}
const { POSTGRES_DBNAME = '', POSTGRES_HOST = '', POSTGRES_USERNAME = '', POSTGRES_PASSWORD = '' } = process.env;
export default new DataSource({
type: 'postgres',
host: POSTGRES_HOST,
database: POSTGRES_DBNAME,
username: POSTGRES_USERNAME,
password: POSTGRES_PASSWORD,
port: 5432,
logging: !__prod__,
synchronize: false,
entities: [App, User, Update],
migrations: [`${process.cwd()}/dist/config/migrations/*.js`],
});

View file

@ -2,7 +2,6 @@ import { z } from 'zod';
import * as dotenv from 'dotenv';
import fs from 'fs-extra';
import { readJsonFile } from '../../modules/fs/fs.helpers';
import { AppSupportedArchitecturesEnum } from '../../modules/apps/apps.types';
if (process.env.NODE_ENV !== 'production') {
dotenv.config({ path: '.env.dev' });
@ -23,14 +22,12 @@ const {
DOMAIN = '',
STORAGE_PATH = '/runtipi',
REDIS_HOST = 'tipi-redis',
ARCHITECTURE = 'amd64',
} = process.env;
const configSchema = z.object({
NODE_ENV: z.union([z.literal('development'), z.literal('production'), z.literal('test')]),
REDIS_HOST: z.string(),
status: z.union([z.literal('RUNNING'), z.literal('UPDATING'), z.literal('RESTARTING')]),
architecture: z.nativeEnum(AppSupportedArchitecturesEnum),
logs: z.object({
LOGS_FOLDER: z.string(),
LOGS_APP: z.string(),
@ -62,7 +59,6 @@ class Config {
},
REDIS_HOST,
NODE_ENV: NODE_ENV as z.infer<typeof configSchema>['NODE_ENV'],
architecture: ARCHITECTURE as z.infer<typeof configSchema>['architecture'],
rootFolder: '/runtipi',
internalIp: INTERNAL_IP,
version: TIPI_VERSION,

View file

@ -1,181 +0,0 @@
import { faker } from '@faker-js/faker';
import fs from 'fs-extra';
import { DataSource } from 'typeorm';
import logger from '../../../config/logger/logger';
import App from '../../../modules/apps/app.entity';
import { AppInfo, AppStatusEnum } from '../../../modules/apps/apps.types';
import { createApp } from '../../../modules/apps/__tests__/apps.factory';
import User from '../../../modules/auth/user.entity';
import Update, { UpdateStatusEnum } from '../../../modules/system/update.entity';
import { setupConnection, teardownConnection } from '../../../test/connection';
import { getConfig } from '../../config/TipiConfig';
import { updateV040 } from '../v040';
jest.mock('fs');
let db: DataSource | null = null;
const TEST_SUITE = 'updatev040';
beforeAll(async () => {
db = await setupConnection(TEST_SUITE);
});
beforeEach(async () => {
jest.resetModules();
jest.resetAllMocks();
await App.clear();
await Update.clear();
});
afterAll(async () => {
await db?.destroy();
await teardownConnection(TEST_SUITE);
});
const createAppState = (apps: string[]) => JSON.stringify({ installed: apps.join(' ') });
const createUserState = (users: { email: string; password: string }[]) => JSON.stringify(users);
describe('No state/apps.json', () => {
it('Should do nothing and create the update with status SUCCES', async () => {
await updateV040();
const update = await Update.findOne({ where: { name: 'v040' } });
expect(update).toBeDefined();
expect(update?.status).toBe(UpdateStatusEnum.SUCCESS);
const apps = await App.find();
expect(apps).toHaveLength(0);
});
it('Should not run the update if already done', async () => {
const spy = jest.spyOn(logger, 'info');
await updateV040();
await updateV040();
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith('Update v040 already applied');
});
});
describe('State/apps.json exists with no installed app', () => {
beforeEach(async () => {
const { MockFiles } = await createApp({});
MockFiles[`${getConfig().rootFolder}/state/apps.json`] = createAppState([]);
// @ts-ignore
fs.__createMockFiles(MockFiles);
});
it('Should do nothing and create the update with status SUCCES', async () => {
await updateV040();
const update = await Update.findOne({ where: { name: 'v040' } });
expect(update).toBeDefined();
expect(update?.status).toBe(UpdateStatusEnum.SUCCESS);
const apps = await App.find();
expect(apps).toHaveLength(0);
});
it('Should delete state file after update', async () => {
await updateV040();
expect(fs.existsSync('/runtipi/state/apps.json')).toBe(false);
});
});
describe('State/apps.json exists with one installed app', () => {
let app1: AppInfo | null = null;
beforeEach(async () => {
const { MockFiles, appInfo } = await createApp({});
app1 = appInfo;
MockFiles['/runtipi/state/apps.json'] = createAppState([appInfo.id]);
MockFiles[`/app/storage/app-data/${appInfo.id}`] = '';
MockFiles[`/app/storage/app-data/${appInfo.id}/app.env`] = 'TEST=test\nAPP_PORT=3000\nTEST_FIELD=test';
// @ts-ignore
fs.__createMockFiles(MockFiles);
});
it('Should create a new app and update', async () => {
await updateV040();
const app = await App.findOne({ where: { id: app1?.id } });
const update = await Update.findOne({ where: { name: 'v040' } });
expect(app).toBeDefined();
expect(app?.status).toBe(AppStatusEnum.STOPPED);
expect(update).toBeDefined();
expect(update?.status).toBe('SUCCESS');
});
it("Should correctly pick up app's variables from existing .env file", async () => {
await updateV040();
const app = await App.findOne({ where: { id: app1?.id } });
expect(app?.config).toStrictEqual({ TEST_FIELD: 'test' });
});
it('Should not try to migrate app if it already exists', async () => {
const { MockFiles, appInfo } = await createApp({ installed: true });
app1 = appInfo;
MockFiles['/runtipi/state/apps.json'] = createAppState([appInfo.id]);
MockFiles[`/app/storage/app-data/${appInfo.id}`] = '';
MockFiles[`/app/storage/app-data/${appInfo.id}/app.env`] = 'TEST=test\nAPP_PORT=3000\nTEST_FIELD=test';
// @ts-ignore
fs.__createMockFiles(MockFiles);
await updateV040();
const spy = jest.spyOn(logger, 'info');
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith('App already migrated');
});
});
describe('State/users.json exists with no user', () => {
beforeEach(async () => {
const { MockFiles } = await createApp({});
MockFiles[`${getConfig().rootFolder}/state/users.json`] = createUserState([]);
// @ts-ignore
fs.__createMockFiles(MockFiles);
});
it('Should do nothing and create the update with status SUCCES', async () => {
await updateV040();
const update = await Update.findOne({ where: { name: 'v040' } });
expect(update).toBeDefined();
expect(update?.status).toBe(UpdateStatusEnum.SUCCESS);
const apps = await App.find();
expect(apps).toHaveLength(0);
});
it('Should delete state file after update', async () => {
await updateV040();
expect(fs.existsSync('/runtipi/state/apps.json')).toBe(false);
});
});
describe('State/users.json exists with one user', () => {
const email = faker.internet.email();
beforeEach(async () => {
const MockFiles: Record<string, string> = {};
MockFiles[`/runtipi/state/users.json`] = createUserState([{ email, password: faker.internet.password() }]);
// @ts-ignore
fs.__createMockFiles(MockFiles);
});
it('Should create a new user and update', async () => {
await updateV040();
const user = await User.findOne({ where: { username: email } });
const update = await Update.findOne({ where: { name: 'v040' } });
expect(user).toBeDefined();
expect(update).toBeDefined();
expect(update?.status).toBe('SUCCESS');
});
});

View file

@ -1,48 +0,0 @@
import { BaseEntity, DataSource, DeepPartial } from 'typeorm';
import logger from '../../config/logger/logger';
import App from '../../modules/apps/app.entity';
import User from '../../modules/auth/user.entity';
import Update from '../../modules/system/update.entity';
const createUser = async (user: DeepPartial<BaseEntity>): Promise<void> => {
await User.create(user).save();
};
const createApp = async (app: DeepPartial<BaseEntity>): Promise<void> => {
await App.create(app).save();
};
const createUpdate = async (update: DeepPartial<BaseEntity>): Promise<void> => {
await Update.create(update).save();
};
const recover = async (datasource: DataSource) => {
logger.info('Recovering broken database');
const queryRunner = datasource.createQueryRunner();
const apps = await queryRunner.query('SELECT * FROM app');
const users = await queryRunner.query('SELECT * FROM "user"');
const updates = await queryRunner.query('SELECT * FROM update');
// drop database
await datasource.dropDatabase();
logger.info('running migrations');
await datasource.runMigrations();
// recreate users
await Promise.all(users.map(createUser));
// create apps
await Promise.all(apps.map(createApp));
// create updates
await Promise.all(updates.map(createUpdate));
logger.info(`Users recovered ${users.length}`);
logger.info(`Apps recovered ${apps.length}`);
logger.info(`Updates recovered ${updates.length}`);
logger.info('Database fully recovered');
};
export default recover;

View file

@ -1,6 +0,0 @@
import { updateV040 } from './v040';
export const runUpdates = async (): Promise<void> => {
// v040: Update to 0.4.0
await updateV040();
};

View file

@ -1,92 +0,0 @@
import { z } from 'zod';
import logger from '../../config/logger/logger';
import App from '../../modules/apps/app.entity';
import { appInfoSchema } from '../../modules/apps/apps.helpers';
import { AppStatusEnum } from '../../modules/apps/apps.types';
import User from '../../modules/auth/user.entity';
import { deleteFolder, fileExists, readFile, readJsonFile } from '../../modules/fs/fs.helpers';
import Update, { UpdateStatusEnum } from '../../modules/system/update.entity';
import { getConfig } from '../config/TipiConfig';
const appStateSchema = z.object({ installed: z.string().optional().default('') });
const userStateSchema = z.object({ email: z.string(), password: z.string() }).array();
const UPDATE_NAME = 'v040';
const migrateApp = async (appId: string): Promise<void> => {
const app = await App.findOne({ where: { id: appId } });
if (!app) {
const envFile = readFile(`/app/storage/app-data/${appId}/app.env`).toString();
const envVars = envFile.split('\n');
const envVarsMap = new Map<string, string>();
envVars.forEach((envVar) => {
const [key, value] = envVar.split('=');
envVarsMap.set(key, value);
});
const form: Record<string, string> = {};
const configFile = readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${appId}/config.json`);
const parsedConfig = appInfoSchema.safeParse(configFile);
if (parsedConfig.success) {
parsedConfig.data.form_fields.forEach((field) => {
const envVar = field.env_variable;
const envVarValue = envVarsMap.get(envVar);
if (envVarValue) {
form[field.env_variable] = envVarValue;
}
});
await App.create({ id: appId, status: AppStatusEnum.STOPPED, config: form }).save();
}
} else {
logger.info('App already migrated');
}
};
const migrateUser = async (user: { email: string; password: string }): Promise<void> => {
await User.create({ username: user.email.trim().toLowerCase(), password: user.password }).save();
};
export const updateV040 = async (): Promise<void> => {
try {
const update = await Update.findOne({ where: { name: UPDATE_NAME } });
if (update) {
logger.info(`Update ${UPDATE_NAME} already applied`);
return;
}
// Migrate apps
if (fileExists('/runtipi/state/apps.json')) {
const state = readJsonFile('/runtipi/state/apps.json');
const parsedState = appStateSchema.safeParse(state);
if (parsedState.success) {
const installed: string[] = parsedState.data.installed.split(' ').filter(Boolean);
await Promise.all(installed.map((appId) => migrateApp(appId)));
deleteFolder('/runtipi/state/apps.json');
}
}
// Migrate users
if (fileExists('/runtipi/state/users.json')) {
const state = readJsonFile('/runtipi/state/users.json');
const parsedState = userStateSchema.safeParse(state);
if (parsedState.success) {
await Promise.all(parsedState.data.map((user) => migrateUser(user)));
deleteFolder('/runtipi/state/users.json');
}
}
await Update.create({ name: UPDATE_NAME, status: UpdateStatusEnum.SUCCESS }).save();
} catch (error) {
logger.error(error);
await Update.create({ name: UPDATE_NAME, status: UpdateStatusEnum.FAILED }).save();
}
};

View file

@ -1,98 +0,0 @@
import { faker } from '@faker-js/faker';
import { AppCategoriesEnum, AppInfo, AppStatusEnum, AppSupportedArchitecturesEnum, FieldTypes } from '../apps.types';
import App from '../app.entity';
import { appInfoSchema } from '../apps.helpers';
interface IProps {
installed?: boolean;
status?: AppStatusEnum;
requiredPort?: number;
randomField?: boolean;
exposed?: boolean;
domain?: string;
exposable?: boolean;
supportedArchitectures?: AppSupportedArchitecturesEnum[];
}
type CreateConfigParams = {
id?: string;
};
const createAppConfig = (props?: CreateConfigParams): AppInfo =>
appInfoSchema.parse({
id: props?.id || faker.random.alphaNumeric(32),
available: true,
port: faker.datatype.number({ min: 30, max: 65535 }),
name: faker.random.alphaNumeric(32),
description: faker.random.alphaNumeric(32),
tipi_version: 1,
short_desc: faker.random.alphaNumeric(32),
author: faker.random.alphaNumeric(32),
source: faker.internet.url(),
categories: [AppCategoriesEnum.AUTOMATION],
});
const createApp = async (props: IProps) => {
const { installed = false, status = AppStatusEnum.RUNNING, randomField = false, exposed = false, domain = '', exposable = false, supportedArchitectures } = props;
const categories = Object.values(AppCategoriesEnum);
const appInfo: AppInfo = {
id: faker.random.word().toLowerCase().trim(),
port: faker.datatype.number({ min: 3000, max: 5000 }),
available: true,
form_fields: [
{
type: FieldTypes.text,
label: faker.random.word(),
required: true,
env_variable: 'TEST_FIELD',
},
],
name: faker.random.word(),
description: faker.random.words(),
tipi_version: faker.datatype.number({ min: 1, max: 10 }),
short_desc: faker.random.words(),
author: faker.name.firstName(),
source: faker.internet.url(),
categories: [categories[faker.datatype.number({ min: 0, max: categories.length - 1 })]],
exposable,
supported_architectures: supportedArchitectures,
};
if (randomField) {
appInfo.form_fields?.push({
type: FieldTypes.random,
label: faker.random.word(),
env_variable: 'RANDOM_FIELD',
});
}
const MockFiles: Record<string, string | string[]> = {};
MockFiles['/runtipi/.env'] = 'TEST=test';
MockFiles['/runtipi/repos/repo-id'] = '';
MockFiles[`/runtipi/repos/repo-id/apps/${appInfo.id}/config.json`] = JSON.stringify(appInfoSchema.parse(appInfo));
MockFiles[`/runtipi/repos/repo-id/apps/${appInfo.id}/docker-compose.yml`] = 'compose';
MockFiles[`/runtipi/repos/repo-id/apps/${appInfo.id}/metadata/description.md`] = 'md desc';
let appEntity = new App();
if (installed) {
appEntity = await App.create({
id: appInfo.id,
config: { TEST_FIELD: 'test' },
status,
exposed,
domain,
version: 1,
}).save();
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[`/runtipi/apps/${appInfo.id}/config.json`] = JSON.stringify(appInfo);
MockFiles[`/runtipi/apps/${appInfo.id}/metadata/description.md`] = 'md desc';
}
return { appInfo, MockFiles, appEntity };
};
export { createApp, createAppConfig };

View file

@ -1,513 +0,0 @@
import { faker } from '@faker-js/faker';
import fs from 'fs-extra';
import { DataSource } from 'typeorm';
import logger from '../../../config/logger/logger';
import { setConfig } from '../../../core/config/TipiConfig';
import { setupConnection, teardownConnection } from '../../../test/connection';
import App from '../app.entity';
import { checkAppRequirements, checkEnvFile, ensureAppFolder, generateEnvFile, getAppInfo, getAvailableApps, getEnvMap, getUpdateInfo } from '../apps.helpers';
import { AppInfo, AppSupportedArchitecturesEnum } from '../apps.types';
import { createApp, createAppConfig } from './apps.factory';
jest.mock('fs-extra');
jest.mock('child_process');
let db: DataSource | null = null;
const TEST_SUITE = 'appshelperslegacy';
beforeAll(async () => {
db = await setupConnection(TEST_SUITE);
});
afterAll(async () => {
await db?.destroy();
await teardownConnection(TEST_SUITE);
});
beforeEach(async () => {
jest.resetModules();
jest.resetAllMocks();
jest.restoreAllMocks();
await App.clear();
});
describe('checkAppRequirements', () => {
let app1: AppInfo;
beforeEach(async () => {
const app1create = await createApp({});
app1 = app1create.appInfo;
// @ts-ignore
fs.__createMockFiles(app1create.MockFiles);
});
it('should return appInfo if there are no particular requirement', async () => {
const result = checkAppRequirements(app1.id);
expect(result.id).toEqual(app1.id);
});
it('Should throw an error if app does not exist', async () => {
try {
checkAppRequirements('notexisting');
expect(true).toBe(false);
} catch (e) {
// @ts-ignore
expect(e.message).toEqual('App notexisting has invalid config.json file');
}
});
it('Should throw if architecture is not supported', async () => {
setConfig('architecture', AppSupportedArchitecturesEnum.ARM64);
const { MockFiles, appInfo } = await createApp({ supportedArchitectures: [AppSupportedArchitecturesEnum.ARM] });
// @ts-ignore
fs.__createMockFiles(MockFiles);
try {
checkAppRequirements(appInfo.id);
expect(true).toBe(false);
} catch (e) {
// @ts-ignore
expect(e.message).toEqual(`App ${appInfo.id} is not supported on this architecture`);
}
});
});
describe('getEnvMap', () => {
let app1: AppInfo;
beforeEach(async () => {
const app1create = await createApp({ installed: true });
app1 = app1create.appInfo;
// @ts-ignore
fs.__createMockFiles(app1create.MockFiles);
});
it('should return a map of env vars', async () => {
const envMap = await getEnvMap(app1.id);
expect(envMap.get('TEST_FIELD')).toBe('test');
});
});
describe('Test: checkEnvFile', () => {
let app1: AppInfo;
beforeEach(async () => {
const app1create = await createApp({ installed: true });
app1 = app1create.appInfo;
// @ts-ignore
fs.__createMockFiles(app1create.MockFiles);
});
it('Should not throw if all required fields are present', async () => {
await checkEnvFile(app1.id);
});
it('Should throw if a required field is missing', () => {
const newAppEnv = 'APP_PORT=test\n';
fs.writeFileSync(`/app/storage/app-data/${app1.id}/app.env`, newAppEnv);
try {
checkEnvFile(app1.id);
expect(true).toBe(false);
} catch (e: unknown) {
if (e instanceof Error) {
expect(e).toBeDefined();
expect(e.message).toBe('New info needed. App config needs to be updated');
} else {
fail('Should throw an error');
}
}
});
it('Should throw if config.json is incorrect', async () => {
// arrange
fs.writeFileSync(`/app/storage/app-data/${app1.id}/config.json`, 'invalid json');
const { appInfo } = await createApp({});
// act
try {
await checkEnvFile(appInfo.id);
expect(true).toBe(false);
} catch (e: unknown) {
if (e instanceof Error) {
expect(e).toBeDefined();
expect(e.message).toBe(`App ${appInfo.id} has invalid config.json file`);
} else {
fail('Should throw an error');
}
}
});
});
describe('Test: generateEnvFile', () => {
let app1: AppInfo;
let appEntity1: App;
beforeEach(async () => {
const app1create = await createApp({ installed: true });
app1 = app1create.appInfo;
appEntity1 = app1create.appEntity;
// @ts-ignore
fs.__createMockFiles(app1create.MockFiles);
});
it('Should generate an env file', async () => {
const fakevalue = faker.random.alphaNumeric(10);
generateEnvFile(Object.assign(appEntity1, { config: { TEST_FIELD: fakevalue } }));
const envmap = await getEnvMap(app1.id);
expect(envmap.get('TEST_FIELD')).toBe(fakevalue);
});
it('Should automatically generate value for random field', async () => {
const { appEntity, appInfo, MockFiles } = await createApp({ installed: true, randomField: true });
// @ts-ignore
fs.__createMockFiles(MockFiles);
generateEnvFile(appEntity);
const envmap = await getEnvMap(appInfo.id);
expect(envmap.get('RANDOM_FIELD')).toBeDefined();
expect(envmap.get('RANDOM_FIELD')).toHaveLength(32);
});
it('Should not re-generate random field if it already exists', async () => {
const { appEntity, appInfo, MockFiles } = await createApp({ installed: true, randomField: true });
// @ts-ignore
fs.__createMockFiles(MockFiles);
const randomField = faker.random.alphaNumeric(32);
fs.writeFileSync(`/app/storage/app-data/${appInfo.id}/app.env`, `RANDOM_FIELD=${randomField}`);
generateEnvFile(appEntity);
const envmap = await getEnvMap(appInfo.id);
expect(envmap.get('RANDOM_FIELD')).toBe(randomField);
});
it('Should throw an error if required field is not provided', async () => {
try {
generateEnvFile(Object.assign(appEntity1, { config: { TEST_FIELD: undefined } }));
expect(true).toBe(false);
} catch (e: unknown) {
if (e instanceof Error) {
expect(e).toBeDefined();
expect(e.message).toBe('Variable TEST_FIELD is required');
} else {
fail('Should throw an error');
}
}
});
it('Should throw an error if app does not exist', async () => {
try {
generateEnvFile(Object.assign(appEntity1, { id: 'not-existing-app' }));
expect(true).toBe(false);
} catch (e: unknown) {
if (e instanceof Error) {
expect(e).toBeDefined();
expect(e.message).toBe('App not-existing-app has invalid config.json file');
} else {
fail('Should throw an error');
}
}
});
it('Should add APP_EXPOSED to env file', async () => {
const domain = faker.internet.domainName();
const { appEntity, appInfo, MockFiles } = await createApp({ installed: true, exposed: true, domain });
// @ts-ignore
fs.__createMockFiles(MockFiles);
generateEnvFile(appEntity);
const envmap = await getEnvMap(appInfo.id);
expect(envmap.get('APP_EXPOSED')).toBe('true');
expect(envmap.get('APP_DOMAIN')).toBe(domain);
});
it('Should not add APP_EXPOSED if domain is not provided', async () => {
const { appEntity, appInfo, MockFiles } = await createApp({ installed: true, exposed: true });
// @ts-ignore
fs.__createMockFiles(MockFiles);
generateEnvFile(appEntity);
const envmap = await getEnvMap(appInfo.id);
expect(envmap.get('APP_EXPOSED')).toBeUndefined();
});
it('Should not add APP_EXPOSED if app is not exposed', async () => {
const { appEntity, appInfo, MockFiles } = await createApp({ installed: true, domain: faker.internet.domainName() });
// @ts-ignore
fs.__createMockFiles(MockFiles);
generateEnvFile(appEntity);
const envmap = await getEnvMap(appInfo.id);
expect(envmap.get('APP_EXPOSED')).toBeUndefined();
expect(envmap.get('APP_DOMAIN')).toBe(`localhost:${appInfo.port}`);
});
it('Should create app folder if it does not exist', async () => {
const { appEntity, appInfo, MockFiles } = await createApp({ installed: true });
// @ts-ignore
fs.__createMockFiles(MockFiles);
fs.rmSync(`/app/storage/app-data/${appInfo.id}`, { recursive: true });
generateEnvFile(appEntity);
expect(fs.existsSync(`/app/storage/app-data/${appInfo.id}`)).toBe(true);
});
});
describe('getAvailableApps', () => {
beforeEach(async () => {
const app1create = await createApp({ installed: true });
const app2create = await createApp({});
// @ts-ignore
fs.__createMockFiles(Object.assign(app1create.MockFiles, app2create.MockFiles));
});
it('Should return all available apps', async () => {
const availableApps = await getAvailableApps();
expect(availableApps.length).toBe(2);
});
it('Should not return apps with invalid config.json', async () => {
const { appInfo: app1, MockFiles: MockFiles1 } = await createApp({ installed: true });
const { MockFiles: MockFiles2 } = await createApp({});
MockFiles1[`/runtipi/repos/repo-id/apps/${app1.id}/config.json`] = 'invalid json';
// @ts-ignore
fs.__createMockFiles(Object.assign(MockFiles1, MockFiles2));
const availableApps = await getAvailableApps();
expect(availableApps.length).toBe(1);
});
});
describe('Test: getAppInfo', () => {
let app1: AppInfo;
beforeEach(async () => {
const app1create = await createApp({ installed: false });
app1 = app1create.appInfo;
// @ts-ignore
fs.__createMockFiles(app1create.MockFiles);
});
it('Should return app info', async () => {
const appInfo = await getAppInfo(app1.id);
expect(appInfo?.id).toBe(app1.id);
});
it('Should take config.json locally if app is installed', async () => {
const { appInfo, MockFiles, appEntity } = await createApp({ installed: true });
// @ts-ignore
fs.__createMockFiles(MockFiles);
const newConfig = createAppConfig();
fs.writeFileSync(`/runtipi/apps/${appInfo.id}/config.json`, JSON.stringify(newConfig));
const app = await getAppInfo(appInfo.id, appEntity.status);
expect(app?.id).toEqual(newConfig.id);
});
it('Should take config.json from repo if app is not installed', async () => {
const { appInfo, MockFiles, appEntity } = await createApp({ installed: false });
// @ts-ignore
fs.__createMockFiles(MockFiles);
const newConfig = createAppConfig();
fs.writeFileSync(`/runtipi/repos/repo-id/apps/${appInfo.id}/config.json`, JSON.stringify(newConfig));
const app = await getAppInfo(appInfo.id, appEntity.status);
expect(app?.id).toEqual(newConfig.id);
});
it('Should return null if app is not available', async () => {
const { appInfo, MockFiles, appEntity } = await createApp({ installed: false });
// @ts-ignore
fs.__createMockFiles(MockFiles);
const newConfig = {
id: faker.random.alphaNumeric(32),
available: false,
};
fs.writeFileSync(`/runtipi/repos/repo-id/apps/${appInfo.id}/config.json`, JSON.stringify(newConfig));
const app = await getAppInfo(appInfo.id, appEntity.status);
expect(app).toBeNull();
});
it('Should throw if something goes wrong', async () => {
const log = jest.spyOn(logger, 'error');
const spy = jest.spyOn(fs, 'existsSync').mockImplementation(() => {
throw new Error('Something went wrong');
});
const { appInfo, MockFiles, appEntity } = await createApp({ installed: false });
// @ts-ignore
fs.__createMockFiles(MockFiles);
const newConfig = {
id: faker.random.alphaNumeric(32),
available: false,
};
fs.writeFileSync(`/runtipi/repos/repo-id/apps/${appInfo.id}/config.json`, JSON.stringify(newConfig));
try {
await getAppInfo(appInfo.id, appEntity.status);
expect(true).toBe(false);
} catch (e: unknown) {
if (e instanceof Error) {
expect(e.message).toBe(`Error loading app: ${appInfo.id}`);
expect(log).toBeCalledWith(`Error loading app: ${appInfo.id}`);
} else {
expect(true).toBe(false);
}
}
spy.mockRestore();
log.mockRestore();
});
it('Should return null if app does not exist', async () => {
const app = await getAppInfo(faker.random.word());
expect(app).toBeNull();
});
});
describe('getUpdateInfo', () => {
let app1: AppInfo;
beforeEach(async () => {
const app1create = await createApp({ installed: true });
app1 = app1create.appInfo;
// @ts-ignore
fs.__createMockFiles(app1create.MockFiles);
});
it('Should return update info', async () => {
const updateInfo = await getUpdateInfo(app1.id, 1);
expect(updateInfo?.latest).toBe(app1.tipi_version);
expect(updateInfo?.current).toBe(1);
});
it('Should return null if app is not installed', async () => {
const updateInfo = await getUpdateInfo(faker.random.word(), 1);
expect(updateInfo).toBeNull();
});
it('Should return null if config.json is invalid', async () => {
const { appInfo, MockFiles } = await createApp({ installed: true });
MockFiles[`/runtipi/repos/repo-id/apps/${appInfo.id}/config.json`] = 'invalid json';
// @ts-ignore
fs.__createMockFiles(MockFiles);
const updateInfo = await getUpdateInfo(appInfo.id, 1);
expect(updateInfo).toBeNull();
});
it('should return null if version is not provided', async () => {
// @ts-ignore
const updateInfo = await getUpdateInfo(app1.id);
expect(updateInfo).toBe(null);
});
});
describe('Test: ensureAppFolder', () => {
beforeEach(() => {
const mockFiles = {
[`/runtipi/repos/repo-id/apps/test`]: ['test.yml'],
};
// @ts-ignore
fs.__createMockFiles(mockFiles);
});
it('should copy the folder from repo', () => {
// Act
ensureAppFolder('test');
// Assert
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/repo-id/apps/test`]: ['test.yml'],
'/runtipi/apps/test': ['docker-compose.yml'],
'/runtipi/apps/test/docker-compose.yml': 'test',
};
// @ts-ignore
fs.__createMockFiles(mockFiles);
// Act
ensureAppFolder('test');
// Assert
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/repo-id/apps/test`]: ['test.yml'],
'/runtipi/apps/test': ['docker-compose.yml'],
'/runtipi/apps/test/docker-compose.yml': 'test',
};
// @ts-ignore
fs.__createMockFiles(mockFiles);
// Act
ensureAppFolder('test', true);
// Assert
const files = fs.readdirSync('/runtipi/apps/test');
expect(files).toEqual(['test.yml']);
});
it('Should delete folder if it exists but has no docker-compose.yml file', () => {
// Arrange
const randomFileName = `${faker.random.word()}.yml`;
const mockFiles = {
[`/runtipi/repos/repo-id/apps/test`]: [randomFileName],
'/runtipi/apps/test': ['test.yml'],
};
// @ts-ignore
fs.__createMockFiles(mockFiles);
// Act
ensureAppFolder('test');
// Assert
const files = fs.readdirSync('/runtipi/apps/test');
expect(files).toEqual([randomFileName]);
});
});

View file

@ -1,513 +0,0 @@
import { DataSource } from 'typeorm';
import fs from 'fs-extra';
import { faker } from '@faker-js/faker';
import { setupConnection, teardownConnection } from '../../../test/connection';
import { gcall } from '../../../test/gcall';
import App from '../app.entity';
import { getAppQuery, InstalledAppsQuery, listAppInfosQuery } from '../../../test/queries';
import { createApp } from './apps.factory';
import { AppInfo, AppStatusEnum, ListAppsResonse } from '../apps.types';
import { createUser } from '../../auth/__tests__/user.factory';
import User from '../../auth/user.entity';
import { installAppMutation, startAppMutation, stopAppMutation, uninstallAppMutation, updateAppConfigMutation, updateAppMutation } from '../../../test/mutations';
import EventDispatcher from '../../../core/config/EventDispatcher';
jest.mock('fs');
jest.mock('child_process');
type TApp = App & {
info: AppInfo;
};
let db: DataSource | null = null;
const TEST_SUITE = 'appsresolver';
beforeAll(async () => {
db = await setupConnection(TEST_SUITE);
});
afterAll(async () => {
await db?.destroy();
await teardownConnection(TEST_SUITE);
});
beforeEach(async () => {
jest.resetModules();
jest.resetAllMocks();
jest.restoreAllMocks();
EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValue({ success: true });
await App.clear();
await User.clear();
});
describe('ListAppsInfos', () => {
let app1: AppInfo;
beforeEach(async () => {
const { MockFiles, appInfo } = await createApp({});
app1 = appInfo;
// @ts-ignore
fs.__createMockFiles(MockFiles);
});
it('Can list apps', async () => {
const { data } = await gcall<{ listAppsInfo: ListAppsResonse }>({ source: listAppInfosQuery });
expect(data?.listAppsInfo.apps.length).toBe(1);
expect(data?.listAppsInfo.total).toBe(1);
const app = data?.listAppsInfo.apps[0];
expect(app?.id).toBe(app1.id);
expect(app?.author).toBe(app1.author);
expect(app?.name).toBe(app1.name);
expect(app?.available).toBe(app1.available);
});
});
describe('GetApp', () => {
let app1: AppInfo;
let app2: AppInfo;
beforeEach(async () => {
const app1create = await createApp({});
const app2create = await createApp({ installed: true });
app1 = app1create.appInfo;
app2 = app2create.appInfo;
// @ts-ignore
fs.__createMockFiles(Object.assign(app1create.MockFiles, app2create.MockFiles));
});
it('Can get app', async () => {
const { data } = await gcall<{ getApp: TApp }>({
source: getAppQuery,
variableValues: { id: app1.id },
});
expect(data?.getApp.info.id).toBe(app1.id);
expect(data?.getApp.status).toBe(AppStatusEnum.MISSING.toUpperCase());
const { data: data2 } = await gcall<{ getApp: TApp }>({
source: getAppQuery,
variableValues: { id: app2.id },
});
expect(data2?.getApp.info.id).toBe(app2.id);
});
it("Should return null info if app doesn't exist", async () => {
const { data } = await gcall<{ getApp: TApp }>({
source: getAppQuery,
variableValues: { id: 'not-existing' },
});
expect(data?.getApp.info).toBeNull();
expect(data?.getApp.status).toBe(AppStatusEnum.MISSING.toUpperCase());
});
});
describe('InstalledApps', () => {
let app1: AppInfo;
beforeEach(async () => {
const app1create = await createApp({ installed: true });
app1 = app1create.appInfo;
// @ts-ignore
fs.__createMockFiles(app1create.MockFiles);
});
it('Can list installed apps', async () => {
const user = await createUser();
const { data } = await gcall<{ installedApps: TApp[] }>({ source: InstalledAppsQuery, userId: user.id });
expect(data?.installedApps.length).toBe(1);
const app = data?.installedApps[0];
expect(app?.id).toBe(app1.id);
expect(app?.info.author).toBe(app1.author);
expect(app?.info.name).toBe(app1.name);
});
it("Should return an error if user doesn't exist", async () => {
const { data, errors } = await gcall<{ installedApps: TApp[] }>({
source: InstalledAppsQuery,
userId: 1,
});
expect(errors?.[0].message).toBe('Access denied! You need to be authorized to perform this action!');
expect(data?.installedApps).toBeUndefined();
});
it('Should throw an error if no userId is provided', async () => {
const { data, errors } = await gcall<{ installedApps: TApp[] }>({
source: InstalledAppsQuery,
});
expect(errors?.[0].message).toBe('Access denied! You need to be authorized to perform this action!');
expect(data?.installedApps).toBeUndefined();
});
});
describe('InstallApp', () => {
let app1: AppInfo;
beforeEach(async () => {
const app1create = await createApp({});
app1 = app1create.appInfo;
// @ts-ignore
fs.__createMockFiles(app1create.MockFiles);
});
it('Can install app', async () => {
const user = await createUser();
const { data } = await gcall<{ installApp: TApp }>({
source: installAppMutation,
userId: user.id,
variableValues: { input: { id: app1.id, form: { TEST_FIELD: 'hello' }, exposed: false, domain: '' } },
});
expect(data?.installApp.info.id).toBe(app1.id);
expect(data?.installApp.status).toBe(AppStatusEnum.RUNNING.toUpperCase());
});
it("Should return an error if app doesn't exist", async () => {
const user = await createUser();
const { data, errors } = await gcall<{ installApp: TApp }>({
source: installAppMutation,
userId: user.id,
variableValues: { input: { id: 'not-existing', form: { TEST_FIELD: 'hello' }, exposed: false, domain: '' } },
});
expect(errors?.[0].message).toBe('App not-existing has invalid config.json file');
expect(data?.installApp).toBeUndefined();
});
it("Should throw an error if user doesn't exist", async () => {
const { data, errors } = await gcall<{ installApp: TApp }>({
source: installAppMutation,
variableValues: { input: { id: app1.id, form: { TEST_FIELD: 'hello' }, exposed: false, domain: '' } },
});
expect(errors?.[0].message).toBe('Access denied! You need to be authorized to perform this action!');
expect(data?.installApp).toBeUndefined();
});
it('Should throw an error if no userId is provided', async () => {
const { data, errors } = await gcall<{ installApp: TApp }>({
source: installAppMutation,
variableValues: { input: { id: app1.id, form: { TEST_FIELD: 'hello' }, exposed: false, domain: '' } },
});
expect(errors?.[0].message).toBe('Access denied! You need to be authorized to perform this action!');
expect(data?.installApp).toBeUndefined();
});
it('Should throw an error if a required field is missing in form', async () => {
const user = await createUser();
const { data, errors } = await gcall<{ installApp: TApp }>({
source: installAppMutation,
userId: user.id,
variableValues: { input: { id: app1.id, form: {}, exposed: false, domain: '' } },
});
expect(errors?.[0].message).toBe(`Variable ${app1.form_fields?.[0].env_variable} is required`);
expect(data?.installApp).toBeUndefined();
});
});
describe('StartApp', () => {
let app1: AppInfo;
beforeEach(async () => {
const app1create = await createApp({ status: AppStatusEnum.STOPPED, installed: true });
app1 = app1create.appInfo;
// @ts-ignore
fs.__createMockFiles(app1create.MockFiles);
});
it('Can start app', async () => {
const user = await createUser();
const { data } = await gcall<{ startApp: TApp }>({
source: startAppMutation,
userId: user.id,
variableValues: { id: app1.id },
});
expect(data?.startApp.info.id).toBe(app1.id);
expect(data?.startApp.status).toBe(AppStatusEnum.RUNNING.toUpperCase());
});
it("Should return an error if app doesn't exist", async () => {
const user = await createUser();
const { data, errors } = await gcall<{ startApp: TApp }>({
source: startAppMutation,
userId: user.id,
variableValues: { id: 'not-existing' },
});
expect(errors?.[0].message).toBe('App not-existing not found');
expect(data?.startApp).toBeUndefined();
});
it("Should throw an error if user doesn't exist", async () => {
const { data, errors } = await gcall<{ startApp: TApp }>({
source: startAppMutation,
userId: 0,
variableValues: { id: app1.id },
});
expect(errors?.[0].message).toBe('Access denied! You need to be authorized to perform this action!');
expect(data?.startApp).toBeUndefined();
});
it('Should throw an error if no userId is provided', async () => {
const { data, errors } = await gcall<{ startApp: TApp }>({
source: startAppMutation,
variableValues: { id: app1.id },
});
expect(errors?.[0].message).toBe('Access denied! You need to be authorized to perform this action!');
expect(data?.startApp).toBeUndefined();
});
});
describe('StopApp', () => {
let app1: AppInfo;
beforeEach(async () => {
const app1create = await createApp({ status: AppStatusEnum.RUNNING, installed: true });
app1 = app1create.appInfo;
// @ts-ignore
fs.__createMockFiles(app1create.MockFiles);
});
it('Can stop app', async () => {
const user = await createUser();
const { data } = await gcall<{ stopApp: TApp }>({
source: stopAppMutation,
userId: user.id,
variableValues: { id: app1.id },
});
expect(data?.stopApp.info.id).toBe(app1.id);
expect(data?.stopApp.status).toBe(AppStatusEnum.STOPPED.toUpperCase());
});
it("Should return an error if app doesn't exist", async () => {
const user = await createUser();
const { data, errors } = await gcall<{ stopApp: TApp }>({
source: stopAppMutation,
userId: user.id,
variableValues: { id: 'not-existing' },
});
expect(errors?.[0].message).toBe('App not-existing not found');
expect(data?.stopApp).toBeUndefined();
});
it("Should throw an error if user doesn't exist", async () => {
const { data, errors } = await gcall<{ stopApp: TApp }>({
source: stopAppMutation,
userId: 0,
variableValues: { id: app1.id },
});
expect(errors?.[0].message).toBe('Access denied! You need to be authorized to perform this action!');
expect(data?.stopApp).toBeUndefined();
});
it('Should throw an error if no userId is provided', async () => {
const { data, errors } = await gcall<{ stopApp: TApp }>({
source: stopAppMutation,
variableValues: { id: app1.id },
});
expect(errors?.[0].message).toBe('Access denied! You need to be authorized to perform this action!');
expect(data?.stopApp).toBeUndefined();
});
});
describe('UninstallApp', () => {
let app1: AppInfo;
beforeEach(async () => {
const app1create = await createApp({ status: AppStatusEnum.STOPPED, installed: true });
app1 = app1create.appInfo;
// @ts-ignore
fs.__createMockFiles(app1create.MockFiles);
});
it('Should uninstall app', async () => {
const user = await createUser();
const { data } = await gcall<{ uninstallApp: TApp }>({
source: uninstallAppMutation,
userId: user.id,
variableValues: { id: app1.id },
});
expect(data?.uninstallApp.info.id).toBe(app1.id);
expect(data?.uninstallApp.status).toBe(AppStatusEnum.MISSING.toUpperCase());
});
it("Should return an error if app doesn't exist", async () => {
const user = await createUser();
const { data, errors } = await gcall<{ uninstallApp: TApp }>({
source: uninstallAppMutation,
userId: user.id,
variableValues: { id: 'not-existing' },
});
expect(errors?.[0].message).toBe('App not-existing not found');
expect(data?.uninstallApp).toBeUndefined();
});
it("Should throw an error if user doesn't exist", async () => {
const { data, errors } = await gcall<{ uninstallApp: TApp }>({
source: uninstallAppMutation,
userId: 0,
variableValues: { id: app1.id },
});
expect(errors?.[0].message).toBe('Access denied! You need to be authorized to perform this action!');
expect(data?.uninstallApp).toBeUndefined();
});
it('Should throw an error if no userId is provided', async () => {
const { data, errors } = await gcall<{ uninstallApp: TApp }>({
source: uninstallAppMutation,
variableValues: { id: app1.id },
});
expect(errors?.[0].message).toBe('Access denied! You need to be authorized to perform this action!');
expect(data?.uninstallApp).toBeUndefined();
});
});
describe('UpdateAppConfig', () => {
let app1: AppInfo;
beforeEach(async () => {
const app1create = await createApp({ status: AppStatusEnum.STOPPED, installed: true });
app1 = app1create.appInfo;
// @ts-ignore
fs.__createMockFiles(app1create.MockFiles);
});
it('Should update app config', async () => {
const user = await createUser();
const word = faker.random.word();
const { data } = await gcall<{ updateAppConfig: TApp }>({
source: updateAppConfigMutation,
userId: user.id,
variableValues: { input: { id: app1.id, form: { TEST_FIELD: word }, exposed: false, domain: '' } },
});
expect(data?.updateAppConfig.info.id).toBe(app1.id);
expect(data?.updateAppConfig.config.TEST_FIELD).toBe(word);
});
it("Should return an error if app doesn't exist", async () => {
const user = await createUser();
const { data, errors } = await gcall<{ updateAppConfig: TApp }>({
source: updateAppConfigMutation,
userId: user.id,
variableValues: { input: { id: 'not-existing', form: { TEST_FIELD: faker.random.word() }, exposed: false, domain: '' } },
});
expect(errors?.[0].message).toBe('App not-existing not found');
expect(data?.updateAppConfig).toBeUndefined();
});
it("Should throw an error if user doesn't exist", async () => {
const { data, errors } = await gcall<{ updateAppConfig: TApp }>({
source: updateAppConfigMutation,
userId: 0,
variableValues: { input: { id: app1.id, form: { TEST_FIELD: faker.random.word() }, exposed: false, domain: '' } },
});
expect(errors?.[0].message).toBe('Access denied! You need to be authorized to perform this action!');
expect(data?.updateAppConfig).toBeUndefined();
});
it('Should throw an error if no userId is provided', async () => {
const { data, errors } = await gcall<{ updateAppConfig: TApp }>({
source: updateAppConfigMutation,
variableValues: { input: { id: app1.id, form: { TEST_FIELD: faker.random.word() }, exposed: false, domain: '' } },
});
expect(errors?.[0].message).toBe('Access denied! You need to be authorized to perform this action!');
expect(data?.updateAppConfig).toBeUndefined();
});
});
describe('UpdateApp', () => {
let app1: AppInfo;
beforeEach(async () => {
const app1create = await createApp({ status: AppStatusEnum.STOPPED, installed: true });
app1 = app1create.appInfo;
// @ts-ignore
fs.__createMockFiles(app1create.MockFiles);
});
it('Should update app', async () => {
const user = await createUser();
const { data } = await gcall<{ updateApp: TApp }>({
source: updateAppMutation,
userId: user.id,
variableValues: { id: app1.id },
});
expect(data?.updateApp.info.id).toBe(app1.id);
expect(data?.updateApp.info.name).toBe(data?.updateApp.info.name);
});
it("Should return an error if app doesn't exist", async () => {
const user = await createUser();
const { data, errors } = await gcall<{ updateApp: TApp }>({
source: updateAppMutation,
userId: user.id,
variableValues: { id: 'not-existing' },
});
expect(errors?.[0].message).toBe('App not-existing not found');
expect(data?.updateApp).toBeUndefined();
});
it("Should throw an error if user doesn't exist", async () => {
const { data, errors } = await gcall<{ updateApp: TApp }>({
source: updateAppMutation,
userId: 0,
variableValues: { id: app1.id },
});
expect(errors?.[0].message).toBe('Access denied! You need to be authorized to perform this action!');
expect(data?.updateApp).toBeUndefined();
});
it('Should throw an error if no userId is provided', async () => {
const { data, errors } = await gcall<{ updateApp: TApp }>({
source: updateAppMutation,
variableValues: { id: app1.id },
});
expect(errors?.[0].message).toBe('Access denied! You need to be authorized to perform this action!');
expect(data?.updateApp).toBeUndefined();
});
});

View file

@ -1,646 +0,0 @@
import fs from 'fs-extra';
import { DataSource } from 'typeorm';
import AppsService from '../apps.service';
import { AppInfo, AppStatusEnum, AppSupportedArchitecturesEnum } from '../apps.types';
import App from '../app.entity';
import { createApp } from './apps.factory';
import { setupConnection, teardownConnection } from '../../../test/connection';
import { getEnvMap } from '../apps.helpers';
import EventDispatcher, { eventDispatcher, EventTypes } from '../../../core/config/EventDispatcher';
import { setConfig } from '../../../core/config/TipiConfig';
jest.mock('fs-extra');
jest.mock('child_process');
let db: DataSource | null = null;
const TEST_SUITE = 'appsservicelegacy';
beforeAll(async () => {
db = await setupConnection(TEST_SUITE);
});
beforeEach(async () => {
jest.resetModules();
jest.resetAllMocks();
jest.restoreAllMocks();
EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValue({ success: true });
await App.clear();
});
afterAll(async () => {
await db?.destroy();
await teardownConnection(TEST_SUITE);
});
describe('Install app', () => {
let app1: AppInfo;
beforeEach(async () => {
const { MockFiles, appInfo } = await createApp({});
app1 = appInfo;
// @ts-ignore
fs.__createMockFiles(MockFiles);
});
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();
expect(envFile.trim()).toBe(`TEST=test\nAPP_PORT=${app1.port}\nTEST_FIELD=test\nAPP_DOMAIN=localhost:${app1.port}`);
});
it('Should add app in database', async () => {
await AppsService.installApp(app1.id, { TEST_FIELD: 'test' });
const app = await App.findOne({ where: { id: app1.id } });
expect(app).toBeDefined();
expect(app?.id).toBe(app1.id);
expect(app?.config).toStrictEqual({ TEST_FIELD: 'test' });
expect(app?.status).toBe(AppStatusEnum.RUNNING);
});
it('Should start app if already installed', async () => {
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([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 () => {
// Arrange
EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: '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();
});
it('Should throw if required form fields are missing', async () => {
await expect(AppsService.installApp(app1.id, {})).rejects.toThrowError('Variable TEST_FIELD is required');
});
it('Correctly generates a random value if the field has a "random" type', async () => {
const { appInfo, MockFiles } = await createApp({ randomField: true });
// @ts-ignore
fs.__createMockFiles(MockFiles);
await AppsService.installApp(appInfo.id, { TEST_FIELD: 'yolo' });
const envMap = getEnvMap(appInfo.id);
expect(envMap.get('RANDOM_FIELD')).toBeDefined();
expect(envMap.get('RANDOM_FIELD')).toHaveLength(32);
});
it('Should correctly copy app from repos to apps folder', async () => {
await AppsService.installApp(app1.id, { TEST_FIELD: 'test' });
const appFolder = fs.readdirSync(`/runtipi/apps/${app1.id}`);
expect(appFolder).toBeDefined();
expect(appFolder.indexOf('docker-compose.yml')).toBeGreaterThanOrEqual(0);
});
it('Should cleanup any app folder existing before install', async () => {
const { MockFiles, appInfo } = await createApp({});
app1 = appInfo;
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(`/runtipi/apps/${app1.id}/test.yml`)).toBe(true);
await AppsService.installApp(app1.id, { TEST_FIELD: 'test' });
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 () => {
await expect(AppsService.installApp(app1.id, { TEST_FIELD: 'test' }, true)).rejects.toThrowError('Domain is required if app is exposed');
});
it('Should throw if app is exposed and config does not allow it', async () => {
await expect(AppsService.installApp(app1.id, { TEST_FIELD: 'test' }, true, 'test.com')).rejects.toThrowError(`App ${app1.id} is not exposable`);
});
it('Should throw if app is exposed and domain is not valid', async () => {
const { MockFiles, appInfo } = await createApp({ exposable: true });
// @ts-ignore
fs.__createMockFiles(MockFiles);
await expect(AppsService.installApp(appInfo.id, { TEST_FIELD: 'test' }, true, 'test')).rejects.toThrowError('Domain test is not valid');
});
it('Should throw if app is exposed and domain is already used', async () => {
const app2 = await createApp({ exposable: true });
const app3 = await createApp({ exposable: true });
// @ts-ignore
fs.__createMockFiles({ ...app2.MockFiles, ...app3.MockFiles });
await AppsService.installApp(app2.appInfo.id, { TEST_FIELD: 'test' }, true, 'test.com');
await expect(AppsService.installApp(app3.appInfo.id, { TEST_FIELD: 'test' }, true, 'test.com')).rejects.toThrowError(`Domain test.com already in use by app ${app2.appInfo.id}`);
});
it('Should throw if architecure is not supported', async () => {
// arrange
setConfig('architecture', AppSupportedArchitecturesEnum.AMD64);
const { MockFiles, appInfo } = await createApp({ supportedArchitectures: [AppSupportedArchitecturesEnum.ARM] });
// @ts-ignore
fs.__createMockFiles(MockFiles);
await expect(AppsService.installApp(appInfo.id, { TEST_FIELD: 'test' })).rejects.toThrowError(`App ${appInfo.id} is not supported on this architecture`);
});
it('Can install if architecture is supported', async () => {
setConfig('architecture', AppSupportedArchitecturesEnum.ARM);
const { MockFiles, appInfo } = await createApp({ supportedArchitectures: [AppSupportedArchitecturesEnum.ARM, AppSupportedArchitecturesEnum.ARM64] });
// @ts-ignore
fs.__createMockFiles(MockFiles);
await AppsService.installApp(appInfo.id, { TEST_FIELD: 'test' });
const app = await App.findOne({ where: { id: appInfo.id } });
expect(app).toBeDefined();
});
it('Can install if no architecture is specified', async () => {
setConfig('architecture', AppSupportedArchitecturesEnum.ARM);
const { MockFiles, appInfo } = await createApp({ supportedArchitectures: undefined });
// @ts-ignore
fs.__createMockFiles(MockFiles);
await AppsService.installApp(appInfo.id, { TEST_FIELD: 'test' });
const app = await App.findOne({ where: { id: appInfo.id } });
expect(app).toBeDefined();
});
it('Should throw if config.json is not valid', async () => {
// arrange
const { MockFiles, appInfo } = await createApp({});
MockFiles[`/runtipi/repos/repo-id/apps/${appInfo.id}/config.json`] = 'test';
// @ts-ignore
fs.__createMockFiles(MockFiles);
// act & assert
await expect(AppsService.installApp(appInfo.id, { TEST_FIELD: 'test' })).rejects.toThrowError(`App ${appInfo.id} has invalid config.json file`);
});
it('Should throw if config.json is not valid after folder copy', async () => {
// arrange
jest.spyOn(fs, 'copySync').mockImplementationOnce(() => {});
const { MockFiles, appInfo } = await createApp({});
MockFiles[`/runtipi/apps/${appInfo.id}/config.json`] = 'test';
// @ts-ignore
fs.__createMockFiles(MockFiles);
// act & assert
await expect(AppsService.installApp(appInfo.id, { TEST_FIELD: 'test' })).rejects.toThrowError(`App ${appInfo.id} has invalid config.json file`);
});
});
describe('Uninstall app', () => {
let app1: AppInfo;
beforeEach(async () => {
const app1create = await createApp({ installed: true });
app1 = app1create.appInfo;
// @ts-ignore
fs.__createMockFiles(Object.assign(app1create.MockFiles));
});
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 stop app if it is running', async () => {
// 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([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 () => {
// Arrange
EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'test' });
await App.update({ id: app1.id }, { status: AppStatusEnum.UPDATING });
// 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);
});
});
describe('Start app', () => {
let app1: AppInfo;
beforeEach(async () => {
const app1create = await createApp({ installed: true });
app1 = app1create.appInfo;
// @ts-ignore
fs.__createMockFiles(Object.assign(app1create.MockFiles));
});
it('Should correctly dispatch event', async () => {
const spy = jest.spyOn(eventDispatcher, 'dispatchEventAsync');
await AppsService.startApp(app1.id);
expect(spy.mock.lastCall).toEqual([EventTypes.APP, ['start', app1.id]]);
spy.mockRestore();
});
it('Should throw if app is not installed', async () => {
await expect(AppsService.startApp('any')).rejects.toThrowError('App any not found');
});
it('Should restart if app is already running', async () => {
const spy = jest.spyOn(eventDispatcher, 'dispatchEventAsync');
await AppsService.startApp(app1.id);
expect(spy.mock.calls.length).toBe(1);
await AppsService.startApp(app1.id);
expect(spy.mock.calls.length).toBe(2);
spy.mockRestore();
});
it('Regenerate env file', async () => {
fs.writeFileSync(`/app/storage/app-data/${app1.id}/app.env`, 'TEST=test\nAPP_PORT=3000');
await AppsService.startApp(app1.id);
const envFile = fs.readFileSync(`/app/storage/app-data/${app1.id}/app.env`).toString();
expect(envFile.trim()).toBe(`TEST=test\nAPP_PORT=${app1.port}\nTEST_FIELD=test\nAPP_DOMAIN=localhost:${app1.port}`);
});
it('Should throw if start script fails', async () => {
// Arrange
EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'test' });
// 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);
});
});
describe('Stop app', () => {
let app1: AppInfo;
beforeEach(async () => {
const app1create = await createApp({ installed: true });
app1 = app1create.appInfo;
// @ts-ignore
fs.__createMockFiles(Object.assign(app1create.MockFiles));
});
it('Should correctly dispatch stop event', async () => {
const spy = jest.spyOn(eventDispatcher, 'dispatchEventAsync');
await AppsService.stopApp(app1.id);
expect(spy.mock.lastCall).toEqual([EventTypes.APP, ['stop', app1.id]]);
});
it('Should throw if app is not installed', async () => {
await expect(AppsService.stopApp('any')).rejects.toThrowError('App any not found');
});
it('Should throw if stop script fails', async () => {
// Arrange
EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'test' });
// 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);
});
});
describe('Update app config', () => {
let app1: AppInfo;
beforeEach(async () => {
const app1create = await createApp({ installed: true });
app1 = app1create.appInfo;
// @ts-ignore
fs.__createMockFiles(Object.assign(app1create.MockFiles));
});
it('Should correctly update app config', async () => {
await AppsService.updateAppConfig(app1.id, { TEST_FIELD: 'test' });
const envFile = fs.readFileSync(`/app/storage/app-data/${app1.id}/app.env`).toString();
expect(envFile.trim()).toBe(`TEST=test\nAPP_PORT=${app1.port}\nTEST_FIELD=test\nAPP_DOMAIN=localhost:${app1.port}`);
});
it('Should throw if required field is missing', async () => {
await expect(AppsService.updateAppConfig(app1.id, { TEST_FIELD: '' })).rejects.toThrowError('Variable TEST_FIELD is required');
});
it('Should throw if app is not installed', async () => {
await expect(AppsService.updateAppConfig('test-app-2', { test: 'test' })).rejects.toThrowError('App test-app-2 not found');
});
it('Should not recreate random field if already present in .env', async () => {
const { appInfo, MockFiles } = await createApp({ randomField: true, installed: true });
// @ts-ignore
fs.__createMockFiles(MockFiles);
const envFile = fs.readFileSync(`/app/storage/app-data/${appInfo.id}/app.env`).toString();
fs.writeFileSync(`/app/storage/app-data/${appInfo.id}/app.env`, `${envFile}\nRANDOM_FIELD=test`);
await AppsService.updateAppConfig(appInfo.id, { TEST_FIELD: 'test' });
const envMap = getEnvMap(appInfo.id);
expect(envMap.get('RANDOM_FIELD')).toBe('test');
});
it('Should throw if app is exposed and domain is not provided', () => expect(AppsService.updateAppConfig(app1.id, { TEST_FIELD: 'test' }, true)).rejects.toThrowError('Domain is required'));
it('Should throw if app is exposed and domain is not valid', () =>
expect(AppsService.updateAppConfig(app1.id, { TEST_FIELD: 'test' }, true, 'test')).rejects.toThrowError('Domain test is not valid'));
it('Should throw if app is exposed and config does not allow it', () =>
expect(AppsService.updateAppConfig(app1.id, { TEST_FIELD: 'test' }, true, 'test.com')).rejects.toThrowError(`App ${app1.id} is not exposable`));
it('Should throw if app is exposed and domain is already used', async () => {
const app2 = await createApp({ exposable: true, installed: true });
const app3 = await createApp({ exposable: true, installed: true });
// @ts-ignore
fs.__createMockFiles(Object.assign(app2.MockFiles, app3.MockFiles));
await AppsService.updateAppConfig(app2.appInfo.id, { TEST_FIELD: 'test' }, true, 'test.com');
await expect(AppsService.updateAppConfig(app3.appInfo.id, { TEST_FIELD: 'test' }, true, 'test.com')).rejects.toThrowError(`Domain test.com already in use by app ${app2.appInfo.id}`);
});
it('Should not throw if updating with same domain', async () => {
const app2 = await createApp({ exposable: true, installed: true });
// @ts-ignore
fs.__createMockFiles(Object.assign(app2.MockFiles));
await AppsService.updateAppConfig(app2.appInfo.id, { TEST_FIELD: 'test' }, true, 'test.com');
await AppsService.updateAppConfig(app2.appInfo.id, { TEST_FIELD: 'test' }, true, 'test.com');
});
it('Should throw if app has invalid config.json', async () => {
const { appInfo, MockFiles } = await createApp({ installed: true });
MockFiles[`/runtipi/apps/${appInfo.id}/config.json`] = 'invalid json';
// @ts-ignore
fs.__createMockFiles(Object.assign(MockFiles));
fs.writeFileSync(`/app/storage/app-data/${appInfo.id}/config.json`, 'test');
await expect(AppsService.updateAppConfig(appInfo.id, { TEST_FIELD: 'test' })).rejects.toThrowError(`App ${appInfo.id} has invalid config.json`);
});
});
describe('Get app config', () => {
let app1: AppInfo;
beforeEach(async () => {
const app1create = await createApp({ installed: true });
app1 = app1create.appInfo;
// @ts-ignore
fs.__createMockFiles(Object.assign(app1create.MockFiles));
});
it('Should correctly get app config', async () => {
const app = await AppsService.getApp(app1.id);
expect(app).toBeDefined();
expect(app.config).toStrictEqual({ TEST_FIELD: 'test' });
expect(app.id).toBe(app1.id);
expect(app.status).toBe(AppStatusEnum.RUNNING);
});
it('Should return default values if app is not installed', async () => {
const appconfig = await AppsService.getApp('test-app2');
expect(appconfig).toBeDefined();
expect(appconfig.id).toBe('test-app2');
expect(appconfig.config).toStrictEqual({});
expect(appconfig.status).toBe(AppStatusEnum.MISSING);
});
});
describe('List apps', () => {
let app1: AppInfo;
let app2: AppInfo;
beforeEach(async () => {
const app1create = await createApp({ installed: true });
const app2create = await createApp({});
app1 = app1create.appInfo;
app2 = app2create.appInfo;
// @ts-ignore
fs.__createMockFiles(Object.assign(app1create.MockFiles, app2create.MockFiles));
});
it('Should correctly list apps sorted by name', async () => {
const { apps } = await AppsService.listApps();
const sortedApps = [app1, app2].sort((a, b) => a.name.localeCompare(b.name));
expect(apps).toBeDefined();
expect(apps.length).toBe(2);
expect(apps.length).toBe(2);
expect(apps[0].id).toBe(sortedApps[0].id);
expect(apps[1].id).toBe(sortedApps[1].id);
expect(apps[0].description).toBe('md desc');
});
it('Should not list apps that have supportedArchitectures and are not supported', async () => {
// Arrange
setConfig('architecture', AppSupportedArchitecturesEnum.ARM64);
const app3 = await createApp({ supportedArchitectures: [AppSupportedArchitecturesEnum.ARM] });
// @ts-ignore
fs.__createMockFiles(Object.assign(app3.MockFiles));
// Act
const { apps } = await AppsService.listApps();
// Assert
expect(apps).toBeDefined();
expect(apps.length).toBe(0);
});
it('Should list apps that have supportedArchitectures and are supported', async () => {
// Arrange
setConfig('architecture', AppSupportedArchitecturesEnum.ARM);
const app3 = await createApp({ supportedArchitectures: [AppSupportedArchitecturesEnum.ARM] });
// @ts-ignore
fs.__createMockFiles(Object.assign(app3.MockFiles));
// Act
const { apps } = await AppsService.listApps();
// Assert
expect(apps).toBeDefined();
expect(apps.length).toBe(1);
});
it('Should list apps that have no supportedArchitectures specified', async () => {
// Arrange
setConfig('architecture', AppSupportedArchitecturesEnum.ARM);
const app3 = await createApp({ supportedArchitectures: undefined });
// @ts-ignore
fs.__createMockFiles(Object.assign(app3.MockFiles));
// Act
const { apps } = await AppsService.listApps();
// Assert
expect(apps).toBeDefined();
expect(apps.length).toBe(1);
});
it('Should not list app with invalid config.json', async () => {
// Arrange
const { MockFiles: mockApp1, appInfo } = await createApp({});
const { MockFiles: mockApp2 } = await createApp({});
const MockFiles = Object.assign(mockApp1, mockApp2);
MockFiles[`/runtipi/repos/repo-id/apps/${appInfo.id}/config.json`] = 'invalid json';
// @ts-ignore
fs.__createMockFiles(MockFiles);
// Act
const { apps } = await AppsService.listApps();
// Assert
expect(apps).toBeDefined();
expect(apps.length).toBe(1);
});
});
describe('Start all apps', () => {
let app1: AppInfo;
let app2: AppInfo;
beforeEach(async () => {
const app1create = await createApp({ installed: true });
const app2create = await createApp({ installed: true });
app1 = app1create.appInfo;
app2 = app2create.appInfo;
// @ts-ignore
fs.__createMockFiles(Object.assign(app1create.MockFiles, app2create.MockFiles));
});
it('Should correctly start all apps', async () => {
const spy = jest.spyOn(eventDispatcher, 'dispatchEventAsync');
await AppsService.startAllApps();
expect(spy.mock.calls.length).toBe(2);
expect(spy.mock.calls).toEqual([
[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(eventDispatcher, 'dispatchEventAsync');
await createApp({ installed: true, status: AppStatusEnum.STOPPED });
await AppsService.startAllApps();
const apps = await App.find();
expect(spy.mock.calls.length).toBe(2);
expect(apps.length).toBe(3);
});
it('Should put app status to STOPPED if start script fails', async () => {
// Arrange
EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'error' });
// Act
await AppsService.startAllApps();
const apps = await App.find();
// Assert
expect(apps.length).toBe(2);
expect(apps[0].status).toBe(AppStatusEnum.STOPPED);
expect(apps[1].status).toBe(AppStatusEnum.STOPPED);
});
});
describe('Update app', () => {
let app1: AppInfo;
beforeEach(async () => {
const app1create = await createApp({ installed: true });
app1 = app1create.appInfo;
// @ts-ignore
fs.__createMockFiles(Object.assign(app1create.MockFiles));
});
it('Should correctly update app', async () => {
await App.update({ id: app1.id }, { version: 0 });
const app = await AppsService.updateApp(app1.id);
expect(app).toBeDefined();
expect(app.config).toStrictEqual({ TEST_FIELD: 'test' });
expect(app.version).toBe(app1.tipi_version);
expect(app.status).toBe(AppStatusEnum.STOPPED);
});
it("Should throw if app doesn't exist", async () => {
await expect(AppsService.updateApp('test-app2')).rejects.toThrow('App test-app2 not found');
});
it('Should throw if update script fails', async () => {
// Arrange
EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'error' });
await expect(AppsService.updateApp(app1.id)).rejects.toThrow(`App ${app1.id} failed to update\nstdout: error`);
const app = await App.findOne({ where: { id: app1.id } });
expect(app?.status).toBe(AppStatusEnum.STOPPED);
});
});

View file

@ -1,77 +0,0 @@
import { GraphQLJSONObject } from 'graphql-type-json';
import { Field, ObjectType, registerEnumType } from 'type-graphql';
import { BaseEntity, Column, CreateDateColumn, Entity, UpdateDateColumn } from 'typeorm';
import { getAppInfo, getUpdateInfo } from './apps.helpers';
import { AppInfo, AppStatusEnum } from './apps.types';
registerEnumType(AppStatusEnum, {
name: 'AppStatusEnum',
});
@ObjectType()
class UpdateInfo {
@Field(() => Number)
current!: number;
@Field(() => Number)
latest!: number;
@Field(() => String, { nullable: true })
dockerVersion?: string;
}
@ObjectType()
@Entity()
class App extends BaseEntity {
@Field(() => String)
@Column({ type: 'varchar', primary: true, unique: true })
id!: string;
@Field(() => AppStatusEnum)
@Column({ type: 'enum', enum: AppStatusEnum, default: AppStatusEnum.STOPPED, nullable: false })
status!: AppStatusEnum;
@Field(() => Date)
@Column({ type: 'timestamptz', nullable: true, default: () => 'CURRENT_TIMESTAMP' })
lastOpened!: Date;
@Field(() => Number)
@Column({ type: 'integer', default: 0, nullable: false })
numOpened!: number;
@Field(() => GraphQLJSONObject)
@Column({ type: 'jsonb', nullable: false })
config!: Record<string, string>;
@Field(() => Number, { nullable: true })
@Column({ type: 'integer', default: 1, nullable: false })
version!: number;
@Field(() => Date)
@CreateDateColumn()
createdAt!: Date;
@Field(() => Date)
@UpdateDateColumn()
updatedAt!: Date;
@Field(() => Boolean)
@Column({ type: 'boolean', default: false })
exposed!: boolean;
@Field(() => String, { nullable: true })
@Column({ type: 'varchar', nullable: true })
domain?: string;
@Field(() => AppInfo, { nullable: true })
info(): AppInfo | null {
return getAppInfo(this.id, this.status);
}
@Field(() => UpdateInfo, { nullable: true })
updateInfo(): Promise<UpdateInfo | null> {
return getUpdateInfo(this.id, this.version);
}
}
export default App;

View file

@ -1,6 +0,0 @@
export interface AppEntityType {
id: string;
config: Record<string, string>;
exposed: boolean;
domain?: string;
}

View file

@ -1,236 +0,0 @@
import crypto from 'crypto';
import fs from 'fs-extra';
import { z } from 'zod';
import { deleteFolder, fileExists, getSeed, readdirSync, readFile, readJsonFile, writeFile } from '../fs/fs.helpers';
import { AppCategoriesEnum, AppInfo, AppStatusEnum, AppSupportedArchitecturesEnum, FieldTypes } from './apps.types';
import logger from '../../config/logger/logger';
import { getConfig } from '../../core/config/TipiConfig';
import { AppEntityType } from './app.types';
import { notEmpty } from '../../helpers/helpers';
const formFieldSchema = z.object({
type: z.nativeEnum(FieldTypes),
label: z.string(),
placeholder: z.string().optional(),
max: z.number().optional(),
min: z.number().optional(),
hint: z.string().optional(),
required: z.boolean().optional().default(false),
env_variable: z.string(),
});
export const appInfoSchema = z.object({
id: z.string(),
available: z.boolean(),
port: z.number().min(1).max(65535),
name: z.string(),
description: z.string().optional().default(''),
version: z.string().optional().default('latest'),
tipi_version: z.number(),
short_desc: z.string(),
author: z.string(),
source: z.string(),
website: z.string().optional(),
categories: z.nativeEnum(AppCategoriesEnum).array(),
url_suffix: z.string().optional(),
form_fields: z.array(formFieldSchema).optional().default([]),
https: z.boolean().optional().default(false),
exposable: z.boolean().optional().default(false),
no_gui: z.boolean().optional().default(false),
supported_architectures: z.nativeEnum(AppSupportedArchitecturesEnum).array().optional(),
});
export const checkAppRequirements = (appName: string) => {
const configFile = readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${appName}/config.json`);
const parsedConfig = appInfoSchema.safeParse(configFile);
if (!parsedConfig.success) {
throw new Error(`App ${appName} has invalid config.json file`);
}
if (parsedConfig.data.supported_architectures && !parsedConfig.data.supported_architectures.includes(getConfig().architecture)) {
throw new Error(`App ${appName} is not supported on this architecture`);
}
return parsedConfig.data;
};
export const getEnvMap = (appName: string): Map<string, string> => {
const envFile = readFile(`/app/storage/app-data/${appName}/app.env`).toString();
const envVars = envFile.split('\n');
const envVarsMap = new Map<string, string>();
envVars.forEach((envVar) => {
const [key, value] = envVar.split('=');
envVarsMap.set(key, value);
});
return envVarsMap;
};
export const checkEnvFile = (appName: string) => {
const configFile = readJsonFile(`/runtipi/apps/${appName}/config.json`);
const parsedConfig = appInfoSchema.safeParse(configFile);
if (!parsedConfig.success) {
throw new Error(`App ${appName} has invalid config.json file`);
}
const envMap = getEnvMap(appName);
parsedConfig.data.form_fields.forEach((field) => {
const envVar = field.env_variable;
const envVarValue = envMap.get(envVar);
if (!envVarValue && field.required) {
throw new Error('New info needed. App config needs to be updated');
}
});
};
const getEntropy = (name: string, length: number) => {
const hash = crypto.createHash('sha256');
hash.update(name + getSeed());
return hash.digest('hex').substring(0, length);
};
export const generateEnvFile = (app: AppEntityType) => {
const configFile = readJsonFile(`/runtipi/apps/${app.id}/config.json`);
const parsedConfig = appInfoSchema.safeParse(configFile);
if (!parsedConfig.success) {
throw new Error(`App ${app.id} has invalid config.json file`);
}
const baseEnvFile = readFile('/runtipi/.env').toString();
let envFile = `${baseEnvFile}\nAPP_PORT=${parsedConfig.data.port}\n`;
const envMap = getEnvMap(app.id);
parsedConfig.data.form_fields.forEach((field) => {
const formValue = app.config[field.env_variable];
const envVar = field.env_variable;
if (formValue) {
envFile += `${envVar}=${formValue}\n`;
} else if (field.type === 'random') {
if (envMap.has(envVar)) {
envFile += `${envVar}=${envMap.get(envVar)}\n`;
} else {
const length = field.min || 32;
const randomString = getEntropy(field.env_variable, length);
envFile += `${envVar}=${randomString}\n`;
}
} else if (field.required) {
throw new Error(`Variable ${field.env_variable} is required`);
}
});
if (app.exposed && app.domain) {
envFile += 'APP_EXPOSED=true\n';
envFile += `APP_DOMAIN=${app.domain}\n`;
envFile += 'APP_PROTOCOL=https\n';
} else {
envFile += `APP_DOMAIN=${getConfig().internalIp}:${parsedConfig.data.port}\n`;
}
// Create app-data folder if it doesn't exist
if (!fs.existsSync(`/app/storage/app-data/${app.id}`)) {
fs.mkdirSync(`/app/storage/app-data/${app.id}`, { recursive: true });
}
writeFile(`/app/storage/app-data/${app.id}/app.env`, envFile);
};
export const getAvailableApps = async (): Promise<AppInfo[]> => {
const appsDir = readdirSync(`/runtipi/repos/${getConfig().appsRepoId}/apps`);
const skippedFiles = ['__tests__', 'docker-compose.common.yml', 'schema.json'];
const apps = appsDir
.map((app) => {
if (skippedFiles.includes(app)) return null;
const configFile = readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${app}/config.json`);
const parsedConfig = appInfoSchema.safeParse(configFile);
if (!parsedConfig.success) {
logger.error(`App ${JSON.stringify(app)} has invalid config.json`);
} else if (parsedConfig.data.available) {
const description = readFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${parsedConfig.data.id}/metadata/description.md`);
return { ...parsedConfig.data, description };
}
return null;
})
.filter(notEmpty);
return apps;
};
export const getAppInfo = (id: string, status?: AppStatusEnum): AppInfo | null => {
try {
// Check if app is installed
const installed = typeof status !== 'undefined' && status !== AppStatusEnum.MISSING;
if (installed && fileExists(`/runtipi/apps/${id}/config.json`)) {
const configFile = readJsonFile(`/runtipi/apps/${id}/config.json`);
const parsedConfig = appInfoSchema.safeParse(configFile);
if (parsedConfig.success && parsedConfig.data.available) {
const description = readFile(`/runtipi/apps/${id}/metadata/description.md`);
return { ...parsedConfig.data, description };
}
}
if (fileExists(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}/config.json`)) {
const configFile = readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}/config.json`);
const parsedConfig = appInfoSchema.safeParse(configFile);
if (parsedConfig.success && parsedConfig.data.available) {
const description = readFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}/metadata/description.md`);
return { ...parsedConfig.data, description };
}
}
return null;
} catch (e) {
logger.error(`Error loading app: ${id}`);
throw new Error(`Error loading app: ${id}`);
}
};
export const getUpdateInfo = async (id: string, version?: number) => {
const doesFileExist = fileExists(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}`);
if (!doesFileExist || !version) {
return null;
}
const repoConfig = readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}/config.json`);
const parsedConfig = appInfoSchema.safeParse(repoConfig);
if (parsedConfig.success) {
return {
current: version || 0,
latest: parsedConfig.data.tipi_version,
dockerVersion: parsedConfig.data.version,
};
}
return null;
};
export const ensureAppFolder = (appName: string, cleanup = false) => {
if (cleanup && fileExists(`/runtipi/apps/${appName}`)) {
deleteFolder(`/runtipi/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}`, `/runtipi/apps/${appName}`);
}
};

View file

@ -1,63 +0,0 @@
import { Arg, Authorized, Mutation, Query, Resolver } from 'type-graphql';
import AppsService from './apps.service';
import { AppInputType, ListAppsResonse } from './apps.types';
import App from './app.entity';
@Resolver()
export default class AppsResolver {
@Query(() => ListAppsResonse)
listAppsInfo(): Promise<ListAppsResonse> {
return AppsService.listApps();
}
@Query(() => App)
getApp(@Arg('id', () => String) id: string): Promise<App> {
return AppsService.getApp(id);
}
@Authorized()
@Query(() => [App])
async installedApps(): Promise<App[]> {
return App.find();
}
@Authorized()
@Mutation(() => App)
async installApp(@Arg('input', () => AppInputType) input: AppInputType): Promise<App> {
const { id, form, exposed, domain } = input;
return AppsService.installApp(id, form, exposed, domain);
}
@Authorized()
@Mutation(() => App)
async startApp(@Arg('id', () => String) id: string): Promise<App> {
return AppsService.startApp(id);
}
@Authorized()
@Mutation(() => App)
async stopApp(@Arg('id', () => String) id: string): Promise<App> {
return AppsService.stopApp(id);
}
@Authorized()
@Mutation(() => App)
async uninstallApp(@Arg('id', () => String) id: string): Promise<App> {
return AppsService.uninstallApp(id);
}
@Authorized()
@Mutation(() => App)
async updateAppConfig(@Arg('input', () => AppInputType) input: AppInputType): Promise<App> {
const { id, form, exposed, domain } = input;
return AppsService.updateAppConfig(id, form, exposed, domain);
}
@Authorized()
@Mutation(() => App)
async updateApp(@Arg('id', () => String) id: string): Promise<App> {
return AppsService.updateApp(id);
}
}

View file

@ -1,328 +0,0 @@
import validator from 'validator';
import { Not } from 'typeorm';
import { createFolder, readJsonFile } from '../fs/fs.helpers';
import { checkAppRequirements, checkEnvFile, generateEnvFile, getAvailableApps, ensureAppFolder, appInfoSchema } from './apps.helpers';
import { AppInfo, AppStatusEnum, ListAppsResonse } from './apps.types';
import App from './app.entity';
import logger from '../../config/logger/logger';
import { getConfig } from '../../core/config/TipiConfig';
import { eventDispatcher, EventTypes } from '../../core/config/EventDispatcher';
const sortApps = (a: AppInfo, b: AppInfo) => a.name.localeCompare(b.name);
const filterApp = (app: AppInfo): boolean => {
if (!app.supported_architectures) {
return true;
}
const arch = getConfig().architecture;
return app.supported_architectures.includes(arch);
};
const filterApps = (apps: AppInfo[]): AppInfo[] => apps.sort(sortApps).filter(filterApp);
/**
* 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 } });
await Promise.all(
apps.map(async (app) => {
// Regenerate env file
try {
ensureAppFolder(app.id);
generateEnvFile(app);
checkEnvFile(app.id);
await App.update({ id: app.id }, { status: AppStatusEnum.STARTING });
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);
}
}),
);
};
/**
* 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 } });
if (!app) {
throw new Error(`App ${appName} not found`);
}
ensureAppFolder(appName);
// Regenerate env file
generateEnvFile(app);
checkEnvFile(appName);
await App.update({ id: appName }, { status: AppStatusEnum.STARTING });
const { success, stdout } = await eventDispatcher.dispatchEventAsync(EventTypes.APP, ['start', app.id]);
if (success) {
await App.update({ id: appName }, { status: AppStatusEnum.RUNNING });
} else {
await App.update({ id: appName }, { status: AppStatusEnum.STOPPED });
throw new Error(`App ${appName} failed to start\nstdout: ${stdout}`);
}
app = (await App.findOne({ where: { id: appName } })) as 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 } });
if (app) {
await startApp(id);
} else {
if (exposed && !domain) {
throw new Error('Domain is required if app is exposed');
}
if (domain && !validator.isFQDN(domain)) {
throw new Error(`Domain ${domain} is not valid`);
}
ensureAppFolder(id, true);
checkAppRequirements(id);
// Create app folder
createFolder(`/app/storage/app-data/${id}`);
const appInfo = readJsonFile(`/runtipi/apps/${id}/config.json`);
const parsedAppInfo = appInfoSchema.safeParse(appInfo);
if (!parsedAppInfo.success) {
throw new Error(`App ${id} has invalid config.json file`);
}
if (!parsedAppInfo.data.exposable && exposed) {
throw new Error(`App ${id} is not exposable`);
}
if (exposed) {
const appsWithSameDomain = await App.find({ where: { domain, exposed: true } });
if (appsWithSameDomain.length > 0) {
throw new Error(`Domain ${domain} already in use by app ${appsWithSameDomain[0].id}`);
}
}
app = await App.create({ id, status: AppStatusEnum.INSTALLING, config: form, version: parsedAppInfo.data.tipi_version, exposed: exposed || false, domain }).save();
// Create env file
generateEnvFile(app);
// Run script
const { success, stdout } = await eventDispatcher.dispatchEventAsync(EventTypes.APP, ['install', id]);
if (!success) {
await App.delete({ id });
throw new Error(`App ${id} failed to install\nstdout: ${stdout}`);
}
}
await App.update({ id }, { status: AppStatusEnum.RUNNING });
app = (await App.findOne({ where: { id } })) as App;
return app;
};
/**
* List all apps available for installation
* @returns - list of all apps available
*/
const listApps = async (): Promise<ListAppsResonse> => {
const apps = await getAvailableApps();
const filteredApps = filterApps(apps);
return { apps: filteredApps, 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');
}
if (domain && !validator.isFQDN(domain)) {
throw new Error(`Domain ${domain} is not valid`);
}
let app = await App.findOne({ where: { id } });
if (!app) {
throw new Error(`App ${id} not found`);
}
const appInfo = readJsonFile(`/runtipi/apps/${id}/config.json`);
const parsedAppInfo = appInfoSchema.safeParse(appInfo);
if (!parsedAppInfo.success) {
throw new Error(`App ${id} has invalid config.json`);
}
if (!parsedAppInfo.data.exposable && exposed) {
throw new Error(`App ${id} is not exposable`);
}
if (exposed) {
const appsWithSameDomain = await App.find({ where: { domain, exposed: true, id: Not(id) } });
if (appsWithSameDomain.length > 0) {
throw new Error(`Domain ${domain} already in use by app ${appsWithSameDomain[0].id}`);
}
}
await App.update({ id }, { config: form, exposed: exposed || false, domain });
app = (await App.findOne({ where: { id } })) as App;
generateEnvFile(app);
app = (await App.findOne({ where: { id } })) as App;
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 } });
if (!app) {
throw new Error(`App ${id} not found`);
}
ensureAppFolder(id);
generateEnvFile(app);
// Run script
await App.update({ id }, { status: AppStatusEnum.STOPPING });
const { success, stdout } = await eventDispatcher.dispatchEventAsync(EventTypes.APP, ['stop', id]);
if (success) {
await App.update({ id }, { status: AppStatusEnum.STOPPED });
} else {
await App.update({ id }, { status: AppStatusEnum.RUNNING });
throw new Error(`App ${id} failed to stop\nstdout: ${stdout}`);
}
app = (await App.findOne({ where: { id } })) as 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> => {
const app = await App.findOne({ where: { id } });
if (!app) {
throw new Error(`App ${id} not found`);
}
if (app.status === AppStatusEnum.RUNNING) {
await stopApp(id);
}
ensureAppFolder(id);
generateEnvFile(app);
await App.update({ id }, { status: AppStatusEnum.UNINSTALLING });
const { success, stdout } = await eventDispatcher.dispatchEventAsync(EventTypes.APP, ['uninstall', id]);
if (!success) {
await App.update({ id }, { status: AppStatusEnum.STOPPED });
throw new Error(`App ${id} failed to uninstall\nstdout: ${stdout}`);
}
await App.delete({ id });
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 } });
if (!app) {
app = { id, status: AppStatusEnum.MISSING, config: {}, exposed: false, domain: '' } as 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 } });
if (!app) {
throw new Error(`App ${id} not found`);
}
ensureAppFolder(id);
generateEnvFile(app);
await App.update({ id }, { status: AppStatusEnum.UPDATING });
const { success, stdout } = await eventDispatcher.dispatchEventAsync(EventTypes.APP, ['update', id]);
if (success) {
const appInfo = readJsonFile(`/runtipi/apps/${id}/config.json`);
const parsedAppInfo = appInfoSchema.parse(appInfo);
await App.update({ id }, { status: AppStatusEnum.RUNNING, version: parsedAppInfo.tipi_version });
} 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;
};
export default { installApp, startApp, updateApp, listApps, getApp, updateAppConfig, stopApp, uninstallApp, startAllApps };

View file

@ -1,167 +0,0 @@
import { Field, InputType, ObjectType, registerEnumType } from 'type-graphql';
import { GraphQLJSONObject } from 'graphql-type-json';
export enum AppCategoriesEnum {
NETWORK = 'network',
MEDIA = 'media',
DEVELOPMENT = 'development',
AUTOMATION = 'automation',
SOCIAL = 'social',
UTILITIES = 'utilities',
PHOTOGRAPHY = 'photography',
SECURITY = 'security',
FEATURED = 'featured',
BOOKS = 'books',
DATA = 'data',
MUSIC = 'music',
FINANCE = 'finance',
GAMING = 'gaming',
}
export enum FieldTypes {
text = 'text',
password = 'password',
email = 'email',
number = 'number',
fqdn = 'fqdn',
ip = 'ip',
fqdnip = 'fqdnip',
url = 'url',
random = 'random',
}
export enum AppStatusEnum {
RUNNING = 'running',
STOPPED = 'stopped',
INSTALLING = 'installing',
UNINSTALLING = 'uninstalling',
STOPPING = 'stopping',
STARTING = 'starting',
MISSING = 'missing',
UPDATING = 'updating',
}
export enum AppSupportedArchitecturesEnum {
ARM = 'arm',
ARM64 = 'arm64',
AMD64 = 'amd64',
}
registerEnumType(AppCategoriesEnum, {
name: 'AppCategoriesEnum',
});
registerEnumType(FieldTypes, {
name: 'FieldTypesEnum',
});
registerEnumType(AppSupportedArchitecturesEnum, {
name: 'AppSupportedArchitecturesEnum',
});
@ObjectType()
class FormField {
@Field(() => FieldTypes)
type!: FieldTypes;
@Field(() => String)
label!: string;
@Field(() => Number, { nullable: true })
max?: number;
@Field(() => Number, { nullable: true })
min?: number;
@Field(() => String, { nullable: true })
hint?: string;
@Field(() => String, { nullable: true })
placeholder?: string;
@Field(() => Boolean, { nullable: true })
required?: boolean;
@Field(() => String)
env_variable!: string;
}
@ObjectType()
class AppInfo {
@Field(() => String)
id!: string;
@Field(() => Boolean)
available!: boolean;
@Field(() => Number)
port!: number;
@Field(() => String)
name!: string;
@Field(() => String)
description!: string;
@Field(() => String, { nullable: true })
version?: string;
@Field(() => Number, { nullable: false })
tipi_version!: number;
@Field(() => String)
short_desc!: string;
@Field(() => String)
author!: string;
@Field(() => String)
source!: string;
@Field(() => [AppCategoriesEnum])
categories!: AppCategoriesEnum[];
@Field(() => String, { nullable: true })
url_suffix?: string;
@Field(() => [FormField])
form_fields?: FormField[];
@Field(() => Boolean, { nullable: true })
https?: boolean;
@Field(() => Boolean, { nullable: true })
exposable?: boolean;
@Field(() => Boolean, { nullable: true })
no_gui?: boolean;
@Field(() => [AppSupportedArchitecturesEnum], { nullable: true })
supported_architectures?: AppSupportedArchitecturesEnum[];
}
@ObjectType()
class ListAppsResonse {
@Field(() => [AppInfo])
apps!: AppInfo[];
@Field(() => Number)
total!: number;
}
@InputType()
class AppInputType {
@Field(() => String)
id!: string;
@Field(() => GraphQLJSONObject)
form!: Record<string, string>;
@Field(() => Boolean)
exposed!: boolean;
@Field(() => String)
domain!: string;
}
export { ListAppsResonse, AppInfo, AppInputType };

View file

@ -1,16 +0,0 @@
import * as argon2 from 'argon2';
import { faker } from '@faker-js/faker';
import User from '../user.entity';
const createUser = async (email?: string) => {
const hash = await argon2.hash('password');
const user = await User.create({
username: email?.toLowerCase().trim() || faker.internet.email().toLowerCase().trim(),
password: hash,
}).save();
return user;
};
export { createUser };

View file

@ -1,28 +0,0 @@
/* eslint-disable import/no-cycle */
import { Field, ID, ObjectType } from 'type-graphql';
import { BaseEntity, Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
import { IsEmail } from 'class-validator';
@ObjectType()
@Entity()
export default class User extends BaseEntity {
@Field(() => ID)
@PrimaryGeneratedColumn()
id!: number;
@Field(() => String)
@IsEmail()
@Column({ type: 'varchar', unique: true })
username!: string;
@Column({ type: 'varchar', nullable: false })
password!: string;
@Field(() => Date)
@CreateDateColumn()
createdAt!: Date;
@Field(() => Date)
@UpdateDateColumn()
updatedAt!: Date;
}

View file

@ -1,24 +0,0 @@
import { BaseEntity, Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
export enum UpdateStatusEnum {
FAILED = 'FAILED',
SUCCESS = 'SUCCESS',
}
@Entity()
export default class Update extends BaseEntity {
@PrimaryGeneratedColumn()
id!: number;
@Column({ type: 'varchar', unique: true, nullable: false })
name!: string;
@Column({ type: 'enum', enum: UpdateStatusEnum, nullable: false })
status!: UpdateStatusEnum;
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
}

View file

@ -1,13 +0,0 @@
import { GraphQLSchema } from 'graphql';
import { buildSchema } from 'type-graphql';
import { customAuthChecker } from './core/middlewares/authChecker';
import AppsResolver from './modules/apps/apps.resolver';
const createSchema = (): Promise<GraphQLSchema> =>
buildSchema({
resolvers: [AppsResolver],
validate: true,
authChecker: customAuthChecker,
});
export { createSchema };

View file

@ -1,35 +1,16 @@
import 'reflect-metadata';
import express from 'express';
import { ApolloServerPluginLandingPageGraphQLPlayground as Playground } from 'apollo-server-core';
import { ApolloServer } from 'apollo-server-express';
import { createServer } from 'http';
import { ZodError } from 'zod';
import cors, { CorsOptions } from 'cors';
import { createSchema } from './schema';
import { ApolloLogs } from './config/logger/apollo.logger';
import logger from './config/logger/logger';
import getSessionMiddleware from './core/middlewares/sessionMiddleware';
import { MyContext } from './types';
import { __prod__ } from './config/constants/constants';
import datasource from './config/datasource';
import appsService from './modules/apps/apps.service';
import { runUpdates } from './core/updates/run';
import startJobs from './core/jobs/jobs';
import { applyJsonConfig, getConfig, setConfig } from './core/config/TipiConfig';
import { eventDispatcher, EventTypes } from './core/config/EventDispatcher';
const applyCustomConfig = () => {
try {
applyJsonConfig();
} catch (e) {
logger.error('Error applying settings.json config');
if (e instanceof ZodError) {
Object.keys(e.flatten().fieldErrors).forEach((key) => {
logger.error(`Error in field ${key}`);
});
}
}
};
import { applyJsonConfig, getConfig } from './core/config/TipiConfig';
import { eventDispatcher } from './core/config/EventDispatcher';
const corsOptions: CorsOptions = {
credentials: false,
@ -41,7 +22,6 @@ const corsOptions: CorsOptions = {
const main = async () => {
try {
eventDispatcher.clear();
applyCustomConfig();
const app = express();
const port = 3001;
@ -50,37 +30,12 @@ const main = async () => {
app.use(express.static(`${getConfig().rootFolder}/repos/${getConfig().appsRepoId}`));
app.use(getSessionMiddleware);
await datasource.initialize();
const schema = await createSchema();
const httpServer = createServer(app);
const plugins = [ApolloLogs];
if (!__prod__) {
plugins.push(Playground());
}
const apolloServer = new ApolloServer({
schema,
context: ({ req, res }): MyContext => ({ req, res }),
plugins,
cache: 'bounded',
});
await apolloServer.start();
apolloServer.applyMiddleware({ app });
await runUpdates();
httpServer.listen(port, async () => {
await eventDispatcher.dispatchEventAsync(EventTypes.CLONE_REPO, [getConfig().appsRepoUrl]);
await eventDispatcher.dispatchEventAsync(EventTypes.UPDATE_REPO, [getConfig().appsRepoUrl]);
startJobs();
setConfig('status', 'RUNNING');
// Start apps
appsService.startAllApps();
logger.info(`Server running on port ${port} 🚀 Production => ${__prod__}`);
logger.info(`Config: ${JSON.stringify(getConfig(), null, 2)}`);
});

View file

@ -1,48 +0,0 @@
import { DataSource } from 'typeorm';
import pg from 'pg';
import App from '../modules/apps/app.entity';
import User from '../modules/auth/user.entity';
import Update from '../modules/system/update.entity';
const HOST = 'localhost';
const USER = 'postgres';
const DATABASE = 'postgres';
const PASSWORD = 'postgres';
const PORT = 5433;
const pgClient = new pg.Client({
user: USER,
host: HOST,
database: DATABASE,
password: PASSWORD,
port: PORT,
});
export const setupConnection = async (testsuite: string): Promise<DataSource> => {
await pgClient.connect();
await pgClient.query(`DROP DATABASE IF EXISTS ${testsuite}`);
await pgClient.query(`CREATE DATABASE ${testsuite}`);
const AppDataSource = new DataSource({
name: 'default',
type: 'postgres',
host: HOST,
port: PORT,
username: USER,
password: PASSWORD,
database: testsuite,
dropSchema: true,
logging: false,
synchronize: true,
entities: [App, User, Update],
});
await AppDataSource.initialize();
return AppDataSource;
};
export const teardownConnection = async (testsuite: string): Promise<void> => {
await pgClient.query(`DROP DATABASE IF EXISTS ${testsuite}`);
await pgClient.end();
};

View file

@ -1,27 +0,0 @@
import { ExecutionResult, graphql, GraphQLSchema } from 'graphql';
import { Maybe } from 'type-graphql';
import { createSchema } from '../schema';
interface Options {
source: string;
variableValues?: Maybe<{
[key: string]: unknown;
}>;
userId?: number;
session?: string;
}
let schema: GraphQLSchema | null = null;
export const gcall = async <T>({ source, variableValues, userId, session }: Options): Promise<ExecutionResult<T, { [key: string]: unknown }>> => {
if (!schema) {
schema = await createSchema();
}
return graphql({
schema,
source,
variableValues,
contextValue: { req: { session: { userId, id: session } } },
}) as unknown as ExecutionResult<T, { [key: string]: unknown }>;
};

View file

@ -1,21 +0,0 @@
/* eslint-disable import/no-extraneous-dependencies */
import 'graphql-import-node';
import { print } from 'graphql/language/printer';
import * as installApp from './installApp.graphql';
import * as startApp from './startApp.graphql';
import * as stopApp from './stopApp.graphql';
import * as uninstallApp from './uninstallApp.graphql';
import * as updateAppConfig from './updateAppConfig.graphql';
import * as updateApp from './updateApp.graphql';
import * as register from './register.graphql';
import * as login from './login.graphql';
export const installAppMutation = print(installApp);
export const startAppMutation = print(startApp);
export const stopAppMutation = print(stopApp);
export const uninstallAppMutation = print(uninstallApp);
export const updateAppConfigMutation = print(updateAppConfig);
export const updateAppMutation = print(updateApp);
export const registerMutation = print(register);
export const loginMutation = print(login);

View file

@ -1,25 +0,0 @@
mutation InstallApp($input: AppInputType!) {
installApp(input: $input) {
id
status
config
info {
id
available
port
name
description
version
author
source
categories
url_suffix
form_fields {
max
min
required
env_variable
}
}
}
}

View file

@ -1,6 +0,0 @@
# Write your query or mutation here
mutation Login($input: UsernamePasswordInput!) {
login(input: $input) {
token
}
}

View file

@ -1,6 +0,0 @@
# Write your query or mutation here
mutation Register($input: UsernamePasswordInput!) {
register(input: $input) {
token
}
}

View file

@ -1,31 +0,0 @@
# Write your query or mutation here
mutation StartApp($id: String!) {
startApp(id: $id) {
id
status
config
info {
id
available
port
name
description
version
author
source
categories
url_suffix
form_fields {
max
min
required
env_variable
}
}
updateInfo {
current
latest
dockerVersion
}
}
}

View file

@ -1,30 +0,0 @@
mutation StopApp($id: String!) {
stopApp(id: $id) {
id
status
config
info {
id
available
port
name
description
version
author
source
categories
url_suffix
form_fields {
max
min
required
env_variable
}
}
updateInfo {
current
latest
dockerVersion
}
}
}

View file

@ -1,30 +0,0 @@
mutation UninstallApp($id: String!) {
uninstallApp(id: $id) {
id
status
config
info {
id
available
port
name
description
version
author
source
categories
url_suffix
form_fields {
max
min
required
env_variable
}
}
updateInfo {
current
latest
dockerVersion
}
}
}

View file

@ -1,30 +0,0 @@
mutation UpdateApp($id: String!) {
updateApp(id: $id) {
id
status
config
info {
id
available
port
name
description
version
author
source
categories
url_suffix
form_fields {
max
min
required
env_variable
}
}
updateInfo {
current
latest
dockerVersion
}
}
}

View file

@ -1,30 +0,0 @@
mutation UpdateAppConfig($input: AppInputType!) {
updateAppConfig(input: $input) {
id
status
config
info {
id
available
port
name
description
version
author
source
categories
url_suffix
form_fields {
max
min
required
env_variable
}
}
updateInfo {
current
latest
dockerVersion
}
}
}

View file

@ -1,29 +0,0 @@
query GetApp($id: String!) {
getApp(id: $id) {
status
config
info {
id
available
port
name
description
version
author
source
categories
url_suffix
form_fields {
max
min
required
env_variable
}
}
updateInfo {
current
latest
dockerVersion
}
}
}

View file

@ -1,17 +0,0 @@
/* eslint-disable import/no-extraneous-dependencies */
import 'graphql-import-node';
import { print } from 'graphql/language/printer';
import * as listAppInfos from './listAppInfos.graphql';
import * as getApp from './getApp.graphql';
import * as InstalledApps from './installedApps.graphql';
import * as Me from './me.graphql';
import * as isConfigured from './isConfigured.graphql';
import * as refreshToken from './refreshToken.graphql';
export const listAppInfosQuery = print(listAppInfos);
export const getAppQuery = print(getApp);
export const InstalledAppsQuery = print(InstalledApps);
export const MeQuery = print(Me);
export const isConfiguredQuery = print(isConfigured);
export const refreshTokenQuery = print(refreshToken);

View file

@ -1,29 +0,0 @@
query {
installedApps {
id
status
lastOpened
numOpened
config
createdAt
updatedAt
info {
id
available
port
name
description
version
author
source
categories
url_suffix
form_fields {
max
min
required
env_variable
}
}
}
}

View file

@ -1,3 +0,0 @@
query IsConfigured {
isConfigured
}

View file

@ -1,23 +0,0 @@
query {
listAppsInfo {
apps {
id
available
port
name
description
version
author
source
categories
url_suffix
form_fields {
max
min
required
env_variable
}
}
total
}
}

View file

@ -1,6 +0,0 @@
{
me {
id
username
}
}

View file

@ -1,6 +0,0 @@
# Write your query or mutation here
query RefreshToken {
refreshToken {
token
}
}

View file

@ -1,6 +0,0 @@
query {
version {
current
latest
}
}