chore: cleanup system-api from now un-used files
This commit is contained in:
parent
79f1da00d0
commit
36a6483ff7
40 changed files with 2 additions and 3535 deletions
|
@ -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`],
|
||||
});
|
|
@ -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,
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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;
|
|
@ -1,6 +0,0 @@
|
|||
import { updateV040 } from './v040';
|
||||
|
||||
export const runUpdates = async (): Promise<void> => {
|
||||
// v040: Update to 0.4.0
|
||||
await updateV040();
|
||||
};
|
|
@ -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();
|
||||
}
|
||||
};
|
|
@ -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 };
|
|
@ -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]);
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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;
|
|
@ -1,6 +0,0 @@
|
|||
export interface AppEntityType {
|
||||
id: string;
|
||||
config: Record<string, string>;
|
||||
exposed: boolean;
|
||||
domain?: string;
|
||||
}
|
|
@ -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}`);
|
||||
}
|
||||
};
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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 };
|
|
@ -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 };
|
|
@ -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 };
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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 };
|
|
@ -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)}`);
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
};
|
|
@ -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 }>;
|
||||
};
|
|
@ -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);
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
# Write your query or mutation here
|
||||
mutation Login($input: UsernamePasswordInput!) {
|
||||
login(input: $input) {
|
||||
token
|
||||
}
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
# Write your query or mutation here
|
||||
mutation Register($input: UsernamePasswordInput!) {
|
||||
register(input: $input) {
|
||||
token
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,3 +0,0 @@
|
|||
query IsConfigured {
|
||||
isConfigured
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
{
|
||||
me {
|
||||
id
|
||||
username
|
||||
}
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
# Write your query or mutation here
|
||||
query RefreshToken {
|
||||
refreshToken {
|
||||
token
|
||||
}
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
query {
|
||||
version {
|
||||
current
|
||||
latest
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue