feat: create trpc router & service for apps
This commit is contained in:
parent
3e67758d86
commit
fa8f178433
20 changed files with 2245 additions and 100 deletions
|
@ -124,10 +124,14 @@ services:
|
|||
- tipi_main_network
|
||||
volumes:
|
||||
- ${PWD}/packages/dashboard/src:/dashboard/src
|
||||
- ${PWD}/packages/dashboard/server.ts:/dashboard/server.ts
|
||||
# - /dashboard/node_modules
|
||||
# - /dashboard/.next
|
||||
- ${PWD}/state:/runtipi/state
|
||||
- ${PWD}/repos:/runtipi/repos:ro
|
||||
- ${PWD}/apps:/runtipi/apps
|
||||
- ${PWD}/logs:/app/logs
|
||||
- ${STORAGE_PATH}:/app/storage
|
||||
labels:
|
||||
traefik.enable: true
|
||||
# Web
|
||||
|
|
|
@ -121,7 +121,10 @@ services:
|
|||
REDIS_HOST: ${REDIS_HOST}
|
||||
volumes:
|
||||
- ${PWD}/state:/runtipi/state
|
||||
- ${PWD}/repos:/runtipi/repos:ro
|
||||
- ${PWD}/apps:/runtipi/apps
|
||||
- ${PWD}/logs:/app/logs
|
||||
- ${STORAGE_PATH}:/app/storage
|
||||
labels:
|
||||
traefik.enable: true
|
||||
# Web
|
||||
|
|
|
@ -126,7 +126,10 @@ services:
|
|||
REDIS_HOST: ${REDIS_HOST}
|
||||
volumes:
|
||||
- ${PWD}/state:/runtipi/state
|
||||
- ${PWD}/repos:/runtipi/repos:ro
|
||||
- ${PWD}/apps:/runtipi/apps
|
||||
- ${PWD}/logs:/app/logs
|
||||
- ${STORAGE_PATH}:/app/storage
|
||||
labels:
|
||||
traefik.enable: true
|
||||
|
||||
|
|
|
@ -122,7 +122,10 @@ services:
|
|||
REDIS_HOST: ${REDIS_HOST}
|
||||
volumes:
|
||||
- ${PWD}/state:/runtipi/state
|
||||
- ${PWD}/repos:/runtipi/repos:ro
|
||||
- ${PWD}/apps:/runtipi/apps
|
||||
- ${PWD}/logs:/app/logs
|
||||
- ${STORAGE_PATH}:/app/storage
|
||||
labels:
|
||||
traefik.enable: true
|
||||
# Web
|
||||
|
|
|
@ -3,3 +3,4 @@ POSTGRES_DBNAME=postgres
|
|||
POSTGRES_USERNAME=postgres
|
||||
POSTGRES_PASSWORD=postgres
|
||||
POSTGRES_PORT=5433
|
||||
APPS_REPO_ID=repo-id
|
||||
|
|
|
@ -1,47 +1,47 @@
|
|||
/* eslint-disable no-console */
|
||||
import path from 'path';
|
||||
import pg from 'pg';
|
||||
import { migrate } from '@runtipi/postgres-migrations';
|
||||
import { Logger } from './src/server/core/Logger';
|
||||
|
||||
export const runPostgresMigrations = async () => {
|
||||
console.log('Starting database migration');
|
||||
export const runPostgresMigrations = async (dbName?: string) => {
|
||||
Logger.info('Starting database migration');
|
||||
|
||||
const { POSTGRES_HOST, POSTGRES_DBNAME, POSTGRES_USERNAME, POSTGRES_PASSWORD, POSTGRES_PORT = 5432 } = process.env;
|
||||
|
||||
console.log('Connecting to database', POSTGRES_DBNAME, 'on', POSTGRES_HOST, 'as', POSTGRES_USERNAME, 'on port', POSTGRES_PORT);
|
||||
Logger.info('Connecting to database', POSTGRES_DBNAME, 'on', POSTGRES_HOST, 'as', POSTGRES_USERNAME, 'on port', POSTGRES_PORT);
|
||||
|
||||
const client = new pg.Client({
|
||||
user: POSTGRES_USERNAME,
|
||||
host: POSTGRES_HOST,
|
||||
database: POSTGRES_DBNAME,
|
||||
database: dbName || POSTGRES_DBNAME,
|
||||
password: POSTGRES_PASSWORD,
|
||||
port: Number(POSTGRES_PORT),
|
||||
});
|
||||
await client.connect();
|
||||
|
||||
console.log('Client connected');
|
||||
Logger.info('Client connected');
|
||||
|
||||
try {
|
||||
const { rows } = await client.query('SELECT * FROM migrations');
|
||||
// if rows contains a migration with name 'Initial1657299198975' (legacy typeorm) delete table migrations. As all migrations are idempotent we can safely delete the table and start over.
|
||||
if (rows.find((row) => row.name === 'Initial1657299198975')) {
|
||||
console.log('Found legacy migration. Deleting table migrations');
|
||||
Logger.info('Found legacy migration. Deleting table migrations');
|
||||
await client.query('DROP TABLE migrations');
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Migrations table not found, creating it');
|
||||
Logger.info('Migrations table not found, creating it');
|
||||
}
|
||||
|
||||
console.log('Running migrations');
|
||||
Logger.info('Running migrations');
|
||||
try {
|
||||
await migrate({ client }, path.join(__dirname, 'migrations'), { skipCreateMigrationTable: true });
|
||||
} catch (e) {
|
||||
console.log('Error running migrations. Dropping table migrations and trying again');
|
||||
Logger.error('Error running migrations. Dropping table migrations and trying again');
|
||||
await client.query('DROP TABLE migrations');
|
||||
await migrate({ client }, path.join(__dirname, 'migrations'), { skipCreateMigrationTable: true });
|
||||
}
|
||||
|
||||
console.log('Migration complete');
|
||||
Logger.info('Migration complete');
|
||||
await client.end();
|
||||
};
|
||||
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import * as trpcNext from '@trpc/server/adapters/next';
|
||||
import { createContext } from '../../../server/context';
|
||||
import { appRouter } from '../../../server/routers/_app';
|
||||
import { mainRouter } from '../../../server/routers/_app';
|
||||
|
||||
// export API handler
|
||||
export default trpcNext.createNextApiHandler({
|
||||
router: appRouter,
|
||||
router: mainRouter,
|
||||
createContext,
|
||||
});
|
||||
|
|
|
@ -9,3 +9,29 @@ export const readJsonFile = (path: string): unknown | null => {
|
|||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const readFile = (path: string): string => {
|
||||
try {
|
||||
return fs.readFileSync(path).toString();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
export const readdirSync = (path: string): string[] => fs.readdirSync(path);
|
||||
|
||||
export const fileExists = (path: string): boolean => fs.existsSync(path);
|
||||
|
||||
export const writeFile = (path: string, data: string) => fs.writeFileSync(path, data);
|
||||
|
||||
export const createFolder = (path: string) => {
|
||||
if (!fileExists(path)) {
|
||||
fs.mkdirSync(path, { recursive: true });
|
||||
}
|
||||
};
|
||||
export const deleteFolder = (path: string) => fs.rmSync(path, { recursive: true });
|
||||
|
||||
export const getSeed = () => {
|
||||
const seed = readFile('/runtipi/state/seed');
|
||||
return seed.toString();
|
||||
};
|
||||
|
|
|
@ -1,14 +1,17 @@
|
|||
import { inferRouterInputs, inferRouterOutputs } from '@trpc/server';
|
||||
import { router } from '../trpc';
|
||||
import { appRouter } from './app/app.router';
|
||||
import { authRouter } from './auth/auth.router';
|
||||
import { systemRouter } from './system/system.router';
|
||||
|
||||
export const appRouter = router({
|
||||
export const mainRouter = router({
|
||||
system: systemRouter,
|
||||
auth: authRouter,
|
||||
app: appRouter,
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
export type AppRouter = typeof appRouter;
|
||||
export type AppRouter = typeof mainRouter;
|
||||
|
||||
export type RouterInput = inferRouterInputs<AppRouter>;
|
||||
export type RouterOutput = inferRouterOutputs<AppRouter>;
|
||||
|
|
27
packages/dashboard/src/server/routers/app/app.router.ts
Normal file
27
packages/dashboard/src/server/routers/app/app.router.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { z } from 'zod';
|
||||
import { inferRouterOutputs } from '@trpc/server';
|
||||
import { AppServiceClass } from '../../services/apps/apps.service';
|
||||
import { router, protectedProcedure } from '../../trpc';
|
||||
import { prisma } from '../../db/client';
|
||||
|
||||
export type AppRouterOutput = inferRouterOutputs<typeof appRouter>;
|
||||
const AppService = new AppServiceClass(prisma);
|
||||
|
||||
const formSchema = z.object({}).catchall(z.any());
|
||||
|
||||
export const appRouter = router({
|
||||
getApp: protectedProcedure.input(z.object({ id: z.string() })).query(({ input }) => AppService.getApp(input.id)),
|
||||
startAllApp: protectedProcedure.mutation(AppService.startAllApps),
|
||||
startApp: protectedProcedure.input(z.object({ id: z.string() })).mutation(({ input }) => AppService.startApp(input.id)),
|
||||
installApp: protectedProcedure
|
||||
.input(z.object({ id: z.string(), form: formSchema, exposed: z.boolean().optional(), domain: z.string().optional() }))
|
||||
.mutation(({ input }) => AppService.installApp(input.id, input.form, input.exposed, input.domain)),
|
||||
updateAppConfig: protectedProcedure
|
||||
.input(z.object({ id: z.string(), form: formSchema, exposed: z.boolean().optional(), domain: z.string().optional() }))
|
||||
.mutation(({ input }) => AppService.updateAppConfig(input.id, input.form, input.exposed, input.domain)),
|
||||
stopApp: protectedProcedure.input(z.object({ id: z.string() })).mutation(({ input }) => AppService.stopApp(input.id)),
|
||||
uninstallApp: protectedProcedure.input(z.object({ id: z.string() })).mutation(({ input }) => AppService.uninstallApp(input.id)),
|
||||
updateApp: protectedProcedure.input(z.object({ id: z.string() })).mutation(({ input }) => AppService.updateApp(input.id)),
|
||||
installedApps: protectedProcedure.query(AppService.installedApps),
|
||||
listApps: protectedProcedure.query(() => AppServiceClass.listApps()),
|
||||
});
|
508
packages/dashboard/src/server/services/apps/apps.helpers.test.ts
Normal file
508
packages/dashboard/src/server/services/apps/apps.helpers.test.ts
Normal file
|
@ -0,0 +1,508 @@
|
|||
import fs from 'fs-extra';
|
||||
import { App, PrismaClient } from '@prisma/client';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { setConfig } from '../../core/TipiConfig';
|
||||
import { AppInfo, checkAppRequirements, checkEnvFile, ensureAppFolder, generateEnvFile, getAppInfo, getAvailableApps, getEnvMap, getUpdateInfo } from './apps.helpers';
|
||||
import { createApp, createAppConfig } from '../../tests/apps.factory';
|
||||
import { Logger } from '../../core/Logger';
|
||||
import { getTestDbClient } from '../../../../tests/server/db-connection';
|
||||
|
||||
let db: PrismaClient;
|
||||
const TEST_SUITE = 'appshelpers';
|
||||
|
||||
beforeAll(async () => {
|
||||
db = await getTestDbClient(TEST_SUITE);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.mock('fs-extra');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.app.deleteMany();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.app.deleteMany();
|
||||
await db.$disconnect();
|
||||
});
|
||||
|
||||
describe('checkAppRequirements', () => {
|
||||
let app1: AppInfo;
|
||||
|
||||
beforeEach(async () => {
|
||||
const app1create = await createApp({}, db);
|
||||
app1 = app1create.appInfo;
|
||||
// @ts-expect-error - Mocking fs
|
||||
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-expect-error - Mocking fs
|
||||
expect(e.message).toEqual('App notexisting has invalid config.json file');
|
||||
}
|
||||
});
|
||||
|
||||
it('Should throw if architecture is not supported', async () => {
|
||||
setConfig('architecture', 'arm64');
|
||||
const { MockFiles, appInfo } = await createApp({ supportedArchitectures: ['arm'] }, db);
|
||||
// @ts-expect-error - Mocking fs
|
||||
fs.__createMockFiles(MockFiles);
|
||||
|
||||
try {
|
||||
checkAppRequirements(appInfo.id);
|
||||
expect(true).toBe(false);
|
||||
} catch (e) {
|
||||
// @ts-expect-error - Test file
|
||||
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 }, db);
|
||||
app1 = app1create.appInfo;
|
||||
// @ts-expect-error - Mocking fs
|
||||
fs.__createMockFiles(app1create.MockFiles);
|
||||
});
|
||||
|
||||
it('should return a map of env vars', async () => {
|
||||
const envMap = getEnvMap(app1.id);
|
||||
|
||||
expect(envMap.get('TEST_FIELD')).toBe('test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test: checkEnvFile', () => {
|
||||
let app1: AppInfo;
|
||||
|
||||
beforeEach(async () => {
|
||||
const app1create = await createApp({ installed: true }, db);
|
||||
app1 = app1create.appInfo;
|
||||
// @ts-expect-error - Mocking fs
|
||||
fs.__createMockFiles(app1create.MockFiles);
|
||||
});
|
||||
|
||||
it('Should not throw if all required fields are present', async () => {
|
||||
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 {
|
||||
expect(true).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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({}, db);
|
||||
|
||||
// act
|
||||
try {
|
||||
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 {
|
||||
expect(true).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test: generateEnvFile', () => {
|
||||
let app1: AppInfo;
|
||||
let appEntity1: App;
|
||||
beforeEach(async () => {
|
||||
const app1create = await createApp({ installed: true }, db);
|
||||
app1 = app1create.appInfo;
|
||||
appEntity1 = app1create.appEntity;
|
||||
// @ts-expect-error - Mocking fs
|
||||
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 = 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 }, db);
|
||||
// @ts-expect-error - Mocking fs
|
||||
fs.__createMockFiles(MockFiles);
|
||||
|
||||
generateEnvFile(appEntity);
|
||||
|
||||
const envmap = 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 }, db);
|
||||
// @ts-expect-error - Mocking fs
|
||||
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 = 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 {
|
||||
expect(true).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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 {
|
||||
expect(true).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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 }, db);
|
||||
// @ts-expect-error - Mocking fs
|
||||
fs.__createMockFiles(MockFiles);
|
||||
|
||||
generateEnvFile(appEntity);
|
||||
|
||||
const envmap = 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 }, db);
|
||||
// @ts-expect-error - Mocking fs
|
||||
fs.__createMockFiles(MockFiles);
|
||||
|
||||
generateEnvFile(appEntity);
|
||||
|
||||
const envmap = 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() }, db);
|
||||
// @ts-expect-error - Mocking fs
|
||||
fs.__createMockFiles(MockFiles);
|
||||
|
||||
generateEnvFile(appEntity);
|
||||
|
||||
const envmap = 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 }, db);
|
||||
// @ts-expect-error - Mocking fs
|
||||
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 }, db);
|
||||
const app2create = await createApp({}, db);
|
||||
// @ts-expect-error - Mocking fs
|
||||
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 }, db);
|
||||
const { MockFiles: MockFiles2 } = await createApp({}, db);
|
||||
MockFiles1[`/runtipi/repos/repo-id/apps/${app1.id}/config.json`] = 'invalid json';
|
||||
// @ts-expect-error - Mocking fs
|
||||
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 }, db);
|
||||
app1 = app1create.appInfo;
|
||||
// @ts-expect-error - Mocking fs
|
||||
fs.__createMockFiles(app1create.MockFiles);
|
||||
});
|
||||
|
||||
it('Should return app info', async () => {
|
||||
const appInfo = 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 }, db);
|
||||
// @ts-expect-error - Mocking fs
|
||||
fs.__createMockFiles(MockFiles);
|
||||
|
||||
const newConfig = createAppConfig();
|
||||
|
||||
fs.writeFileSync(`/runtipi/apps/${appInfo.id}/config.json`, JSON.stringify(newConfig));
|
||||
|
||||
const app = 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 }, db);
|
||||
// @ts-expect-error - Mocking fs
|
||||
fs.__createMockFiles(MockFiles);
|
||||
|
||||
const newConfig = createAppConfig();
|
||||
|
||||
fs.writeFileSync(`/runtipi/repos/repo-id/apps/${appInfo.id}/config.json`, JSON.stringify(newConfig));
|
||||
|
||||
const app = 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 }, db);
|
||||
// @ts-expect-error - Mocking fs
|
||||
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 = 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 }, db);
|
||||
// @ts-expect-error - Mocking fs
|
||||
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 {
|
||||
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 = getAppInfo(faker.random.word());
|
||||
|
||||
expect(app).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUpdateInfo', () => {
|
||||
let app1: AppInfo;
|
||||
beforeEach(async () => {
|
||||
const app1create = await createApp({ installed: true }, db);
|
||||
app1 = app1create.appInfo;
|
||||
// @ts-expect-error - Mocking fs
|
||||
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 }, db);
|
||||
MockFiles[`/runtipi/repos/repo-id/apps/${appInfo.id}/config.json`] = 'invalid json';
|
||||
// @ts-expect-error - Mocking fs
|
||||
fs.__createMockFiles(MockFiles);
|
||||
|
||||
const updateInfo = await getUpdateInfo(appInfo.id, 1);
|
||||
|
||||
expect(updateInfo).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null if version is not provided', async () => {
|
||||
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-expect-error - Mocking fs
|
||||
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-expect-error - Mocking fs
|
||||
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-expect-error - Mocking fs
|
||||
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-expect-error - Mocking fs
|
||||
fs.__createMockFiles(mockFiles);
|
||||
|
||||
// Act
|
||||
ensureAppFolder('test');
|
||||
|
||||
// Assert
|
||||
const files = fs.readdirSync('/runtipi/apps/test');
|
||||
expect(files).toEqual([randomFileName]);
|
||||
});
|
||||
});
|
339
packages/dashboard/src/server/services/apps/apps.helpers.ts
Normal file
339
packages/dashboard/src/server/services/apps/apps.helpers.ts
Normal file
|
@ -0,0 +1,339 @@
|
|||
import crypto from 'crypto';
|
||||
import fs from 'fs-extra';
|
||||
import { z } from 'zod';
|
||||
import { App } from '@prisma/client';
|
||||
import { deleteFolder, fileExists, getSeed, readdirSync, readFile, readJsonFile, writeFile } from '../../common/fs.helpers';
|
||||
import { APP_CATEGORIES, FIELD_TYPES } from './apps.types';
|
||||
import { getConfig } from '../../core/TipiConfig';
|
||||
import { Logger } from '../../core/Logger';
|
||||
import { notEmpty } from '../../common/typescript.helpers';
|
||||
import { ARCHITECTURES } from '../../core/TipiConfig/TipiConfig';
|
||||
|
||||
const formFieldSchema = z.object({
|
||||
type: z.nativeEnum(FIELD_TYPES),
|
||||
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(APP_CATEGORIES).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(ARCHITECTURES).array().optional(),
|
||||
});
|
||||
|
||||
export type AppInfo = z.infer<typeof appInfoSchema>;
|
||||
export type FormField = z.infer<typeof formFieldSchema>;
|
||||
|
||||
/**
|
||||
* This function checks the requirements for the app with the provided name.
|
||||
* It reads the config.json file for the app, parses it,
|
||||
* and checks if the architecture of the current system is supported by the app.
|
||||
* If the config.json file is invalid, it throws an error.
|
||||
* If the architecture is not supported, it throws an error.
|
||||
*
|
||||
* @param {string} appName - The name of the app.
|
||||
* @throws Will throw an error if the app has an invalid config.json file or if the current system architecture is not supported by the app.
|
||||
* @returns {AppInfo} - parsed app config data
|
||||
*/
|
||||
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;
|
||||
};
|
||||
|
||||
/**
|
||||
* This function reads the env file for the app with the provided name and returns a Map containing the key-value pairs of the environment variables.
|
||||
* It reads the file, splits it into individual environment variables, and stores them in a Map, with the environment variable name as the key and its value as the value.
|
||||
*
|
||||
* @param {string} appName - The name of the app.
|
||||
* @returns {Map<string, string>} - A Map containing the key-value pairs of the environment variables.
|
||||
*/
|
||||
export const getEnvMap = (appName: 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('=');
|
||||
if (key && value) envVarsMap.set(key, value);
|
||||
});
|
||||
|
||||
return envVarsMap;
|
||||
};
|
||||
|
||||
/**
|
||||
* This function checks if the env file for the app with the provided name is valid.
|
||||
* It reads the config.json file for the app, parses it,
|
||||
* and uses the app's form fields to check if all required fields are present in the env file.
|
||||
* If the config.json file is invalid, it throws an error.
|
||||
* If a required variable is missing in the env file, it throws an error.
|
||||
*
|
||||
* @param {string} appName - The name of the app.
|
||||
* @throws Will throw an error if the app has an invalid config.json file or if a required variable is missing in the env file.
|
||||
*/
|
||||
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');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* This function generates a random string of the provided length by using the SHA-256 hash algorithm.
|
||||
* It takes the provided name and a seed value, concatenates them, and uses them as input for the hash algorithm.
|
||||
* It then returns a substring of the resulting hash of the provided length.
|
||||
*
|
||||
* @param {string} name - A name used as input for the hash algorithm.
|
||||
* @param {number} length - The desired length of the random string.
|
||||
* @returns {string} - A random string of the provided length.
|
||||
*/
|
||||
const getEntropy = (name: string, length: number) => {
|
||||
const hash = crypto.createHash('sha256');
|
||||
hash.update(name + getSeed());
|
||||
return hash.digest('hex').substring(0, length);
|
||||
};
|
||||
|
||||
/**
|
||||
* This function takes an input of unknown type, checks if it is an object and not null,
|
||||
* and returns it as a record of unknown values, if it is not an object or is null, returns an empty object.
|
||||
*
|
||||
* @param {unknown} json - The input of unknown type.
|
||||
* @returns {Record<string, unknown>} - The input as a record of unknown values, or an empty object if the input is not an object or is null.
|
||||
*/
|
||||
const castAppConfig = (json: unknown): Record<string, unknown> => {
|
||||
if (typeof json !== 'object' || json === null) {
|
||||
return {};
|
||||
}
|
||||
return json as Record<string, unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
* This function generates an env file for the provided app.
|
||||
* It reads the config.json file for the app, parses it,
|
||||
* and uses the app's form fields and domain to generate the env file
|
||||
* if the app is exposed and has a domain set, it adds the domain to the env file,
|
||||
* otherwise, it adds the internal IP address to the env file
|
||||
* It also creates the app-data folder for the app if it does not exist
|
||||
*
|
||||
* @param {App} app - The app for which the env file is generated.
|
||||
* @throws Will throw an error if the app has an invalid config.json file or if a required variable is missing.
|
||||
*/
|
||||
export const generateEnvFile = (app: App) => {
|
||||
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 = castAppConfig(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);
|
||||
};
|
||||
|
||||
/**
|
||||
This function reads the apps directory and skips certain system files, then reads the config.json and metadata/description.md files for each app,
|
||||
parses the config file, filters out any apps that are not available and returns an array of app information.
|
||||
If the config.json file is invalid, it logs an error message.
|
||||
|
||||
@returns {Promise<AppInfo[]>} - Returns a promise that resolves with an array of available apps' information.
|
||||
*/
|
||||
export const getAvailableApps = async () => {
|
||||
const appsDir = readdirSync(`/runtipi/repos/${getConfig().appsRepoId}/apps`);
|
||||
|
||||
const skippedFiles = ['__tests__', 'docker-compose.common.yml', 'schema.json', '.DS_Store'];
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
/**
|
||||
* This function reads the config.json and metadata/description.md files for the app with the provided id,
|
||||
* parses the config file and returns an object with app information.
|
||||
* It checks if the app is installed or not and looks for the config.json file in the appropriate directory.
|
||||
* If the config.json file is invalid, it returns null.
|
||||
* If an error occurs during the process, it logs the error message and throws an error.
|
||||
*
|
||||
* @param {string} id - The app id.
|
||||
* @param {AppStatus} [status] - The app status.
|
||||
* @returns {AppInfo | null} - Returns an object with app information or null if the app is not found.
|
||||
*/
|
||||
export const getAppInfo = (id: string, status?: App['status']) => {
|
||||
try {
|
||||
// Check if app is installed
|
||||
const installed = typeof status !== 'undefined' && status !== '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}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* This function returns an object containing information about the updates available for the app with the provided id.
|
||||
* It checks if the app is installed or not and looks for the config.json file in the appropriate directory.
|
||||
* If the config.json file is invalid, it returns null.
|
||||
* If the app is not found, it returns null.
|
||||
*
|
||||
* @param {string} id - The app id.
|
||||
* @param {number} [version] - The current version of the app.
|
||||
* @returns {Promise<{current: number, latest: number, dockerVersion: string} | null>} - Returns an object containing information about the updates available for the app or null if the app is not found or has an invalid config.json file.
|
||||
*/
|
||||
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;
|
||||
};
|
||||
|
||||
/**
|
||||
* This function ensures that the app folder for the app with the provided name exists.
|
||||
* If the cleanup parameter is set to true, it deletes the app folder if it exists.
|
||||
* If the app folder does not exist, it copies the app folder from the apps repository.
|
||||
*
|
||||
* @param {string} appName - The name of the app.
|
||||
* @param {boolean} [cleanup=false] - A flag indicating whether to cleanup the app folder before ensuring its existence.
|
||||
* @throws Will throw an error if the app folder cannot be copied from the repository
|
||||
*/
|
||||
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}`);
|
||||
}
|
||||
};
|
648
packages/dashboard/src/server/services/apps/apps.service.test.ts
Normal file
648
packages/dashboard/src/server/services/apps/apps.service.test.ts
Normal file
|
@ -0,0 +1,648 @@
|
|||
import fs from 'fs-extra';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { AppServiceClass } from './apps.service';
|
||||
import { EventDispatcher, EVENT_TYPES } from '../../core/EventDispatcher';
|
||||
import { AppInfo, getEnvMap } from './apps.helpers';
|
||||
import { createApp } from '../../tests/apps.factory';
|
||||
import { APP_STATUS } from './apps.types';
|
||||
import { setConfig } from '../../core/TipiConfig';
|
||||
import { getTestDbClient } from '../../../../tests/server/db-connection';
|
||||
|
||||
let db: PrismaClient;
|
||||
let AppsService: AppServiceClass;
|
||||
const TEST_SUITE = 'appsservice';
|
||||
|
||||
beforeAll(async () => {
|
||||
db = await getTestDbClient(TEST_SUITE);
|
||||
AppsService = new AppServiceClass(db);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.mock('fs-extra');
|
||||
await db.app.deleteMany();
|
||||
EventDispatcher.dispatchEventAsync = jest.fn().mockResolvedValue({ success: true });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.app.deleteMany();
|
||||
await db.$disconnect();
|
||||
});
|
||||
|
||||
describe('Install app', () => {
|
||||
let app1: AppInfo;
|
||||
|
||||
beforeEach(async () => {
|
||||
const { MockFiles, appInfo } = await createApp({}, db);
|
||||
app1 = appInfo;
|
||||
// @ts-expect-error - Mocking fs
|
||||
fs.__createMockFiles(MockFiles);
|
||||
});
|
||||
|
||||
it('Should correctly generate env file for app', async () => {
|
||||
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 db.app.findUnique({ where: { id: app1.id } });
|
||||
|
||||
expect(app).toBeDefined();
|
||||
expect(app?.id).toBe(app1.id);
|
||||
expect(app?.config).toStrictEqual({ TEST_FIELD: 'test' });
|
||||
expect(app?.status).toBe(APP_STATUS.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([EVENT_TYPES.APP, ['install', app1.id]]);
|
||||
expect(spy.mock.calls[1]).toEqual([EVENT_TYPES.APP, ['start', app1.id]]);
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('Should delete app if install script fails', async () => {
|
||||
// Arrange
|
||||
EventDispatcher.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 db?.app.findUnique({ 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 }, db);
|
||||
// @ts-expect-error - Mocking fs
|
||||
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({}, db);
|
||||
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-expect-error - Mocking fs
|
||||
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 }, db);
|
||||
// @ts-expect-error - Mocking fs
|
||||
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 }, db);
|
||||
const app3 = await createApp({ exposable: true }, db);
|
||||
// @ts-expect-error - Mocking fs
|
||||
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', 'amd64');
|
||||
const { MockFiles, appInfo } = await createApp({ supportedArchitectures: ['arm'] }, db);
|
||||
// @ts-expect-error - Mocking fs
|
||||
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', 'arm');
|
||||
const { MockFiles, appInfo } = await createApp({ supportedArchitectures: ['arm', 'amd64'] }, db);
|
||||
// @ts-expect-error - Mocking fs
|
||||
fs.__createMockFiles(MockFiles);
|
||||
|
||||
await AppsService.installApp(appInfo.id, { TEST_FIELD: 'test' });
|
||||
const app = await db.app.findUnique({ where: { id: appInfo.id } });
|
||||
|
||||
expect(app).toBeDefined();
|
||||
});
|
||||
|
||||
it('Can install if no architecture is specified', async () => {
|
||||
setConfig('architecture', 'arm');
|
||||
const { MockFiles, appInfo } = await createApp({ supportedArchitectures: undefined }, db);
|
||||
// @ts-expect-error - Mocking fs
|
||||
fs.__createMockFiles(MockFiles);
|
||||
|
||||
await AppsService.installApp(appInfo.id, { TEST_FIELD: 'test' });
|
||||
const app = await db.app.findUnique({ where: { id: appInfo.id } });
|
||||
|
||||
expect(app).toBeDefined();
|
||||
});
|
||||
|
||||
it('Should throw if config.json is not valid', async () => {
|
||||
// arrange
|
||||
const { MockFiles, appInfo } = await createApp({}, db);
|
||||
MockFiles[`/runtipi/repos/repo-id/apps/${appInfo.id}/config.json`] = 'test';
|
||||
// @ts-expect-error - Mocking fs
|
||||
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({}, db);
|
||||
MockFiles[`/runtipi/apps/${appInfo.id}/config.json`] = 'test';
|
||||
// @ts-expect-error - Mocking fs
|
||||
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 }, db);
|
||||
app1 = app1create.appInfo;
|
||||
// @ts-expect-error - Mocking fs
|
||||
fs.__createMockFiles(Object.assign(app1create.MockFiles));
|
||||
});
|
||||
|
||||
it('App should be installed by default', async () => {
|
||||
// Act
|
||||
const app = await db.app.findUnique({ where: { id: app1.id } });
|
||||
|
||||
// Assert
|
||||
expect(app).toBeDefined();
|
||||
expect(app?.id).toBe(app1.id);
|
||||
expect(app?.status).toBe(APP_STATUS.RUNNING);
|
||||
});
|
||||
|
||||
it('Should correctly remove app from database', async () => {
|
||||
// Act
|
||||
await AppsService.uninstallApp(app1.id);
|
||||
const app = await db.app.findUnique({ 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([EVENT_TYPES.APP, ['stop', app1.id]]);
|
||||
expect(spy.mock.calls[1]).toEqual([EVENT_TYPES.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.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'test' });
|
||||
await db.app.update({ where: { id: app1.id }, data: { status: 'updating' } });
|
||||
|
||||
// Act & Assert
|
||||
await expect(AppsService.uninstallApp(app1.id)).rejects.toThrow(`App ${app1.id} failed to uninstall\nstdout: test`);
|
||||
const app = await db.app.findUnique({ where: { id: app1.id } });
|
||||
expect(app?.status).toBe(APP_STATUS.STOPPED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Start app', () => {
|
||||
let app1: AppInfo;
|
||||
|
||||
beforeEach(async () => {
|
||||
const app1create = await createApp({ installed: true }, db);
|
||||
app1 = app1create.appInfo;
|
||||
// @ts-expect-error - Mocking fs
|
||||
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([EVENT_TYPES.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.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 db.app.findUnique({ where: { id: app1.id } });
|
||||
expect(app?.status).toBe(APP_STATUS.STOPPED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Stop app', () => {
|
||||
let app1: AppInfo;
|
||||
|
||||
beforeEach(async () => {
|
||||
const app1create = await createApp({ installed: true }, db);
|
||||
app1 = app1create.appInfo;
|
||||
// @ts-expect-error - Mocking fs
|
||||
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([EVENT_TYPES.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.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 db.app.findUnique({ where: { id: app1.id } });
|
||||
expect(app?.status).toBe(APP_STATUS.RUNNING);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Update app config', () => {
|
||||
let app1: AppInfo;
|
||||
|
||||
beforeEach(async () => {
|
||||
const app1create = await createApp({ installed: true }, db);
|
||||
app1 = app1create.appInfo;
|
||||
// @ts-expect-error - Mocking fs
|
||||
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 }, db);
|
||||
// @ts-expect-error - Mocking fs
|
||||
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 }, db);
|
||||
const app3 = await createApp({ exposable: true, installed: true }, db);
|
||||
// @ts-expect-error - Mocking fs
|
||||
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 }, db);
|
||||
// @ts-expect-error - Mocking fs
|
||||
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 }, db);
|
||||
MockFiles[`/runtipi/apps/${appInfo.id}/config.json`] = 'invalid json';
|
||||
|
||||
// @ts-expect-error - Mocking fs
|
||||
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 }, db);
|
||||
app1 = app1create.appInfo;
|
||||
// @ts-expect-error - Mocking fs
|
||||
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(APP_STATUS.RUNNING);
|
||||
});
|
||||
|
||||
it('Should return default values if app is not installed', async () => {
|
||||
const { appInfo, MockFiles } = await createApp({ installed: false }, db);
|
||||
// @ts-expect-error - Mocking fs
|
||||
fs.__createMockFiles(Object.assign(MockFiles));
|
||||
const appconfig = await AppsService.getApp(appInfo.id);
|
||||
|
||||
expect(appconfig).toBeDefined();
|
||||
expect(appconfig.id).toBe(appInfo.id);
|
||||
expect(appconfig.config).toStrictEqual({});
|
||||
expect(appconfig.status).toBe(APP_STATUS.MISSING);
|
||||
});
|
||||
});
|
||||
|
||||
describe('List apps', () => {
|
||||
let app1: AppInfo;
|
||||
let app2: AppInfo;
|
||||
|
||||
beforeEach(async () => {
|
||||
const app1create = await createApp({ installed: true }, db);
|
||||
const app2create = await createApp({}, db);
|
||||
app1 = app1create.appInfo;
|
||||
app2 = app2create.appInfo;
|
||||
// @ts-expect-error - Mocking fs
|
||||
fs.__createMockFiles(Object.assign(app1create.MockFiles, app2create.MockFiles));
|
||||
});
|
||||
|
||||
it('Should correctly list apps sorted by name', async () => {
|
||||
const { apps } = await AppServiceClass.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', 'arm64');
|
||||
const app3 = await createApp({ supportedArchitectures: ['arm'] }, db);
|
||||
// @ts-expect-error - Mocking fs
|
||||
fs.__createMockFiles(Object.assign(app3.MockFiles));
|
||||
|
||||
// Act
|
||||
const { apps } = await AppServiceClass.listApps();
|
||||
|
||||
// Assert
|
||||
expect(apps).toBeDefined();
|
||||
expect(apps.length).toBe(0);
|
||||
});
|
||||
|
||||
it('Should list apps that have supportedArchitectures and are supported', async () => {
|
||||
// Arrange
|
||||
setConfig('architecture', 'arm');
|
||||
const app3 = await createApp({ supportedArchitectures: ['arm'] }, db);
|
||||
// @ts-expect-error - Mocking fs
|
||||
fs.__createMockFiles(Object.assign(app3.MockFiles));
|
||||
// Act
|
||||
const { apps } = await AppServiceClass.listApps();
|
||||
|
||||
// Assert
|
||||
expect(apps).toBeDefined();
|
||||
expect(apps.length).toBe(1);
|
||||
});
|
||||
|
||||
it('Should list apps that have no supportedArchitectures specified', async () => {
|
||||
// Arrange
|
||||
setConfig('architecture', 'arm');
|
||||
const app3 = await createApp({ supportedArchitectures: undefined }, db);
|
||||
// @ts-expect-error - Mocking fs
|
||||
fs.__createMockFiles(Object.assign(app3.MockFiles));
|
||||
|
||||
// Act
|
||||
const { apps } = await AppServiceClass.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({}, db);
|
||||
const { MockFiles: mockApp2 } = await createApp({}, db);
|
||||
const MockFiles = Object.assign(mockApp1, mockApp2);
|
||||
MockFiles[`/runtipi/repos/repo-id/apps/${appInfo.id}/config.json`] = 'invalid json';
|
||||
// @ts-expect-error - Mocking fs
|
||||
fs.__createMockFiles(MockFiles);
|
||||
|
||||
// Act
|
||||
const { apps } = await AppServiceClass.listApps();
|
||||
|
||||
// Assert
|
||||
expect(apps).toBeDefined();
|
||||
expect(apps.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skip('Start all apps', () => {
|
||||
it('Should correctly start all apps', async () => {
|
||||
// arrange
|
||||
const app1create = await createApp({ installed: true }, db);
|
||||
const app2create = await createApp({ installed: true }, db);
|
||||
const app1 = app1create.appInfo;
|
||||
const app2 = app2create.appInfo;
|
||||
const apps = [app1, app2].sort((a, b) => a.id.localeCompare(b.id));
|
||||
const spy = jest.spyOn(EventDispatcher, 'dispatchEventAsync');
|
||||
// @ts-expect-error - Mocking fs
|
||||
fs.__createMockFiles(Object.assign(app1create.MockFiles, app2create.MockFiles));
|
||||
|
||||
await AppsService.startAllApps();
|
||||
|
||||
expect(spy.mock.calls.length).toBe(2);
|
||||
|
||||
const expectedCalls = apps.map((app) => [EVENT_TYPES.APP, ['start', app.id]]);
|
||||
|
||||
expect(spy.mock.calls).toEqual(expect.arrayContaining(expectedCalls));
|
||||
});
|
||||
|
||||
it('Should not start apps which have not status RUNNING', async () => {
|
||||
// arrange
|
||||
const app1 = await createApp({ installed: true, status: 'running' }, db);
|
||||
const app2 = await createApp({ installed: true, status: 'running' }, db);
|
||||
const app3 = await createApp({ installed: true, status: 'stopped' }, db);
|
||||
const spy = jest.spyOn(EventDispatcher, 'dispatchEventAsync');
|
||||
// @ts-expect-error - Mocking fs
|
||||
fs.__createMockFiles(Object.assign(app1.MockFiles, app2.MockFiles, app3.MockFiles));
|
||||
|
||||
await AppsService.startAllApps();
|
||||
const apps = await db.app.findMany();
|
||||
|
||||
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
|
||||
await createApp({ installed: true }, db);
|
||||
await createApp({ installed: true }, db);
|
||||
EventDispatcher.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'error' });
|
||||
|
||||
// Act
|
||||
await AppsService.startAllApps();
|
||||
|
||||
const apps = await db.app.findMany();
|
||||
|
||||
// Assert
|
||||
expect(apps.length).toBe(2);
|
||||
expect(apps[0]?.status).toBe(APP_STATUS.STOPPED);
|
||||
expect(apps[1]?.status).toBe(APP_STATUS.STOPPED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Update app', () => {
|
||||
it('Should correctly update app', async () => {
|
||||
const app1create = await createApp({ installed: true }, db);
|
||||
const app1 = app1create.appInfo;
|
||||
// @ts-expect-error - Mocking fs
|
||||
fs.__createMockFiles(Object.assign(app1create.MockFiles));
|
||||
|
||||
await db.app.update({ where: { id: app1.id }, data: { 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(APP_STATUS.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
|
||||
const app1create = await createApp({ installed: true }, db);
|
||||
const app1 = app1create.appInfo;
|
||||
// @ts-expect-error - Mocking fs
|
||||
fs.__createMockFiles(Object.assign(app1create.MockFiles));
|
||||
EventDispatcher.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 db.app.findUnique({ where: { id: app1.id } });
|
||||
expect(app?.status).toBe(APP_STATUS.STOPPED);
|
||||
});
|
||||
});
|
373
packages/dashboard/src/server/services/apps/apps.service.ts
Normal file
373
packages/dashboard/src/server/services/apps/apps.service.ts
Normal file
|
@ -0,0 +1,373 @@
|
|||
import validator from 'validator';
|
||||
import { App, PrismaClient } from '@prisma/client';
|
||||
import { checkAppRequirements, checkEnvFile, generateEnvFile, getAvailableApps, ensureAppFolder, appInfoSchema, AppInfo, getAppInfo } from './apps.helpers';
|
||||
import { getConfig } from '../../core/TipiConfig';
|
||||
import { EventDispatcher } from '../../core/EventDispatcher';
|
||||
import { Logger } from '../../core/Logger';
|
||||
import { createFolder, readJsonFile } from '../../common/fs.helpers';
|
||||
|
||||
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);
|
||||
|
||||
export class AppServiceClass {
|
||||
private prisma;
|
||||
|
||||
constructor(p: PrismaClient) {
|
||||
this.prisma = p;
|
||||
}
|
||||
|
||||
/**
|
||||
* This function starts all apps that are in the 'running' status.
|
||||
* It finds all the running apps and starts them by regenerating the env file, checking the env file and dispatching the start event.
|
||||
* If the start event is successful, the app's status is updated to 'running', otherwise, it is updated to 'stopped'
|
||||
* If there is an error while starting the app, it logs the error and updates the app's status to 'stopped'.
|
||||
*
|
||||
* @returns {Promise<void>} - A promise that resolves when all apps are started.
|
||||
*/
|
||||
public async startAllApps() {
|
||||
const apps = await this.prisma.app.findMany({ where: { status: 'running' }, orderBy: { id: 'asc' } });
|
||||
|
||||
await Promise.all(
|
||||
apps.map(async (app) => {
|
||||
// Regenerate env file
|
||||
try {
|
||||
ensureAppFolder(app.id);
|
||||
generateEnvFile(app);
|
||||
checkEnvFile(app.id);
|
||||
|
||||
await this.prisma.app.update({ where: { id: app.id }, data: { status: 'starting' } });
|
||||
|
||||
EventDispatcher.dispatchEventAsync('app', ['start', app.id]).then(({ success }) => {
|
||||
if (success) {
|
||||
this.prisma.app.update({ where: { id: app.id }, data: { status: 'running' } }).then(() => {});
|
||||
} else {
|
||||
this.prisma.app.update({ where: { id: app.id }, data: { status: 'stopped' } }).then(() => {});
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
await this.prisma.app.update({ where: { id: app.id }, data: { status: 'stopped' } });
|
||||
Logger.error(e);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* This function starts an app specified by its appName, regenerates its environment file and checks for missing requirements.
|
||||
* It updates the app's status in the database to 'starting' and 'running' if the start process is successful, otherwise it updates the status to 'stopped'.
|
||||
*
|
||||
* @param {string} appName - The name of the app to start
|
||||
* @returns {Promise<App | null>} - Returns a promise that resolves with the updated app information.
|
||||
* @throws {Error} - If the app is not found or the start process fails.
|
||||
*/
|
||||
public startApp = async (appName: string) => {
|
||||
let app = await this.prisma.app.findUnique({ where: { id: appName } });
|
||||
|
||||
if (!app) {
|
||||
throw new Error(`App ${appName} not found`);
|
||||
}
|
||||
|
||||
ensureAppFolder(appName);
|
||||
// Regenerate env file
|
||||
generateEnvFile(app);
|
||||
checkEnvFile(appName);
|
||||
|
||||
await this.prisma.app.update({ where: { id: appName }, data: { status: 'starting' } });
|
||||
const { success, stdout } = await EventDispatcher.dispatchEventAsync('app', ['start', app.id]);
|
||||
|
||||
if (success) {
|
||||
await this.prisma.app.update({ where: { id: appName }, data: { status: 'running' } });
|
||||
} else {
|
||||
await this.prisma.app.update({ where: { id: appName }, data: { status: 'stopped' } });
|
||||
throw new Error(`App ${appName} failed to start\nstdout: ${stdout}`);
|
||||
}
|
||||
|
||||
app = await this.prisma.app.findUnique({ where: { id: appName } });
|
||||
|
||||
return app;
|
||||
};
|
||||
|
||||
/**
|
||||
* Installs an app and updates the status accordingly
|
||||
*
|
||||
* @param {string} id - The id of the app to be installed
|
||||
* @param {Record<string, string>} form - The form data submitted by the user
|
||||
* @param {boolean} [exposed] - A flag indicating if the app will be exposed to the internet
|
||||
* @param {string} [domain] - The domain name to expose the app to the internet, required if exposed is true
|
||||
* @returns {Promise<App | null>} Returns a promise that resolves to the installed app object
|
||||
*/
|
||||
public installApp = async (id: string, form: Record<string, string>, exposed?: boolean, domain?: string) => {
|
||||
let app = await this.prisma.app.findUnique({ where: { id } });
|
||||
|
||||
if (app) {
|
||||
await this.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 this.prisma.app.findMany({ where: { domain, exposed: true } });
|
||||
if (appsWithSameDomain.length > 0) {
|
||||
throw new Error(`Domain ${domain} already in use by app ${appsWithSameDomain[0]?.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
app = await this.prisma.app.create({ data: { id, status: 'installing', config: form, version: parsedAppInfo.data.tipi_version, exposed: exposed || false, domain } });
|
||||
|
||||
if (app) {
|
||||
// Create env file
|
||||
generateEnvFile(app);
|
||||
}
|
||||
|
||||
// Run script
|
||||
const { success, stdout } = await EventDispatcher.dispatchEventAsync('app', ['install', id]);
|
||||
|
||||
if (!success) {
|
||||
await this.prisma.app.delete({ where: { id } });
|
||||
throw new Error(`App ${id} failed to install\nstdout: ${stdout}`);
|
||||
}
|
||||
}
|
||||
|
||||
app = await this.prisma.app.update({ where: { id }, data: { status: 'running' } });
|
||||
|
||||
return app;
|
||||
};
|
||||
|
||||
/**
|
||||
* Lists available apps
|
||||
*
|
||||
* @returns {Promise<{apps: Array<AppInfo>, total: number }>} An object containing list of apps and total number of apps
|
||||
*/
|
||||
public static listApps = async () => {
|
||||
const apps = await getAvailableApps();
|
||||
const filteredApps = filterApps(apps);
|
||||
|
||||
return { apps: filteredApps, total: apps.length };
|
||||
};
|
||||
|
||||
/**
|
||||
* Update the configuration of an app
|
||||
*
|
||||
* @param {string} id - The ID of the app to update.
|
||||
* @param {object} form - The new configuration of the app.
|
||||
* @param {boolean} [exposed=false] - If the app should be exposed or not.
|
||||
* @param {string} [domain] - The domain for the app if exposed is true.
|
||||
* @returns {Promise<App | null>} The updated app
|
||||
*/
|
||||
public updateAppConfig = async (id: string, form: Record<string, string>, exposed?: boolean, domain?: string) => {
|
||||
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 this.prisma.app.findUnique({ 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 this.prisma.app.findMany({ where: { domain, exposed: true, id: { not: id } } });
|
||||
if (appsWithSameDomain.length > 0) {
|
||||
throw new Error(`Domain ${domain} already in use by app ${appsWithSameDomain[0]?.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
app = await this.prisma.app.update({ where: { id }, data: { config: form, exposed: exposed || false, domain } });
|
||||
|
||||
generateEnvFile(app);
|
||||
|
||||
return app;
|
||||
};
|
||||
|
||||
/**
|
||||
* Stops a running application by its id
|
||||
*
|
||||
* @param {string} id - The id of the application to stop
|
||||
* @returns {Promise<App>} - The stopped application
|
||||
* @throws {Error} - If the app cannot be found or if stopping the app failed
|
||||
*/
|
||||
public stopApp = async (id: string) => {
|
||||
let app = await this.prisma.app.findUnique({ where: { id } });
|
||||
|
||||
if (!app) {
|
||||
throw new Error(`App ${id} not found`);
|
||||
}
|
||||
|
||||
ensureAppFolder(id);
|
||||
generateEnvFile(app);
|
||||
|
||||
// Run script
|
||||
await this.prisma.app.update({ where: { id }, data: { status: 'stopping' } });
|
||||
|
||||
const { success, stdout } = await EventDispatcher.dispatchEventAsync('app', ['stop', id]);
|
||||
|
||||
if (success) {
|
||||
await this.prisma.app.update({ where: { id }, data: { status: 'stopped' } });
|
||||
} else {
|
||||
await this.prisma.app.update({ where: { id }, data: { status: 'running' } });
|
||||
throw new Error(`App ${id} failed to stop\nstdout: ${stdout}`);
|
||||
}
|
||||
|
||||
app = await this.prisma.app.findUnique({ where: { id } });
|
||||
|
||||
return app;
|
||||
};
|
||||
|
||||
/**
|
||||
* Uninstalls an app by stopping it, running the app's `uninstall` script, and removing its data
|
||||
*
|
||||
* @param {string} id - The id of the app to uninstall
|
||||
* @returns {Promise<{id: string, status: string, config: object}>} - An object containing the id of the uninstalled app, the status of the app ('missing'), and the config object
|
||||
* @throws {Error} - If the app is not found or if the app's `uninstall` script fails
|
||||
*/
|
||||
public uninstallApp = async (id: string) => {
|
||||
const app = await this.prisma.app.findUnique({ where: { id } });
|
||||
|
||||
if (!app) {
|
||||
throw new Error(`App ${id} not found`);
|
||||
}
|
||||
if (app.status === 'running') {
|
||||
await this.stopApp(id);
|
||||
}
|
||||
|
||||
ensureAppFolder(id);
|
||||
generateEnvFile(app);
|
||||
|
||||
await this.prisma.app.update({ where: { id }, data: { status: 'uninstalling' } });
|
||||
|
||||
const { success, stdout } = await EventDispatcher.dispatchEventAsync('app', ['uninstall', id]);
|
||||
|
||||
if (!success) {
|
||||
await this.prisma.app.update({ where: { id }, data: { status: 'stopped' } });
|
||||
throw new Error(`App ${id} failed to uninstall\nstdout: ${stdout}`);
|
||||
}
|
||||
|
||||
await this.prisma.app.delete({ where: { id } });
|
||||
|
||||
return { id, status: 'missing', config: {} };
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the app with the provided id. If the app is not found, it returns a default app object
|
||||
*
|
||||
* @param {string} id - The id of the app to retrieve
|
||||
* @returns {Promise<App>} - The app object
|
||||
*/
|
||||
public getApp = async (id: string) => {
|
||||
let app = await this.prisma.app.findUnique({ where: { id } });
|
||||
const info = getAppInfo(id, app?.status);
|
||||
const parsedInfo = appInfoSchema.safeParse(info);
|
||||
|
||||
if (parsedInfo.success) {
|
||||
if (!app) {
|
||||
app = { id, status: 'missing', config: {}, exposed: false, domain: '' } as App;
|
||||
}
|
||||
|
||||
return { ...app, info: { ...parsedInfo.data } };
|
||||
}
|
||||
|
||||
throw new Error(`App ${id} has invalid config.json`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates an app with the specified ID
|
||||
*
|
||||
* @param {string} id - ID of the app to update
|
||||
* @returns {Promise<App>} - An object representing the updated app
|
||||
* @throws {Error} - If the app is not found or if the update process fails.
|
||||
*/
|
||||
public updateApp = async (id: string) => {
|
||||
let app = await this.prisma.app.findUnique({ where: { id } });
|
||||
|
||||
if (!app) {
|
||||
throw new Error(`App ${id} not found`);
|
||||
}
|
||||
|
||||
ensureAppFolder(id);
|
||||
generateEnvFile(app);
|
||||
|
||||
await this.prisma.app.update({ where: { id }, data: { status: 'updating' } });
|
||||
|
||||
const { success, stdout } = await EventDispatcher.dispatchEventAsync('app', ['update', id]);
|
||||
|
||||
if (success) {
|
||||
const appInfo = readJsonFile(`/runtipi/apps/${id}/config.json`);
|
||||
const parsedAppInfo = appInfoSchema.parse(appInfo);
|
||||
|
||||
await this.prisma.app.update({ where: { id }, data: { status: 'running', version: parsedAppInfo.tipi_version } });
|
||||
} else {
|
||||
await this.prisma.app.update({ where: { id }, data: { status: 'stopped' } });
|
||||
throw new Error(`App ${id} failed to update\nstdout: ${stdout}`);
|
||||
}
|
||||
|
||||
app = await this.prisma.app.update({ where: { id }, data: { status: 'stopped' } });
|
||||
return app;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a list of all installed apps
|
||||
*
|
||||
* @returns {Promise<App[]>} - An array of app objects
|
||||
* @throws {Error} - If the app is not found or if the update process fails.
|
||||
*/
|
||||
public installedApps = async () => {
|
||||
const apps = await this.prisma.app.findMany();
|
||||
|
||||
return apps.map((app) => {
|
||||
const info = readJsonFile(`/runtipi/apps/${app.id}/config.json`);
|
||||
const parsedInfo = appInfoSchema.safeParse(info);
|
||||
|
||||
if (parsedInfo.success) {
|
||||
return { ...app, info: { ...parsedInfo.data } };
|
||||
}
|
||||
|
||||
throw new Error(`App ${app.id} has invalid config.json`);
|
||||
});
|
||||
};
|
||||
}
|
45
packages/dashboard/src/server/services/apps/apps.types.ts
Normal file
45
packages/dashboard/src/server/services/apps/apps.types.ts
Normal file
|
@ -0,0 +1,45 @@
|
|||
export const APP_STATUS = {
|
||||
INSTALLING: 'installing',
|
||||
MISSING: 'missing',
|
||||
RUNNING: 'running',
|
||||
STARTING: 'starting',
|
||||
STOPPED: 'stopped',
|
||||
STOPPING: 'stopping',
|
||||
UNINSTALLING: 'uninstalling',
|
||||
UPDATING: 'updating',
|
||||
} as const;
|
||||
|
||||
export type AppStatus = typeof APP_STATUS[keyof typeof APP_STATUS];
|
||||
|
||||
export const APP_CATEGORIES = {
|
||||
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',
|
||||
} as const;
|
||||
|
||||
export type AppCategory = typeof APP_CATEGORIES[keyof typeof APP_CATEGORIES];
|
||||
|
||||
export const FIELD_TYPES = {
|
||||
TEXT: 'text',
|
||||
PASSWORD: 'password',
|
||||
EMAIL: 'email',
|
||||
NUMBER: 'number',
|
||||
FQDN: 'fqdn',
|
||||
IP: 'ip',
|
||||
FQDNIP: 'fqdnip',
|
||||
URL: 'url',
|
||||
RANDOM: 'random',
|
||||
} as const;
|
||||
|
||||
export type FieldType = typeof FIELD_TYPES[keyof typeof FIELD_TYPES];
|
107
packages/dashboard/src/server/tests/apps.factory.ts
Normal file
107
packages/dashboard/src/server/tests/apps.factory.ts
Normal file
|
@ -0,0 +1,107 @@
|
|||
import { faker } from '@faker-js/faker';
|
||||
import { App, PrismaClient } from '@prisma/client';
|
||||
import { Architecture } from '../core/TipiConfig/TipiConfig';
|
||||
import { AppInfo, appInfoSchema } from '../services/apps/apps.helpers';
|
||||
import { APP_CATEGORIES } from '../services/apps/apps.types';
|
||||
|
||||
interface IProps {
|
||||
installed?: boolean;
|
||||
status?: App['status'];
|
||||
requiredPort?: number;
|
||||
randomField?: boolean;
|
||||
exposed?: boolean;
|
||||
domain?: string;
|
||||
exposable?: boolean;
|
||||
supportedArchitectures?: Architecture[];
|
||||
}
|
||||
|
||||
type CreateConfigParams = {
|
||||
id?: string;
|
||||
};
|
||||
|
||||
const createAppConfig = (props?: CreateConfigParams) =>
|
||||
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: [APP_CATEGORIES.AUTOMATION],
|
||||
});
|
||||
|
||||
const createApp = async (props: IProps, db?: PrismaClient) => {
|
||||
const { installed = false, status = 'running', randomField = false, exposed = false, domain = '', exposable = false, supportedArchitectures } = props;
|
||||
|
||||
const categories = Object.values(APP_CATEGORIES);
|
||||
|
||||
const randomId = faker.random.alphaNumeric(32);
|
||||
|
||||
const appInfo: AppInfo = {
|
||||
id: randomId,
|
||||
port: faker.datatype.number({ min: 3000, max: 5000 }),
|
||||
available: true,
|
||||
form_fields: [
|
||||
{
|
||||
type: '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 })]] as AppInfo['categories'],
|
||||
exposable,
|
||||
supported_architectures: supportedArchitectures,
|
||||
version: String(faker.datatype.number({ min: 1, max: 10 })),
|
||||
https: false,
|
||||
no_gui: false,
|
||||
};
|
||||
|
||||
if (randomField) {
|
||||
appInfo.form_fields?.push({
|
||||
required: false,
|
||||
type: '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: App = {} as App;
|
||||
if (installed && db) {
|
||||
appEntity = await db.app.create({
|
||||
data: {
|
||||
id: appInfo.id,
|
||||
config: { TEST_FIELD: 'test' },
|
||||
status,
|
||||
exposed,
|
||||
domain,
|
||||
version: 1,
|
||||
},
|
||||
});
|
||||
|
||||
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 };
|
30
packages/dashboard/tests/server/db-connection.ts
Normal file
30
packages/dashboard/tests/server/db-connection.ts
Normal file
|
@ -0,0 +1,30 @@
|
|||
import { PrismaClient } from '@prisma/client';
|
||||
import pg from 'pg';
|
||||
import { runPostgresMigrations } from '../../run-migration';
|
||||
import { getConfig } from '../../src/server/core/TipiConfig';
|
||||
|
||||
export const getTestDbClient = async (testsuite: string) => {
|
||||
const pgClient = new pg.Client({
|
||||
user: getConfig().postgresUsername,
|
||||
host: getConfig().postgresHost,
|
||||
database: getConfig().postgresDatabase,
|
||||
password: getConfig().postgresPassword,
|
||||
port: getConfig().postgresPort,
|
||||
});
|
||||
await pgClient.connect();
|
||||
|
||||
await pgClient.query(`DROP DATABASE IF EXISTS ${testsuite}`);
|
||||
await pgClient.query(`CREATE DATABASE ${testsuite}`);
|
||||
|
||||
await pgClient.end();
|
||||
|
||||
await runPostgresMigrations(testsuite);
|
||||
|
||||
return new PrismaClient({
|
||||
datasources: {
|
||||
db: {
|
||||
url: `postgresql://${getConfig().postgresUsername}:${getConfig().postgresPassword}@${getConfig().postgresHost}:${getConfig().postgresPort}/${testsuite}?connect_timeout=300`,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
|
@ -13,7 +13,7 @@ jest.mock('fs-extra');
|
|||
jest.mock('child_process');
|
||||
|
||||
let db: DataSource | null = null;
|
||||
const TEST_SUITE = 'appshelpers';
|
||||
const TEST_SUITE = 'appshelperslegacy';
|
||||
|
||||
beforeAll(async () => {
|
||||
db = await setupConnection(TEST_SUITE);
|
||||
|
|
|
@ -13,7 +13,7 @@ jest.mock('fs-extra');
|
|||
jest.mock('child_process');
|
||||
|
||||
let db: DataSource | null = null;
|
||||
const TEST_SUITE = 'appsservice';
|
||||
const TEST_SUITE = 'appsservicelegacy';
|
||||
|
||||
beforeAll(async () => {
|
||||
db = await setupConnection(TEST_SUITE);
|
||||
|
@ -309,7 +309,7 @@ describe('Start app', () => {
|
|||
});
|
||||
|
||||
it('Regenerate env file', async () => {
|
||||
fs.writeFile(`/app/storage/app-data/${app1.id}/app.env`, 'TEST=test\nAPP_PORT=3000', () => {});
|
||||
fs.writeFileSync(`/app/storage/app-data/${app1.id}/app.env`, 'TEST=test\nAPP_PORT=3000');
|
||||
|
||||
await AppsService.startApp(app1.id);
|
||||
|
||||
|
|
189
pnpm-lock.yaml
generated
189
pnpm-lock.yaml
generated
|
@ -30,15 +30,15 @@ importers:
|
|||
'@runtipi/postgres-migrations': ^5.3.0
|
||||
'@tabler/core': 1.0.0-beta16
|
||||
'@tabler/icons': ^1.109.0
|
||||
'@tanstack/react-query': ^4.20.4
|
||||
'@tanstack/react-query': ^4.24.4
|
||||
'@testing-library/dom': ^8.19.0
|
||||
'@testing-library/jest-dom': ^5.16.5
|
||||
'@testing-library/react': ^13.4.0
|
||||
'@testing-library/user-event': ^14.4.3
|
||||
'@trpc/client': ^10.7.0
|
||||
'@trpc/next': ^10.7.0
|
||||
'@trpc/react-query': ^10.7.0
|
||||
'@trpc/server': ^10.7.0
|
||||
'@trpc/next': ^10.9.1
|
||||
'@trpc/react-query': ^10.9.1
|
||||
'@trpc/server': ^10.9.1
|
||||
'@types/fs-extra': ^9.0.13
|
||||
'@types/isomorphic-fetch': ^0.0.36
|
||||
'@types/jest': ^29.2.4
|
||||
|
@ -63,6 +63,7 @@ importers:
|
|||
eslint-config-next: 13.1.1
|
||||
eslint-plugin-import: ^2.25.3
|
||||
eslint-plugin-jest: ^27.1.7
|
||||
eslint-plugin-jsdoc: ^39.6.9
|
||||
eslint-plugin-jsx-a11y: ^6.6.1
|
||||
eslint-plugin-react: ^7.31.10
|
||||
eslint-plugin-react-hooks: ^4.6.0
|
||||
|
@ -73,9 +74,10 @@ importers:
|
|||
jest: ^29.3.1
|
||||
jest-environment-jsdom: ^29.3.1
|
||||
jsonwebtoken: ^9.0.0
|
||||
msw: ^0.49.2
|
||||
msw: ^1.0.0
|
||||
next: 13.1.1
|
||||
next-router-mock: ^0.8.0
|
||||
nodemon: ^2.0.15
|
||||
pg: ^8.7.3
|
||||
prisma: ^4.8.0
|
||||
react: 18.2.0
|
||||
|
@ -109,11 +111,11 @@ importers:
|
|||
'@runtipi/postgres-migrations': 5.3.0
|
||||
'@tabler/core': 1.0.0-beta16_biqbaboplfbrettd7655fr4n2y
|
||||
'@tabler/icons': 1.116.1_biqbaboplfbrettd7655fr4n2y
|
||||
'@tanstack/react-query': 4.20.4_biqbaboplfbrettd7655fr4n2y
|
||||
'@trpc/client': 10.7.0_@trpc+server@10.7.0
|
||||
'@trpc/next': 10.7.0_gmaturtqmtlrknaw65ek5pmv2i
|
||||
'@trpc/react-query': 10.7.0_x4ie6nhblo2vtx2aafrgzlfqy4
|
||||
'@trpc/server': 10.7.0
|
||||
'@tanstack/react-query': 4.24.4_biqbaboplfbrettd7655fr4n2y
|
||||
'@trpc/client': 10.7.0_@trpc+server@10.9.1
|
||||
'@trpc/next': 10.9.1_3jqi55bejizrmk4tzcsm3yonw4
|
||||
'@trpc/react-query': 10.9.1_hv4rsvckahicenzgmmmurigzzu
|
||||
'@trpc/server': 10.9.1
|
||||
argon2: 0.29.1
|
||||
clsx: 1.1.1
|
||||
fs-extra: 10.1.0
|
||||
|
@ -176,13 +178,15 @@ importers:
|
|||
eslint-config-next: 13.1.1_lzzuuodtsqwxnvqeq4g4likcqa
|
||||
eslint-plugin-import: 2.26.0_smw3o7qjeokkcohbvp7rylsoqq
|
||||
eslint-plugin-jest: 27.1.7_q2j57hara4akamrxhqbo5gmcq4
|
||||
eslint-plugin-jsdoc: 39.6.9_eslint@8.30.0
|
||||
eslint-plugin-jsx-a11y: 6.6.1_eslint@8.30.0
|
||||
eslint-plugin-react: 7.31.11_eslint@8.30.0
|
||||
eslint-plugin-react-hooks: 4.6.0_eslint@8.30.0
|
||||
jest: 29.3.1_zfha7dvnw4nti6zkbsmhmn6xo4
|
||||
jest-environment-jsdom: 29.3.1
|
||||
msw: 0.49.2_typescript@4.9.4
|
||||
msw: 1.0.0_typescript@4.9.4
|
||||
next-router-mock: 0.8.0_next@13.1.1+react@18.2.0
|
||||
nodemon: 2.0.16
|
||||
prisma: 4.8.0
|
||||
ts-jest: 29.0.3_iyz3vhhlowkpp2xbqliblzwv3y
|
||||
ts-node: 10.9.1_awa2wsr5thmg3i7jqycphctjfq
|
||||
|
@ -1555,6 +1559,15 @@ packages:
|
|||
- typescript
|
||||
dev: true
|
||||
|
||||
/@es-joy/jsdoccomment/0.36.1:
|
||||
resolution: {integrity: sha512-922xqFsTpHs6D0BUiG4toiyPOMc8/jafnWKxz1KWgS4XzKPy2qXf1Pe6UFuNSCQqt6tOuhAWXBNuuyUhJmw9Vg==}
|
||||
engines: {node: ^14 || ^16 || ^17 || ^18 || ^19}
|
||||
dependencies:
|
||||
comment-parser: 1.3.1
|
||||
esquery: 1.4.0
|
||||
jsdoc-type-pratt-parser: 3.1.0
|
||||
dev: true
|
||||
|
||||
/@esbuild/android-arm/0.16.8:
|
||||
resolution: {integrity: sha512-r/qxYWkC3gY+Uq24wZacAUevGGb6d7d8VpyO8R0HGg31LXVi+eUr8XxHLCcmVzAjRjlZsZfzPelGpAKP/DafKg==}
|
||||
engines: {node: '>=12'}
|
||||
|
@ -3307,12 +3320,12 @@ packages:
|
|||
react-dom: 18.2.0_react@18.2.0
|
||||
dev: false
|
||||
|
||||
/@tanstack/query-core/4.20.4:
|
||||
resolution: {integrity: sha512-lhLtGVNJDsJ/DyZXrLzekDEywQqRVykgBqTmkv0La32a/RleILXy6JMLBb7UmS3QCatg/F/0N9/5b0i5j6IKcA==}
|
||||
/@tanstack/query-core/4.24.4:
|
||||
resolution: {integrity: sha512-9dqjv9eeB6VHN7lD3cLo16ZAjfjCsdXetSAD5+VyKqLUvcKTL0CklGQRJu+bWzdrS69R6Ea4UZo8obHYZnG6aA==}
|
||||
dev: false
|
||||
|
||||
/@tanstack/react-query/4.20.4_biqbaboplfbrettd7655fr4n2y:
|
||||
resolution: {integrity: sha512-SJRxx13k/csb9lXAJfycgVA1N/yU/h3bvRNWP0+aHMfMjmbyX82FdoAcckDBbOdEyAupvb0byelNHNeypCFSyA==}
|
||||
/@tanstack/react-query/4.24.4_biqbaboplfbrettd7655fr4n2y:
|
||||
resolution: {integrity: sha512-RpaS/3T/a3pHuZJbIAzAYRu+1nkp+/enr9hfRXDS/mojwx567UiMksoqW4wUFWlwIvWTXyhot2nbIipTKEg55Q==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
|
@ -3323,7 +3336,7 @@ packages:
|
|||
react-native:
|
||||
optional: true
|
||||
dependencies:
|
||||
'@tanstack/query-core': 4.20.4
|
||||
'@tanstack/query-core': 4.24.4
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0_react@18.2.0
|
||||
use-sync-external-store: 1.2.0_react@18.2.0
|
||||
|
@ -3386,53 +3399,53 @@ packages:
|
|||
engines: {node: '>= 10'}
|
||||
dev: true
|
||||
|
||||
/@trpc/client/10.7.0_@trpc+server@10.7.0:
|
||||
/@trpc/client/10.7.0_@trpc+server@10.9.1:
|
||||
resolution: {integrity: sha512-xCPc2hp37REJst6TXMkJvdc85CHvxpVY6YVdZIjl7uCYoQcrstwy8AWi7ElB3x7e19Cweke+9CAaUXKwAE1AKQ==}
|
||||
peerDependencies:
|
||||
'@trpc/server': 10.7.0
|
||||
dependencies:
|
||||
'@trpc/server': 10.7.0
|
||||
'@trpc/server': 10.9.1
|
||||
dev: false
|
||||
|
||||
/@trpc/next/10.7.0_gmaturtqmtlrknaw65ek5pmv2i:
|
||||
resolution: {integrity: sha512-ZLY4J9IgEHN2op0UM14NCwCDyodRUw8Pmxgnpr62+UMIhDll6uggb2an8Op87l9EeYdKhKe2VavDWphI8vFGgw==}
|
||||
/@trpc/next/10.9.1_3jqi55bejizrmk4tzcsm3yonw4:
|
||||
resolution: {integrity: sha512-4eYS+2B+TtYpymZBtyPPZG5ZWMEfKvZ1T6Q5sWGGFidBHW3FBjMiSpNmigrFfQpidiAEtygaPyBeJvM2DQtMCw==}
|
||||
peerDependencies:
|
||||
'@tanstack/react-query': ^4.3.8
|
||||
'@trpc/client': 10.7.0
|
||||
'@trpc/react-query': ^10.0.0-proxy-beta.21
|
||||
'@trpc/server': 10.7.0
|
||||
'@trpc/client': 10.9.1
|
||||
'@trpc/react-query': ^10.8.0
|
||||
'@trpc/server': 10.9.1
|
||||
next: '*'
|
||||
react: '>=16.8.0'
|
||||
react-dom: '>=16.8.0'
|
||||
dependencies:
|
||||
'@tanstack/react-query': 4.20.4_biqbaboplfbrettd7655fr4n2y
|
||||
'@trpc/client': 10.7.0_@trpc+server@10.7.0
|
||||
'@trpc/react-query': 10.7.0_x4ie6nhblo2vtx2aafrgzlfqy4
|
||||
'@trpc/server': 10.7.0
|
||||
'@tanstack/react-query': 4.24.4_biqbaboplfbrettd7655fr4n2y
|
||||
'@trpc/client': 10.7.0_@trpc+server@10.9.1
|
||||
'@trpc/react-query': 10.9.1_hv4rsvckahicenzgmmmurigzzu
|
||||
'@trpc/server': 10.9.1
|
||||
next: 13.1.1_7nrowiyds4jpk2wpzkb7237oey
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0_react@18.2.0
|
||||
react-ssr-prepass: 1.5.0_react@18.2.0
|
||||
dev: false
|
||||
|
||||
/@trpc/react-query/10.7.0_x4ie6nhblo2vtx2aafrgzlfqy4:
|
||||
resolution: {integrity: sha512-dSSmYid6NWocU32QMsH6qWEqnW4mbp7hX01hxsBfAA2mGYD8b4Kgc9ripw4MpPNbsJElJFyWDHfb/UHnhywWbA==}
|
||||
/@trpc/react-query/10.9.1_hv4rsvckahicenzgmmmurigzzu:
|
||||
resolution: {integrity: sha512-R6TvyJkvxc0i7qXNHlb+1aUnsJz5YuxhZDzDUJl9akNlvxlmZFm3R9gEMEjDAXIYAUHjbmucl9FKKN6GjCTBTA==}
|
||||
peerDependencies:
|
||||
'@tanstack/react-query': ^4.3.8
|
||||
'@trpc/client': 10.7.0
|
||||
'@trpc/server': 10.7.0
|
||||
'@trpc/client': 10.9.1
|
||||
'@trpc/server': 10.9.1
|
||||
react: '>=16.8.0'
|
||||
react-dom: '>=16.8.0'
|
||||
dependencies:
|
||||
'@tanstack/react-query': 4.20.4_biqbaboplfbrettd7655fr4n2y
|
||||
'@trpc/client': 10.7.0_@trpc+server@10.7.0
|
||||
'@trpc/server': 10.7.0
|
||||
'@tanstack/react-query': 4.24.4_biqbaboplfbrettd7655fr4n2y
|
||||
'@trpc/client': 10.7.0_@trpc+server@10.9.1
|
||||
'@trpc/server': 10.9.1
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0_react@18.2.0
|
||||
dev: false
|
||||
|
||||
/@trpc/server/10.7.0:
|
||||
resolution: {integrity: sha512-kFhlqhzZt5PwVRRDci8oJKMMFGjjq9189stT6bvS9xuuLjRzzwhMShRi99sm/jqhdjWmlXyO3Ncx0rnghUvH+w==}
|
||||
/@trpc/server/10.9.1:
|
||||
resolution: {integrity: sha512-cUbj8KphlH0IHuoVUhkZrN1E+FdfRpagglqhblyj8VPJKkjzTjb6D8MYHlXhiX7Tdmtwy2w0ev39HBkzlM0thg==}
|
||||
dev: false
|
||||
|
||||
/@tsconfig/node10/1.0.9:
|
||||
|
@ -3500,12 +3513,12 @@ packages:
|
|||
resolution: {integrity: sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==}
|
||||
dependencies:
|
||||
'@types/connect': 3.4.35
|
||||
'@types/node': 17.0.31
|
||||
'@types/node': 18.11.18
|
||||
|
||||
/@types/connect/3.4.35:
|
||||
resolution: {integrity: sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==}
|
||||
dependencies:
|
||||
'@types/node': 17.0.31
|
||||
'@types/node': 18.11.18
|
||||
|
||||
/@types/cookie/0.4.1:
|
||||
resolution: {integrity: sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==}
|
||||
|
@ -3529,26 +3542,18 @@ packages:
|
|||
resolution: {integrity: sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==}
|
||||
dev: false
|
||||
|
||||
/@types/express-serve-static-core/4.17.28:
|
||||
resolution: {integrity: sha512-P1BJAEAW3E2DJUlkgq4tOL3RyMunoWXqbSCygWo5ZIWTjUgN1YnaXWW4VWl/oc8vs/XoYibEGBKP0uZyF4AHig==}
|
||||
dependencies:
|
||||
'@types/node': 17.0.31
|
||||
'@types/qs': 6.9.7
|
||||
'@types/range-parser': 1.2.4
|
||||
|
||||
/@types/express-serve-static-core/4.17.29:
|
||||
resolution: {integrity: sha512-uMd++6dMKS32EOuw1Uli3e3BPgdLIXmezcfHv7N4c1s3gkhikBplORPpMq3fuWkxncZN1reb16d5n8yhQ80x7Q==}
|
||||
dependencies:
|
||||
'@types/node': 17.0.31
|
||||
'@types/node': 18.11.18
|
||||
'@types/qs': 6.9.7
|
||||
'@types/range-parser': 1.2.4
|
||||
dev: false
|
||||
|
||||
/@types/express/4.17.13:
|
||||
resolution: {integrity: sha512-6bSZTPaTIACxn48l50SR+axgrqm6qXFIxrdAKaG6PaJk3+zuUr35hBlgT7vOmJcum+OEaIBLtHV/qloEAFITeA==}
|
||||
dependencies:
|
||||
'@types/body-parser': 1.19.2
|
||||
'@types/express-serve-static-core': 4.17.28
|
||||
'@types/express-serve-static-core': 4.17.29
|
||||
'@types/qs': 6.9.7
|
||||
'@types/serve-static': 1.13.10
|
||||
|
||||
|
@ -3698,7 +3703,6 @@ packages:
|
|||
|
||||
/@types/node/18.11.18:
|
||||
resolution: {integrity: sha512-DHQpWGjyQKSHj3ebjFI/wRKcqQcdR+MoFBygntYOZytCqNfkd2ZC4ARDJ2DQqhjH5p85Nnd3jhUJIXrszFX/JA==}
|
||||
dev: true
|
||||
|
||||
/@types/normalize-package-data/2.4.1:
|
||||
resolution: {integrity: sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==}
|
||||
|
@ -3763,7 +3767,7 @@ packages:
|
|||
resolution: {integrity: sha512-nCkHGI4w7ZgAdNkrEu0bv+4xNV/XDqW+DydknebMOQwkpDGx8G+HTlj7R7ABI8i8nKxVw0wtKPi1D+lPOkh4YQ==}
|
||||
dependencies:
|
||||
'@types/mime': 1.3.2
|
||||
'@types/node': 17.0.31
|
||||
'@types/node': 18.11.18
|
||||
|
||||
/@types/set-cookie-parser/2.4.2:
|
||||
resolution: {integrity: sha512-fBZgytwhYAUkj/jC/FAV4RQ5EerRup1YQsXQCh8rZfiHkc4UahC192oH0smGwsXol3cL3A5oETuAHeQHmhXM4w==}
|
||||
|
@ -4511,7 +4515,7 @@ packages:
|
|||
dev: true
|
||||
|
||||
/array-flatten/1.1.1:
|
||||
resolution: {integrity: sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=}
|
||||
resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==}
|
||||
dev: false
|
||||
|
||||
/array-ify/1.0.0:
|
||||
|
@ -4525,7 +4529,7 @@ packages:
|
|||
call-bind: 1.0.2
|
||||
define-properties: 1.1.4
|
||||
es-abstract: 1.20.0
|
||||
get-intrinsic: 1.1.1
|
||||
get-intrinsic: 1.1.3
|
||||
is-string: 1.0.7
|
||||
dev: true
|
||||
|
||||
|
@ -4966,7 +4970,7 @@ packages:
|
|||
resolution: {integrity: sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==}
|
||||
dependencies:
|
||||
function-bind: 1.1.1
|
||||
get-intrinsic: 1.1.1
|
||||
get-intrinsic: 1.1.3
|
||||
|
||||
/callsites/3.1.0:
|
||||
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
|
||||
|
@ -5324,6 +5328,11 @@ packages:
|
|||
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
|
||||
dev: false
|
||||
|
||||
/comment-parser/1.3.1:
|
||||
resolution: {integrity: sha512-B52sN2VNghyq5ofvUsqZjmk6YkihBX5vMSChmSK9v4ShjKf3Vk5Xcmgpw4o+iIgtrnM/u5FiMpz9VKb8lpBveA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
dev: true
|
||||
|
||||
/commitizen/4.2.5:
|
||||
resolution: {integrity: sha512-9sXju8Qrz1B4Tw7kC5KhnvwYQN88qs2zbiB8oyMsnXZyJ24PPGiNM3nHr73d32dnE3i8VJEXddBFIbOgYSEXtQ==}
|
||||
engines: {node: '>= 12'}
|
||||
|
@ -5462,7 +5471,7 @@ packages:
|
|||
dev: true
|
||||
|
||||
/cookie-signature/1.0.6:
|
||||
resolution: {integrity: sha1-4wOogrNCzD7oylE6eZmXNNqzriw=}
|
||||
resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==}
|
||||
dev: false
|
||||
|
||||
/cookie/0.4.2:
|
||||
|
@ -5947,7 +5956,7 @@ packages:
|
|||
safe-buffer: 5.2.1
|
||||
|
||||
/ee-first/1.1.1:
|
||||
resolution: {integrity: sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=}
|
||||
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
||||
dev: false
|
||||
|
||||
/electron-to-chromium/1.4.136:
|
||||
|
@ -5981,7 +5990,7 @@ packages:
|
|||
dev: false
|
||||
|
||||
/encodeurl/1.0.2:
|
||||
resolution: {integrity: sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=}
|
||||
resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==}
|
||||
engines: {node: '>= 0.8'}
|
||||
dev: false
|
||||
|
||||
|
@ -6016,7 +6025,7 @@ packages:
|
|||
es-to-primitive: 1.2.1
|
||||
function-bind: 1.1.1
|
||||
function.prototype.name: 1.1.5
|
||||
get-intrinsic: 1.1.1
|
||||
get-intrinsic: 1.1.3
|
||||
get-symbol-description: 1.0.0
|
||||
has: 1.0.3
|
||||
has-property-descriptors: 1.0.0
|
||||
|
@ -6028,7 +6037,7 @@ packages:
|
|||
is-shared-array-buffer: 1.0.2
|
||||
is-string: 1.0.7
|
||||
is-weakref: 1.0.2
|
||||
object-inspect: 1.12.0
|
||||
object-inspect: 1.12.2
|
||||
object-keys: 1.1.1
|
||||
object.assign: 4.1.2
|
||||
regexp.prototype.flags: 1.4.3
|
||||
|
@ -6135,7 +6144,7 @@ packages:
|
|||
dev: true
|
||||
|
||||
/escape-html/1.0.3:
|
||||
resolution: {integrity: sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=}
|
||||
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
|
||||
dev: false
|
||||
|
||||
/escape-string-regexp/1.0.5:
|
||||
|
@ -6506,6 +6515,24 @@ packages:
|
|||
- typescript
|
||||
dev: true
|
||||
|
||||
/eslint-plugin-jsdoc/39.6.9_eslint@8.30.0:
|
||||
resolution: {integrity: sha512-GrESRfjT1jOaK1LC2DldMuh+Ajmex9OnRIVSKMXJ1t7lNDB+J2MA11afLPXfkTKWJpplxZohjo8prT8s5LOPgQ==}
|
||||
engines: {node: ^14 || ^16 || ^17 || ^18 || ^19}
|
||||
peerDependencies:
|
||||
eslint: ^7.0.0 || ^8.0.0
|
||||
dependencies:
|
||||
'@es-joy/jsdoccomment': 0.36.1
|
||||
comment-parser: 1.3.1
|
||||
debug: 4.3.4
|
||||
escape-string-regexp: 4.0.0
|
||||
eslint: 8.30.0
|
||||
esquery: 1.4.0
|
||||
semver: 7.3.8
|
||||
spdx-expression-parse: 3.0.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
dev: true
|
||||
|
||||
/eslint-plugin-jsx-a11y/6.6.1_eslint@8.30.0:
|
||||
resolution: {integrity: sha512-sXgFVNHiWffBq23uiS/JaP6eVR622DqwB4yTzKvGZGcPq6/yZ3WmOZfuBks/vHWo9GaFOqC2ZK4i6+C35knx7Q==}
|
||||
engines: {node: '>=4.0'}
|
||||
|
@ -6781,7 +6808,7 @@ packages:
|
|||
dev: true
|
||||
|
||||
/etag/1.8.1:
|
||||
resolution: {integrity: sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=}
|
||||
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
dev: false
|
||||
|
||||
|
@ -7132,7 +7159,7 @@ packages:
|
|||
dev: false
|
||||
|
||||
/fresh/0.5.2:
|
||||
resolution: {integrity: sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=}
|
||||
resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==}
|
||||
engines: {node: '>= 0.6'}
|
||||
dev: false
|
||||
|
||||
|
@ -7225,13 +7252,6 @@ packages:
|
|||
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
|
||||
engines: {node: 6.* || 8.* || >= 10.*}
|
||||
|
||||
/get-intrinsic/1.1.1:
|
||||
resolution: {integrity: sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==}
|
||||
dependencies:
|
||||
function-bind: 1.1.1
|
||||
has: 1.0.3
|
||||
has-symbols: 1.0.3
|
||||
|
||||
/get-intrinsic/1.1.3:
|
||||
resolution: {integrity: sha512-QJVz1Tj7MS099PevUG5jvnt9tSkXN8K14dxQlikJuPt4uD9hHAHjLyLBiLR5zELelBdD9QNRAXZzsJx0WaDL9A==}
|
||||
dependencies:
|
||||
|
@ -7757,7 +7777,7 @@ packages:
|
|||
dev: true
|
||||
|
||||
/import-lazy/2.1.0:
|
||||
resolution: {integrity: sha1-BWmOPUXIjo1+nZLLBYTnfwlvPkM=}
|
||||
resolution: {integrity: sha512-m7ZEHgtw69qOGw+jwxXkHlrlIPdTGkyh66zXZ1ajZbxkDBNjSY/LGbmjc7h0s2ELsUDTAhFr55TrPSSqJGPG0A==}
|
||||
engines: {node: '>=4'}
|
||||
dev: true
|
||||
|
||||
|
@ -9170,6 +9190,11 @@ packages:
|
|||
dependencies:
|
||||
argparse: 2.0.1
|
||||
|
||||
/jsdoc-type-pratt-parser/3.1.0:
|
||||
resolution: {integrity: sha512-MgtD0ZiCDk9B+eI73BextfRrVQl0oyzRG8B2BjORts6jbunj4ScKPcyXGTbB6eXL4y9TzxCm6hyeLq/2ASzNdw==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
dev: true
|
||||
|
||||
/jsdom/20.0.3:
|
||||
resolution: {integrity: sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==}
|
||||
engines: {node: '>=14'}
|
||||
|
@ -9824,7 +9849,7 @@ packages:
|
|||
dev: false
|
||||
|
||||
/media-typer/0.3.0:
|
||||
resolution: {integrity: sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=}
|
||||
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
|
||||
engines: {node: '>= 0.6'}
|
||||
dev: false
|
||||
|
||||
|
@ -9850,7 +9875,7 @@ packages:
|
|||
dev: true
|
||||
|
||||
/merge-descriptors/1.0.1:
|
||||
resolution: {integrity: sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=}
|
||||
resolution: {integrity: sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==}
|
||||
dev: false
|
||||
|
||||
/merge-stream/2.0.0:
|
||||
|
@ -9879,7 +9904,7 @@ packages:
|
|||
dev: true
|
||||
|
||||
/methods/1.1.2:
|
||||
resolution: {integrity: sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=}
|
||||
resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==}
|
||||
engines: {node: '>= 0.6'}
|
||||
dev: false
|
||||
|
||||
|
@ -10333,8 +10358,8 @@ packages:
|
|||
/ms/2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
/msw/0.49.2_typescript@4.9.4:
|
||||
resolution: {integrity: sha512-70/E10f+POE2UmMw16v8PnKatpZplpkUwVRLBqiIdimpgaC3le7y2yOq9JmOrL15jpwWM5wAcPTOj0f550LI3g==}
|
||||
/msw/1.0.0_typescript@4.9.4:
|
||||
resolution: {integrity: sha512-8QVa1RAN/Nzbn/tKmtimJ+b2M1QZOMdETQW7/1TmBOZ4w+wJojfxuh1Hj5J4FYdBgZWW/TK4CABUOlOM4OjTOA==}
|
||||
engines: {node: '>=14'}
|
||||
hasBin: true
|
||||
requiresBuild: true
|
||||
|
@ -10360,7 +10385,7 @@ packages:
|
|||
node-fetch: 2.6.7
|
||||
outvariant: 1.3.0
|
||||
path-to-regexp: 6.2.1
|
||||
strict-event-emitter: 0.2.8
|
||||
strict-event-emitter: 0.4.4
|
||||
type-fest: 2.19.0
|
||||
typescript: 4.9.4
|
||||
yargs: 17.4.1
|
||||
|
@ -10607,10 +10632,6 @@ packages:
|
|||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
/object-inspect/1.12.0:
|
||||
resolution: {integrity: sha512-Ho2z80bVIvJloH+YzRmpZVQe87+qASmBUKZDWgx9cu+KDrX2ZDH/3tMy+gXbZETVGs2M8YdxObOh7XAtim9Y0g==}
|
||||
dev: true
|
||||
|
||||
/object-inspect/1.12.2:
|
||||
resolution: {integrity: sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==}
|
||||
|
||||
|
@ -10994,7 +11015,7 @@ packages:
|
|||
dev: true
|
||||
|
||||
/path-to-regexp/0.1.7:
|
||||
resolution: {integrity: sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=}
|
||||
resolution: {integrity: sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==}
|
||||
dev: false
|
||||
|
||||
/path-to-regexp/6.2.1:
|
||||
|
@ -12057,6 +12078,10 @@ packages:
|
|||
events: 3.3.0
|
||||
dev: true
|
||||
|
||||
/strict-event-emitter/0.4.4:
|
||||
resolution: {integrity: sha512-rTCFXHBxs2/XvNc7InSkSwUkwyQ0T9eop/Qvm0atNUXpBxjwsJ5yb7Ih/tgHbjPdeCcB4aCP8K4tuo7hNKssNg==}
|
||||
dev: true
|
||||
|
||||
/string-env-interpolation/1.0.1:
|
||||
resolution: {integrity: sha512-78lwMoCcn0nNu8LszbP1UA7g55OeE4v7rCeWnM5B453rnNr4aq+5it3FEYtZrSEiMvHZOZ9Jlqb0OD0M2VInqg==}
|
||||
dev: true
|
||||
|
@ -13015,7 +13040,7 @@ packages:
|
|||
is-yarn-global: 0.3.0
|
||||
latest-version: 5.1.0
|
||||
pupa: 2.1.1
|
||||
semver: 7.3.7
|
||||
semver: 7.3.8
|
||||
semver-diff: 3.1.1
|
||||
xdg-basedir: 4.0.0
|
||||
dev: true
|
||||
|
@ -13087,7 +13112,7 @@ packages:
|
|||
dev: true
|
||||
|
||||
/utils-merge/1.0.1:
|
||||
resolution: {integrity: sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=}
|
||||
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
|
||||
engines: {node: '>= 0.4.0'}
|
||||
dev: false
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue