feat(system.service): create routes for get settings and update settings
This commit is contained in:
parent
43612cb78f
commit
b19d30cce3
8 changed files with 171 additions and 27 deletions
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -1 +1 @@
|
|||
export { getConfig, setConfig, TipiConfig } from './TipiConfig';
|
||||
export { getConfig, setConfig, getSettings, setSettings, TipiConfig } from './TipiConfig';
|
||||
|
|
7
src/server/routers/routers.test.ts
Normal file
7
src/server/routers/routers.test.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { mainRouter } from './_app';
|
||||
|
||||
describe('routers', () => {
|
||||
it('should return a router', () => {
|
||||
expect(mainRouter).toBeDefined();
|
||||
});
|
||||
});
|
|
@ -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),
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
Loading…
Reference in a new issue