feat(system.service): create routes for get settings and update settings

This commit is contained in:
Nicolas Meienberger 2023-03-28 21:23:50 +02:00 committed by Nicolas Meienberger
parent 43612cb78f
commit b19d30cce3
8 changed files with 171 additions and 27 deletions

View file

@ -4,3 +4,4 @@ POSTGRES_USERNAME=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_PORT=5433
APPS_REPO_ID=repo-id
APPS_REPO_URL=https://test.com/test

View file

@ -24,7 +24,7 @@ describe('Test: getConfig', () => {
it('It should overrides config from settings.json file', () => {
const settingsJson = {
appsRepoUrl: faker.random.word(),
appsRepoUrl: faker.internet.url(),
appsRepoId: faker.random.word(),
domain: faker.random.word(),
};
@ -65,7 +65,7 @@ describe('Test: getConfig', () => {
describe('Test: setConfig', () => {
it('It should be able set config', () => {
const randomWord = faker.random.word();
const randomWord = faker.internet.url();
setConfig('appsRepoUrl', randomWord);
const config = getConfig();
@ -73,13 +73,24 @@ describe('Test: setConfig', () => {
expect(config.appsRepoUrl).toBe(randomWord);
});
it('Should not be able to set invalid NODE_ENV', () => {
// @ts-expect-error - We are testing invalid NODE_ENV
expect(() => setConfig('NODE_ENV', 'invalid')).toThrow();
it('Should not be able to set invalid NODE_ENV', async () => {
// arrange
let error;
// act
try {
// @ts-expect-error - We are testing invalid NODE_ENV
await setConfig('NODE_ENV', 'invalid');
} catch (e) {
error = e;
}
// assert
expect(error).toBeDefined();
});
it('Should write config to json file', () => {
const randomWord = faker.random.word();
const randomWord = faker.internet.url();
setConfig('appsRepoUrl', randomWord, true);
const config = getConfig();
@ -92,3 +103,70 @@ describe('Test: setConfig', () => {
expect(settingsJson.appsRepoUrl).toBe(randomWord);
});
});
describe('Test: getSettings', () => {
it('It should return settings from settings.json', () => {
// arrange
const fakeSettings = {
appsRepoUrl: faker.internet.url(),
};
const MockFiles = { '/runtipi/state/settings.json': JSON.stringify(fakeSettings) };
// @ts-expect-error - We are mocking fs
fs.__createMockFiles(MockFiles);
// act
const settings = new TipiConfig().getSettings();
// assert
expect(settings).toBeDefined();
expect(settings.appsRepoUrl).toBe(fakeSettings.appsRepoUrl);
});
it('It should return current config if settings.json has any invalid value', () => {
// arrange
const tipiConf = new TipiConfig();
const MockFiles = { '/runtipi/state/settings.json': JSON.stringify({ appsRepoUrl: 10 }) };
// @ts-expect-error - We are mocking fs
fs.__createMockFiles(MockFiles);
// act
const settings = tipiConf.getSettings();
// assert
expect(settings).toBeDefined();
expect(settings.appsRepoUrl).not.toBe(10);
expect(settings.appsRepoUrl).toBe(tipiConf.getConfig().appsRepoUrl);
});
});
describe('Test: setSettings', () => {
it('should write settings to json file', () => {
// arrange
const fakeSettings = {
appsRepoUrl: faker.internet.url(),
};
// act
new TipiConfig().setSettings(fakeSettings);
// assert
const settingsJson = readJsonFile('/runtipi/state/settings.json') as { [key: string]: string };
expect(settingsJson).toBeDefined();
expect(settingsJson.appsRepoUrl).toBe(fakeSettings.appsRepoUrl);
});
it('should not write settings to json file if there are invalid values', () => {
// arrange
const fakeSettings = { appsRepoUrl: 10 };
// act
new TipiConfig().setSettings(fakeSettings as object);
// assert
const settingsJson = (readJsonFile('/runtipi/state/settings.json') || {}) as { [key: string]: string };
expect(settingsJson).toBeDefined();
expect(settingsJson.appsRepoUrl).not.toBe(fakeSettings.appsRepoUrl);
});
});

View file

@ -21,7 +21,7 @@ const {
APPS_REPO_URL,
DOMAIN,
REDIS_HOST,
STORAGE_PATH = '/runtipi',
STORAGE_PATH,
ARCHITECTURE = 'amd64',
POSTGRES_HOST,
POSTGRES_DBNAME,
@ -30,20 +30,20 @@ const {
POSTGRES_PORT = 5432,
} = conf;
const configSchema = z.object({
export const configSchema = z.object({
NODE_ENV: z.union([z.literal('development'), z.literal('production'), z.literal('test')]),
REDIS_HOST: z.string(),
status: z.union([z.literal('RUNNING'), z.literal('UPDATING'), z.literal('RESTARTING')]),
architecture: z.nativeEnum(ARCHITECTURES),
dnsIp: z.string(),
dnsIp: z.string().ip(),
rootFolder: z.string(),
internalIp: z.string(),
version: z.string(),
jwtSecret: z.string(),
appsRepoId: z.string(),
appsRepoUrl: z.string(),
appsRepoUrl: z.string().url(),
domain: z.string(),
storagePath: z.string(),
storagePath: z.string().optional(),
postgresHost: z.string(),
postgresDatabase: z.string(),
postgresUsername: z.string(),
@ -51,6 +51,9 @@ const configSchema = z.object({
postgresPort: z.number(),
});
export const settingsSchema = configSchema.partial().pick({ dnsIp: true, internalIp: true, appsRepoUrl: true, domain: true, storagePath: true });
export type TipiSettingsType = z.infer<typeof settingsSchema>;
export const formatErrors = (errors: { fieldErrors: Record<string, string[]> }) =>
Object.entries(errors.fieldErrors)
.map(([name, value]) => `${name}: ${value[0]}`)
@ -116,7 +119,19 @@ export class TipiConfig {
return this.config;
}
public setConfig<T extends keyof typeof configSchema.shape>(key: T, value: z.infer<typeof configSchema>[T], writeFile = false) {
public getSettings() {
const fileConfig = readJsonFile('/runtipi/state/settings.json') || {};
const parsedSettings = settingsSchema.safeParse({ ...this.config, ...fileConfig });
if (parsedSettings.success) {
return parsedSettings.data;
}
Logger.error('❌ Invalid settings.json file');
return this.config;
}
public async setConfig<T extends keyof typeof configSchema.shape>(key: T, value: z.infer<typeof configSchema>[T], writeFile = false) {
const newConf: z.infer<typeof configSchema> = { ...this.getConfig() };
newConf[key] = value;
@ -129,13 +144,29 @@ export class TipiConfig {
parsedConf[key] = value;
const parsed = configSchema.partial().parse(parsedConf);
fs.writeFileSync('/runtipi/state/settings.json', JSON.stringify(parsed));
await fs.promises.writeFile('/runtipi/state/settings.json', JSON.stringify(parsed));
}
}
public async setSettings(settings: TipiSettingsType) {
const newConf: z.infer<typeof configSchema> = { ...this.getConfig() };
const parsed = settingsSchema.safeParse(settings);
if (!parsed.success) {
Logger.error('❌ Invalid settings.json file');
return;
}
await fs.promises.writeFile('/runtipi/state/settings.json', JSON.stringify(parsed.data));
this.config = configSchema.parse({ ...newConf, ...parsed.data });
}
}
export const setConfig = <T extends keyof typeof configSchema.shape>(key: T, value: z.infer<typeof configSchema>[T], writeFile = false) => {
TipiConfig.getInstance().setConfig(key, value, writeFile);
return TipiConfig.getInstance().setConfig(key, value, writeFile);
};
export const getConfig = () => TipiConfig.getInstance().getConfig();
export const getSettings = () => TipiConfig.getInstance().getSettings();
export const setSettings = (settings: TipiSettingsType) => TipiConfig.getInstance().setSettings(settings);

View file

@ -1 +1 @@
export { getConfig, setConfig, TipiConfig } from './TipiConfig';
export { getConfig, setConfig, getSettings, setSettings, TipiConfig } from './TipiConfig';

View file

@ -0,0 +1,7 @@
import { mainRouter } from './_app';
describe('routers', () => {
it('should return a router', () => {
expect(mainRouter).toBeDefined();
});
});

View file

@ -1,6 +1,8 @@
import { inferRouterOutputs } from '@trpc/server';
import { router, protectedProcedure, publicProcedure } from '../../trpc';
import { SystemServiceClass } from '../../services/system';
import { settingsSchema } from '../../core/TipiConfig/TipiConfig';
import * as TipiConfig from '../../core/TipiConfig';
export type SystemRouterOutput = inferRouterOutputs<typeof systemRouter>;
const SystemService = new SystemServiceClass();
@ -11,4 +13,6 @@ export const systemRouter = router({
getVersion: publicProcedure.query(SystemService.getVersion),
restart: protectedProcedure.mutation(SystemService.restart),
update: protectedProcedure.mutation(SystemService.update),
updateSettings: protectedProcedure.input(settingsSchema.partial()).mutation(({ input }) => TipiConfig.setSettings(input)),
getSettings: protectedProcedure.query(TipiConfig.getSettings),
});

View file

@ -5,7 +5,7 @@ import { readJsonFile } from '../../common/fs.helpers';
import { EventDispatcher } from '../../core/EventDispatcher';
import { Logger } from '../../core/Logger';
import TipiCache from '../../core/TipiCache';
import { getConfig, setConfig } from '../../core/TipiConfig';
import * as TipiConfig from '../../core/TipiConfig';
const SYSTEM_STATUS = ['UPDATING', 'RESTARTING', 'RUNNING'] as const;
type SystemStatus = (typeof SYSTEM_STATUS)[keyof typeof SYSTEM_STATUS];
@ -38,6 +38,7 @@ export class SystemServiceClass {
/**
* Get the current and latest version of Tipi
*
* @returns {Promise<{ current: string; latest: string }>}
*/
public getVersion = async (): Promise<{ current: string; latest?: string }> => {
@ -52,10 +53,10 @@ export class SystemServiceClass {
await this.cache.set('latestVersion', version?.replace('v', '') || '', 60 * 60);
}
return { current: getConfig().version, latest: version?.replace('v', '') };
return { current: TipiConfig.getConfig().version, latest: version?.replace('v', '') };
} catch (e) {
Logger.error(e);
return { current: getConfig().version, latest: undefined };
return { current: TipiConfig.getConfig().version, latest: undefined };
}
};
@ -72,7 +73,7 @@ export class SystemServiceClass {
public update = async (): Promise<boolean> => {
const { current, latest } = await this.getVersion();
if (getConfig().NODE_ENV === 'development') {
if (TipiConfig.getConfig().NODE_ENV === 'development') {
throw new Error('Cannot update in development mode');
}
@ -92,7 +93,7 @@ export class SystemServiceClass {
throw new Error('The major version has changed. Please update manually (instructions on GitHub)');
}
setConfig('status', 'UPDATING');
TipiConfig.setConfig('status', 'UPDATING');
this.dispatcher.dispatchEventAsync('update');
@ -100,17 +101,17 @@ export class SystemServiceClass {
};
public restart = async (): Promise<boolean> => {
if (getConfig().NODE_ENV === 'development') {
if (TipiConfig.getConfig().NODE_ENV === 'development') {
throw new Error('Cannot restart in development mode');
}
setConfig('status', 'RESTARTING');
TipiConfig.setConfig('status', 'RESTARTING');
this.dispatcher.dispatchEventAsync('restart');
return true;
};
public static status = async (): Promise<{ status: SystemStatus }> => ({
status: getConfig().status as SystemStatus,
status: TipiConfig.getConfig().status,
});
}

View file

@ -1,11 +1,33 @@
import { initTRPC, TRPCError } from '@trpc/server';
import superjson from 'superjson';
import { typeToFlattenedError, ZodError } from 'zod';
import { type Context } from './context';
/**
*
* @param errors
*/
export function zodErrorsToRecord(errors: typeToFlattenedError<string>) {
const record: Record<string, string> = {};
Object.entries(errors.fieldErrors).forEach(([key, value]) => {
const error = value?.[0];
if (error) {
record[key] = error;
}
});
return record;
}
const t = initTRPC.context<Context>().create({
transformer: superjson,
errorFormatter({ shape }) {
return shape;
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError: error.code === 'BAD_REQUEST' && error.cause instanceof ZodError ? zodErrorsToRecord(error.cause.flatten()) : null,
},
};
},
});
// Base router and procedure helpers
@ -13,7 +35,7 @@ export const { router } = t;
/**
* Unprotected procedure
* */
*/
export const publicProcedure = t.procedure;
/**
@ -33,5 +55,5 @@ const isAuthed = t.middleware(({ ctx, next }) => {
/**
* Protected procedure
* */
*/
export const protectedProcedure = t.procedure.use(isAuthed);