mirror of
https://github.com/meienberger/runtipi.git
synced 2024-09-11 12:15:48 +03:00
feat(worker): create new docker template yaml generation
This commit is contained in:
parent
ede5bf5f8a
commit
9814ec78cb
@ -1,7 +1,15 @@
|
||||
// Schemas
|
||||
export { appInfoSchema, formFieldSchema, FIELD_TYPES, APP_CATEGORIES, type AppInfo, type FormField, type AppCategory } from './schemas/app-schemas';
|
||||
export { envSchema, settingsSchema, ARCHITECTURES, type Architecture } from './schemas/env-schemas';
|
||||
export { eventSchema, eventResultSchema, EVENT_TYPES, type EventType, type SystemEvent } from './schemas/queue-schemas';
|
||||
export {
|
||||
eventSchema,
|
||||
eventResultSchema,
|
||||
EVENT_TYPES,
|
||||
type EventType,
|
||||
type SystemEvent,
|
||||
type AppEventForm,
|
||||
type AppEventFormInput,
|
||||
} from './schemas/queue-schemas';
|
||||
export { linkSchema, type LinkInfo, type LinkInfoInput } from './schemas/link-schemas';
|
||||
export { socketEventSchema, type SocketEvent } from './schemas/socket-schemas';
|
||||
export { systemLoadSchema, type SystemLoad } from './schemas/system-schemas';
|
||||
|
@ -1,4 +1,3 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { createLogger, format, transports } from 'winston';
|
||||
|
||||
@ -13,10 +12,6 @@ type Transports = transports.ConsoleTransportInstance | transports.FileTransport
|
||||
* @param {string} logsFolder - The folder where the logs will be stored
|
||||
*/
|
||||
export const newLogger = (id: string, logsFolder: string, console?: boolean) => {
|
||||
if (!fs.existsSync(logsFolder)) {
|
||||
fs.mkdirSync(logsFolder, { recursive: true });
|
||||
}
|
||||
|
||||
const tr: Transports[] = [];
|
||||
let exceptionHandlers: Transports[] = [new transports.Console()];
|
||||
|
||||
|
@ -78,6 +78,7 @@ export const appInfoSchema = z.object({
|
||||
supported_architectures: z.nativeEnum(ARCHITECTURES).array().optional(),
|
||||
uid: z.number().optional(),
|
||||
gid: z.number().optional(),
|
||||
dynamic_config: z.boolean().optional().default(false),
|
||||
});
|
||||
|
||||
// Derived types
|
||||
|
@ -8,7 +8,7 @@ export const EVENT_TYPES = {
|
||||
|
||||
export type EventType = (typeof EVENT_TYPES)[keyof typeof EVENT_TYPES];
|
||||
|
||||
const appCommandSchema = z.object({
|
||||
const appEventSchema = z.object({
|
||||
type: z.literal(EVENT_TYPES.APP),
|
||||
command: z.union([
|
||||
z.literal('start'),
|
||||
@ -21,9 +21,21 @@ const appCommandSchema = z.object({
|
||||
]),
|
||||
appid: z.string(),
|
||||
skipEnv: z.boolean().optional().default(false),
|
||||
form: z.object({}).catchall(z.any()),
|
||||
form: z
|
||||
.object({
|
||||
exposed: z.boolean().optional(),
|
||||
exposedLocal: z.boolean().optional(),
|
||||
openPort: z.boolean().optional(),
|
||||
domain: z.string().optional(),
|
||||
isVisibleOnGuestDashboard: z.boolean().optional(),
|
||||
})
|
||||
.extend({})
|
||||
.catchall(z.unknown()),
|
||||
});
|
||||
|
||||
export type AppEventFormInput = z.input<typeof appEventSchema>['form'];
|
||||
export type AppEventForm = z.output<typeof appEventSchema>['form'];
|
||||
|
||||
const repoCommandSchema = z.object({
|
||||
type: z.literal(EVENT_TYPES.REPO),
|
||||
command: z.union([z.literal('clone'), z.literal('update')]),
|
||||
@ -35,7 +47,7 @@ const systemCommandSchema = z.object({
|
||||
command: z.literal('system_info'),
|
||||
});
|
||||
|
||||
export const eventSchema = appCommandSchema.or(repoCommandSchema).or(systemCommandSchema);
|
||||
export const eventSchema = appEventSchema.or(repoCommandSchema).or(systemCommandSchema);
|
||||
|
||||
export const eventResultSchema = z.object({
|
||||
success: z.boolean(),
|
||||
|
@ -2,5 +2,5 @@ module.exports = {
|
||||
singleQuote: true,
|
||||
semi: true,
|
||||
trailingComma: 'all',
|
||||
printWidth: 200,
|
||||
printWidth: 100,
|
||||
};
|
||||
|
@ -18,6 +18,7 @@
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "^8.4.1",
|
||||
"@sentry/esbuild-plugin": "^2.16.1",
|
||||
"@total-typescript/shoehorn": "^0.1.2",
|
||||
"@types/web-push": "^3.6.3",
|
||||
"dotenv-cli": "^7.4.1",
|
||||
"esbuild": "^0.19.4",
|
||||
@ -43,6 +44,7 @@
|
||||
"socket.io": "^4.7.5",
|
||||
"systeminformation": "^5.22.7",
|
||||
"web-push": "^3.6.7",
|
||||
"yaml": "^2.4.1",
|
||||
"zod": "^3.23.0"
|
||||
}
|
||||
}
|
||||
|
211
packages/worker/src/config/__tests__/docker-templates.test.ts
Normal file
211
packages/worker/src/config/__tests__/docker-templates.test.ts
Normal file
@ -0,0 +1,211 @@
|
||||
/* eslint-disable no-template-curly-in-string */
|
||||
import { it, describe, expect } from 'vitest';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { ServiceInput, getDockerCompose } from '../docker-templates';
|
||||
|
||||
describe('getDockerCompose', async () => {
|
||||
it('should return correct docker-compose config', async () => {
|
||||
// arrange
|
||||
const serviceName1 = faker.word.noun();
|
||||
const serviceName2 = faker.word.noun();
|
||||
|
||||
const serviceImage1 = faker.system.semver();
|
||||
const serviceImage2 = faker.system.semver();
|
||||
|
||||
const servicePort1 = faker.number.int({ min: 64, max: 65535 });
|
||||
const servicePort2 = faker.number.int({ min: 64, max: 65535 });
|
||||
|
||||
const fakeEnv = {
|
||||
one: faker.system.semver(),
|
||||
two: faker.system.semver(),
|
||||
};
|
||||
|
||||
const services = [
|
||||
{
|
||||
isMain: true,
|
||||
volumes: [
|
||||
{ hostPath: '${APP_DATA_DIR}/data/redis', containerPath: '/data' },
|
||||
{ hostPath: '${APP_DATA_DIR}/logs/redis', containerPath: '/logs' },
|
||||
],
|
||||
dependsOn: {
|
||||
[serviceName2]: {
|
||||
condition: 'service_healthy',
|
||||
},
|
||||
},
|
||||
name: serviceName1,
|
||||
image: serviceImage1,
|
||||
internalPort: servicePort1,
|
||||
environment: fakeEnv,
|
||||
},
|
||||
{
|
||||
name: serviceName2,
|
||||
healthCheck: {
|
||||
test: 'curl --fail http://localhost:3000 || exit 1',
|
||||
retries: 3,
|
||||
interval: '30s',
|
||||
timeout: '10s',
|
||||
},
|
||||
dependsOn: [serviceName1],
|
||||
image: serviceImage2,
|
||||
internalPort: servicePort2,
|
||||
},
|
||||
] satisfies ServiceInput[];
|
||||
|
||||
// act
|
||||
const result = getDockerCompose(services, {
|
||||
exposed: false,
|
||||
exposedLocal: false,
|
||||
openPort: true,
|
||||
isVisibleOnGuestDashboard: false,
|
||||
});
|
||||
|
||||
// assert
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
"services:
|
||||
${serviceName1}:
|
||||
image: ${serviceImage1}
|
||||
container_name: ${serviceName1}
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- tipi_main_network
|
||||
environment:
|
||||
one: ${fakeEnv.one}
|
||||
two: ${fakeEnv.two}
|
||||
ports:
|
||||
- \${APP_PORT}:${servicePort1}
|
||||
volumes:
|
||||
- \${APP_DATA_DIR}/data/redis:/data
|
||||
- \${APP_DATA_DIR}/logs/redis:/logs
|
||||
depends_on:
|
||||
${serviceName2}:
|
||||
condition: service_healthy
|
||||
labels:
|
||||
generated: true
|
||||
traefik.enable: false
|
||||
traefik.http.middlewares.${serviceName1}-web-redirect.redirectscheme.scheme: https
|
||||
traefik.http.services.${serviceName1}.loadbalancer.server.port: "${servicePort1}"
|
||||
${serviceName2}:
|
||||
image: ${serviceImage2}
|
||||
container_name: ${serviceName2}
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- tipi_main_network
|
||||
healthcheck:
|
||||
test: curl --fail http://localhost:3000 || exit 1
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
depends_on:
|
||||
- ${serviceName1}
|
||||
networks:
|
||||
tipi_main_network:
|
||||
name: runtipi_tipi_main_network
|
||||
external: true
|
||||
"
|
||||
`);
|
||||
});
|
||||
|
||||
it('should add traefik labels when exposed is true', async () => {
|
||||
// arrange
|
||||
const serviceName1 = faker.word.noun();
|
||||
const serviceImage1 = faker.system.semver();
|
||||
const servicePort1 = faker.number.int({ min: 64, max: 65535 });
|
||||
|
||||
const services = [
|
||||
{
|
||||
isMain: true,
|
||||
name: serviceName1,
|
||||
image: serviceImage1,
|
||||
internalPort: servicePort1,
|
||||
},
|
||||
] satisfies ServiceInput[];
|
||||
|
||||
// act
|
||||
const result = getDockerCompose(services, {
|
||||
exposed: true,
|
||||
exposedLocal: false,
|
||||
openPort: false,
|
||||
isVisibleOnGuestDashboard: false,
|
||||
});
|
||||
|
||||
// assert
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
"services:
|
||||
${serviceName1}:
|
||||
image: ${serviceImage1}
|
||||
container_name: ${serviceName1}
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- tipi_main_network
|
||||
labels:
|
||||
generated: true
|
||||
traefik.enable: true
|
||||
traefik.http.middlewares.${serviceName1}-web-redirect.redirectscheme.scheme: https
|
||||
traefik.http.services.${serviceName1}.loadbalancer.server.port: "${servicePort1}"
|
||||
traefik.http.routers.${serviceName1}-insecure.rule: Host(\`\${APP_DOMAIN}\`)
|
||||
traefik.http.routers.${serviceName1}-insecure.service: ${serviceName1}
|
||||
traefik.http.routers.${serviceName1}-insecure.middlewares: ${serviceName1}-web-redirect
|
||||
traefik.http.routers.${serviceName1}.rule: Host(\`\${APP_DOMAIN}\`)
|
||||
traefik.http.routers.${serviceName1}.entrypoints: websecure
|
||||
traefik.http.routers.${serviceName1}.tls.certresolver: myresolver
|
||||
networks:
|
||||
tipi_main_network:
|
||||
name: runtipi_tipi_main_network
|
||||
external: true
|
||||
"
|
||||
`);
|
||||
});
|
||||
|
||||
it('should add traefik labels when exposedLocal is true', async () => {
|
||||
// arrange
|
||||
const serviceName1 = faker.word.noun();
|
||||
const serviceImage1 = faker.system.semver();
|
||||
const servicePort1 = faker.number.int({ min: 64, max: 65535 });
|
||||
|
||||
const services = [
|
||||
{
|
||||
isMain: true,
|
||||
name: serviceName1,
|
||||
image: serviceImage1,
|
||||
internalPort: servicePort1,
|
||||
},
|
||||
] satisfies ServiceInput[];
|
||||
|
||||
// act
|
||||
const result = getDockerCompose(services, {
|
||||
exposed: false,
|
||||
exposedLocal: true,
|
||||
openPort: false,
|
||||
isVisibleOnGuestDashboard: false,
|
||||
});
|
||||
|
||||
// assert
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
"services:
|
||||
${serviceName1}:
|
||||
image: ${serviceImage1}
|
||||
container_name: ${serviceName1}
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- tipi_main_network
|
||||
labels:
|
||||
generated: true
|
||||
traefik.enable: true
|
||||
traefik.http.middlewares.${serviceName1}-web-redirect.redirectscheme.scheme: https
|
||||
traefik.http.services.${serviceName1}.loadbalancer.server.port: "${servicePort1}"
|
||||
traefik.http.routers.${serviceName1}-local-insecure.rule: Host(\`${serviceName1}.\${LOCAL_DOMAIN}\`)
|
||||
traefik.http.routers.${serviceName1}-local-insecure.entrypoints: web
|
||||
traefik.http.routers.${serviceName1}-local-insecure.service: ${serviceName1}
|
||||
traefik.http.routers.${serviceName1}-local-insecure.middlewares: ${serviceName1}-web-redirect
|
||||
traefik.http.routers.${serviceName1}-local.rule: Host(\`${serviceName1}.\${LOCAL_DOMAIN}\`)
|
||||
traefik.http.routers.${serviceName1}-local.entrypoints: websecure
|
||||
traefik.http.routers.${serviceName1}-local.service: ${serviceName1}
|
||||
traefik.http.routers.${serviceName1}-local.tls: true
|
||||
networks:
|
||||
tipi_main_network:
|
||||
name: runtipi_tipi_main_network
|
||||
external: true
|
||||
"
|
||||
`);
|
||||
});
|
||||
});
|
167
packages/worker/src/config/docker-templates.ts
Normal file
167
packages/worker/src/config/docker-templates.ts
Normal file
@ -0,0 +1,167 @@
|
||||
/* eslint-disable no-template-curly-in-string */
|
||||
import { AppEventForm } from '@runtipi/shared';
|
||||
import * as yaml from 'yaml';
|
||||
import { z } from 'zod';
|
||||
|
||||
type GetAppLabelsArgs = {
|
||||
internalPort: number;
|
||||
appId: string;
|
||||
exposedLocal?: boolean;
|
||||
exposed?: boolean;
|
||||
};
|
||||
|
||||
const getTraefikLabels = (params: GetAppLabelsArgs) => {
|
||||
const { internalPort, appId, exposedLocal, exposed } = params;
|
||||
|
||||
let labels = {
|
||||
// General
|
||||
generated: true,
|
||||
'traefik.enable': false,
|
||||
[`traefik.http.middlewares.${appId}-web-redirect.redirectscheme.scheme`]: 'https',
|
||||
[`traefik.http.services.${appId}.loadbalancer.server.port`]: `${internalPort}`,
|
||||
};
|
||||
|
||||
if (exposed) {
|
||||
labels = Object.assign(labels, {
|
||||
'traefik.enable': true,
|
||||
// HTTP
|
||||
[`traefik.http.routers.${appId}-insecure.rule`]: 'Host(`${APP_DOMAIN}`)',
|
||||
[`traefik.http.routers.${appId}-insecure.service`]: appId,
|
||||
[`traefik.http.routers.${appId}-insecure.middlewares`]: `${appId}-web-redirect`,
|
||||
// HTTPS
|
||||
[`traefik.http.routers.${appId}.rule`]: 'Host(`${APP_DOMAIN}`)',
|
||||
[`traefik.http.routers.${appId}.entrypoints`]: 'websecure',
|
||||
[`traefik.http.routers.${appId}.tls.certresolver`]: 'myresolver',
|
||||
});
|
||||
}
|
||||
|
||||
if (exposedLocal) {
|
||||
labels = Object.assign(labels, {
|
||||
'traefik.enable': true,
|
||||
// HTTP local
|
||||
[`traefik.http.routers.${appId}-local-insecure.rule`]: `Host(\`${appId}.\${LOCAL_DOMAIN}\`)`,
|
||||
[`traefik.http.routers.${appId}-local-insecure.entrypoints`]: 'web',
|
||||
[`traefik.http.routers.${appId}-local-insecure.service`]: appId,
|
||||
[`traefik.http.routers.${appId}-local-insecure.middlewares`]: `${appId}-web-redirect`,
|
||||
// HTTPS local
|
||||
[`traefik.http.routers.${appId}-local.rule`]: `Host(\`${appId}.\${LOCAL_DOMAIN}\`)`,
|
||||
[`traefik.http.routers.${appId}-local.entrypoints`]: 'websecure',
|
||||
[`traefik.http.routers.${appId}-local.service`]: appId,
|
||||
[`traefik.http.routers.${appId}-local.tls`]: true,
|
||||
});
|
||||
}
|
||||
|
||||
return labels;
|
||||
};
|
||||
|
||||
const dependsOnSchema = z.union([
|
||||
z.array(z.string()),
|
||||
z.record(
|
||||
z.string(),
|
||||
z.object({
|
||||
condition: z.enum(['service_healthy', 'service_started', 'service_completed_successfully']),
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
const serviceSchema = z
|
||||
.object({
|
||||
openPort: z.boolean().optional(),
|
||||
image: z.string(),
|
||||
name: z.string(),
|
||||
internalPort: z.number(),
|
||||
isMain: z.boolean().optional(),
|
||||
command: z.string().optional(),
|
||||
volumes: z
|
||||
.array(
|
||||
z.object({
|
||||
hostPath: z.string(),
|
||||
containerPath: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
environment: z.record(z.string()).optional(),
|
||||
exposedLocal: z.boolean().optional(),
|
||||
exposed: z.boolean().optional(),
|
||||
healthCheck: z
|
||||
.object({
|
||||
test: z.string(),
|
||||
interval: z.string(),
|
||||
timeout: z.string(),
|
||||
retries: z.number(),
|
||||
})
|
||||
.optional(),
|
||||
dependsOn: dependsOnSchema.optional(),
|
||||
})
|
||||
.transform((data) => {
|
||||
const base: Record<string, unknown> = {
|
||||
image: data.image,
|
||||
container_name: data.name,
|
||||
restart: 'unless-stopped',
|
||||
networks: ['tipi_main_network'],
|
||||
environment: data.environment,
|
||||
healthcheck: data.healthCheck,
|
||||
command: data.command,
|
||||
};
|
||||
|
||||
if (data.isMain && data.openPort) {
|
||||
base.ports = [`\${APP_PORT}:${data.internalPort}`];
|
||||
}
|
||||
|
||||
if (data.volumes?.length) {
|
||||
base.volumes = data.volumes.map(
|
||||
({ hostPath, containerPath }) => `${hostPath}:${containerPath}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (data.dependsOn) {
|
||||
base.depends_on = data.dependsOn;
|
||||
}
|
||||
|
||||
if (data.isMain) {
|
||||
base.labels = getTraefikLabels({
|
||||
internalPort: data.internalPort,
|
||||
appId: data.name,
|
||||
exposedLocal: data.exposedLocal,
|
||||
exposed: data.exposed,
|
||||
});
|
||||
}
|
||||
|
||||
return base;
|
||||
});
|
||||
|
||||
export type ServiceInput = z.input<typeof serviceSchema>;
|
||||
|
||||
const getService = (params: ServiceInput) => {
|
||||
return serviceSchema.parse(params);
|
||||
};
|
||||
|
||||
export const getDockerCompose = (services: ServiceInput[], form: AppEventForm) => {
|
||||
// Format services with provided form data (add exposed field to main service)
|
||||
const formattedServices = services.map((service) => {
|
||||
if (service.isMain) {
|
||||
return Object.assign(service, {
|
||||
exposed: form.exposed,
|
||||
exposedLocal: form.exposedLocal,
|
||||
openPort: form.openPort,
|
||||
});
|
||||
}
|
||||
return service;
|
||||
});
|
||||
|
||||
return yaml.stringify({
|
||||
services: formattedServices.reduce(
|
||||
(acc, service) => {
|
||||
acc[service.name] = getService(service);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, unknown>,
|
||||
),
|
||||
networks: {
|
||||
tipi_main_network: {
|
||||
name: 'runtipi_tipi_main_network',
|
||||
external: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
@ -4,7 +4,7 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
import * as Sentry from '@sentry/node';
|
||||
import { execAsync, pathExists } from '@runtipi/shared/node';
|
||||
import { SocketEvent, sanitizePath } from '@runtipi/shared';
|
||||
import { AppEventForm, SocketEvent, sanitizePath } from '@runtipi/shared';
|
||||
import { copyDataDir, generateEnvFile } from './app.helpers';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { compose } from '@/lib/docker';
|
||||
@ -12,6 +12,7 @@ import { getEnv } from '@/lib/environment';
|
||||
import { SocketManager } from '@/lib/socket/SocketManager';
|
||||
import { getDbClient } from '@/lib/db';
|
||||
import { APP_DATA_DIR, DATA_DIR } from '@/config/constants';
|
||||
import { getDockerCompose } from '@/config/docker-templates';
|
||||
|
||||
export class AppExecutors {
|
||||
private readonly logger;
|
||||
@ -51,7 +52,7 @@ export class AppExecutors {
|
||||
* If not, copies the app folder from the repo
|
||||
* @param {string} appId - App id
|
||||
*/
|
||||
private ensureAppDir = async (appId: string) => {
|
||||
private ensureAppDir = async (appId: string, form: AppEventForm) => {
|
||||
const { appDirPath, appDataDirPath, repoPath } = this.getAppPaths(appId);
|
||||
const dockerFilePath = path.join(DATA_DIR, 'apps', sanitizePath(appId), 'docker-compose.yml');
|
||||
|
||||
@ -65,17 +66,28 @@ export class AppExecutors {
|
||||
await fs.promises.cp(repoPath, appDirPath, { recursive: true });
|
||||
}
|
||||
|
||||
// Check if app has a compose.json file
|
||||
if (await pathExists(path.join(repoPath, 'compose.json'))) {
|
||||
// Generate docker-compose.yml file
|
||||
const rawComposeConfig = await fs.promises.readFile(path.join(repoPath, 'compose.json'), 'utf-8');
|
||||
const jsonComposeConfig = JSON.parse(rawComposeConfig);
|
||||
|
||||
const composeFile = getDockerCompose(jsonComposeConfig.services, form);
|
||||
|
||||
await fs.promises.writeFile(dockerFilePath, composeFile);
|
||||
}
|
||||
|
||||
// Set permissions
|
||||
await execAsync(`chmod -Rf a+rwx ${path.join(appDataDirPath)}`).catch(() => {
|
||||
this.logger.error(`Error setting permissions for app ${appId}`);
|
||||
});
|
||||
};
|
||||
|
||||
public regenerateAppEnv = async (appId: string, config: Record<string, unknown>) => {
|
||||
public regenerateAppEnv = async (appId: string, form: AppEventForm) => {
|
||||
try {
|
||||
this.logger.info(`Regenerating app.env file for app ${appId}`);
|
||||
await this.ensureAppDir(appId);
|
||||
await generateEnvFile(appId, config);
|
||||
await this.ensureAppDir(appId, form);
|
||||
await generateEnvFile(appId, form);
|
||||
|
||||
SocketManager.emit({ type: 'app', event: 'generate_env_success', data: { appId } });
|
||||
return { success: true, message: `App ${appId} env file regenerated successfully` };
|
||||
@ -87,9 +99,9 @@ export class AppExecutors {
|
||||
/**
|
||||
* Install an app from the repo
|
||||
* @param {string} appId - The id of the app to install
|
||||
* @param {Record<string, unknown>} config - The config of the app
|
||||
* @param {AppEventForm} form - The config of the app
|
||||
*/
|
||||
public installApp = async (appId: string, config: Record<string, unknown>) => {
|
||||
public installApp = async (appId: string, form: AppEventForm) => {
|
||||
try {
|
||||
SocketManager.emit({ type: 'app', event: 'status_change', data: { appId } });
|
||||
|
||||
@ -129,7 +141,7 @@ export class AppExecutors {
|
||||
|
||||
// Create app.env file
|
||||
this.logger.info(`Creating app.env file for app ${appId}`);
|
||||
await generateEnvFile(appId, config);
|
||||
await generateEnvFile(appId, form);
|
||||
|
||||
// Copy data dir
|
||||
this.logger.info(`Copying data dir for app ${appId}`);
|
||||
@ -137,7 +149,7 @@ export class AppExecutors {
|
||||
await copyDataDir(appId);
|
||||
}
|
||||
|
||||
await this.ensureAppDir(appId);
|
||||
await this.ensureAppDir(appId, form);
|
||||
|
||||
// run docker-compose up
|
||||
this.logger.info(`Running docker-compose up for app ${appId}`);
|
||||
@ -156,9 +168,9 @@ export class AppExecutors {
|
||||
/**
|
||||
* Stops an app
|
||||
* @param {string} appId - The id of the app to stop
|
||||
* @param {Record<string, unknown>} config - The config of the app
|
||||
* @param {Record<string, unknown>} form - The config of the app
|
||||
*/
|
||||
public stopApp = async (appId: string, config: Record<string, unknown>, skipEnvGeneration = false) => {
|
||||
public stopApp = async (appId: string, form: AppEventForm, skipEnvGeneration = false) => {
|
||||
try {
|
||||
const { appDirPath } = this.getAppPaths(appId);
|
||||
const configJsonPath = path.join(appDirPath, 'config.json');
|
||||
@ -171,11 +183,11 @@ export class AppExecutors {
|
||||
SocketManager.emit({ type: 'app', event: 'status_change', data: { appId } });
|
||||
this.logger.info(`Stopping app ${appId}`);
|
||||
|
||||
await this.ensureAppDir(appId);
|
||||
await this.ensureAppDir(appId, form);
|
||||
|
||||
if (!skipEnvGeneration) {
|
||||
this.logger.info(`Regenerating app.env file for app ${appId}`);
|
||||
await generateEnvFile(appId, config);
|
||||
await generateEnvFile(appId, form);
|
||||
}
|
||||
await compose(appId, 'rm --force --stop');
|
||||
|
||||
@ -187,21 +199,22 @@ export class AppExecutors {
|
||||
await client?.query('UPDATE app SET status = $1 WHERE id = $2', ['stopped', appId]);
|
||||
return { success: true, message: `App ${appId} stopped successfully` };
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return this.handleAppError(err, appId, 'stop_error');
|
||||
}
|
||||
};
|
||||
|
||||
public startApp = async (appId: string, config: Record<string, unknown>, skipEnvGeneration = false) => {
|
||||
public startApp = async (appId: string, form: AppEventForm, skipEnvGeneration = false) => {
|
||||
try {
|
||||
SocketManager.emit({ type: 'app', event: 'status_change', data: { appId } });
|
||||
|
||||
this.logger.info(`Starting app ${appId}`);
|
||||
|
||||
await this.ensureAppDir(appId);
|
||||
await this.ensureAppDir(appId, form);
|
||||
|
||||
if (!skipEnvGeneration) {
|
||||
this.logger.info(`Regenerating app.env file for app ${appId}`);
|
||||
await generateEnvFile(appId, config);
|
||||
await generateEnvFile(appId, form);
|
||||
}
|
||||
|
||||
await compose(appId, 'up --detach --force-recreate --remove-orphans --pull always');
|
||||
@ -218,7 +231,7 @@ export class AppExecutors {
|
||||
}
|
||||
};
|
||||
|
||||
public uninstallApp = async (appId: string, config: Record<string, unknown>) => {
|
||||
public uninstallApp = async (appId: string, form: AppEventForm) => {
|
||||
try {
|
||||
SocketManager.emit({ type: 'app', event: 'status_change', data: { appId } });
|
||||
|
||||
@ -226,8 +239,8 @@ export class AppExecutors {
|
||||
this.logger.info(`Uninstalling app ${appId}`);
|
||||
|
||||
this.logger.info(`Regenerating app.env file for app ${appId}`);
|
||||
await this.ensureAppDir(appId);
|
||||
await generateEnvFile(appId, config);
|
||||
await this.ensureAppDir(appId, form);
|
||||
await generateEnvFile(appId, form);
|
||||
try {
|
||||
await compose(appId, 'down --remove-orphans --volumes --rmi all');
|
||||
} catch (err) {
|
||||
@ -260,14 +273,14 @@ export class AppExecutors {
|
||||
}
|
||||
};
|
||||
|
||||
public resetApp = async (appId: string, config: Record<string, unknown>) => {
|
||||
public resetApp = async (appId: string, form: AppEventForm) => {
|
||||
try {
|
||||
SocketManager.emit({ type: 'app', event: 'status_change', data: { appId } });
|
||||
|
||||
const { appDataDirPath } = this.getAppPaths(appId);
|
||||
this.logger.info(`Resetting app ${appId}`);
|
||||
await this.ensureAppDir(appId);
|
||||
await generateEnvFile(appId, config);
|
||||
await this.ensureAppDir(appId, form);
|
||||
await generateEnvFile(appId, form);
|
||||
|
||||
// Stop app
|
||||
try {
|
||||
@ -288,7 +301,7 @@ export class AppExecutors {
|
||||
|
||||
// Create app.env file
|
||||
this.logger.info(`Creating app.env file for app ${appId}`);
|
||||
await generateEnvFile(appId, config);
|
||||
await generateEnvFile(appId, form);
|
||||
|
||||
// Copy data dir
|
||||
this.logger.info(`Copying data dir for app ${appId}`);
|
||||
@ -296,7 +309,7 @@ export class AppExecutors {
|
||||
await copyDataDir(appId);
|
||||
}
|
||||
|
||||
await this.ensureAppDir(appId);
|
||||
await this.ensureAppDir(appId, form);
|
||||
|
||||
// run docker-compose up
|
||||
this.logger.info(`Running docker-compose up for app ${appId}`);
|
||||
@ -312,14 +325,14 @@ export class AppExecutors {
|
||||
}
|
||||
};
|
||||
|
||||
public updateApp = async (appId: string, config: Record<string, unknown>) => {
|
||||
public updateApp = async (appId: string, form: AppEventForm) => {
|
||||
try {
|
||||
SocketManager.emit({ type: 'app', event: 'status_change', data: { appId } });
|
||||
|
||||
const { appDirPath, repoPath } = this.getAppPaths(appId);
|
||||
this.logger.info(`Updating app ${appId}`);
|
||||
await this.ensureAppDir(appId);
|
||||
await generateEnvFile(appId, config);
|
||||
await this.ensureAppDir(appId, form);
|
||||
await generateEnvFile(appId, form);
|
||||
|
||||
try {
|
||||
await compose(appId, 'up --detach --force-recreate --remove-orphans');
|
||||
@ -334,7 +347,7 @@ export class AppExecutors {
|
||||
this.logger.info(`Copying folder ${repoPath} to ${appDirPath}`);
|
||||
await fs.promises.cp(repoPath, appDirPath, { recursive: true });
|
||||
|
||||
await this.ensureAppDir(appId);
|
||||
await this.ensureAppDir(appId, form);
|
||||
|
||||
await compose(appId, 'pull');
|
||||
|
||||
@ -371,7 +384,7 @@ export class AppExecutors {
|
||||
for (const row of rows) {
|
||||
const { id, config } = row;
|
||||
|
||||
const { success } = await this.startApp(id, config);
|
||||
const { success } = await this.startApp(id, config as AppEventForm);
|
||||
|
||||
if (!success) {
|
||||
this.logger.error(`Error starting app ${id}`);
|
||||
|
@ -1,7 +1,13 @@
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { appInfoSchema, envMapToString, envStringToMap, sanitizePath } from '@runtipi/shared';
|
||||
import {
|
||||
AppEventForm,
|
||||
appInfoSchema,
|
||||
envMapToString,
|
||||
envStringToMap,
|
||||
sanitizePath,
|
||||
} from '@runtipi/shared';
|
||||
import { pathExists, execAsync } from '@runtipi/shared/node';
|
||||
import { generateVapidKeys, getAppEnvMap } from './env.helpers';
|
||||
import { getEnv } from '@/lib/environment';
|
||||
@ -32,13 +38,15 @@ const getEntropy = async (name: string, length: number) => {
|
||||
* It also creates the app-data folder for the app if it does not exist
|
||||
*
|
||||
* @param {string} appId - The id of the app to generate the env file for.
|
||||
* @param {Record<string, unknown>} config - The config object for the app.
|
||||
* @param {AppEventForm} form - The config object for the app.
|
||||
* @throws Will throw an error if the app has an invalid config.json file or if a required variable is missing.
|
||||
*/
|
||||
export const generateEnvFile = async (appId: string, config: Record<string, unknown>) => {
|
||||
export const generateEnvFile = async (appId: string, form: AppEventForm) => {
|
||||
const { internalIp, appDataPath, rootFolderHost } = getEnv();
|
||||
|
||||
const configFile = await fs.promises.readFile(path.join(DATA_DIR, 'apps', sanitizePath(appId), 'config.json'));
|
||||
const configFile = await fs.promises.readFile(
|
||||
path.join(DATA_DIR, 'apps', sanitizePath(appId), 'config.json'),
|
||||
);
|
||||
const parsedConfig = appInfoSchema.safeParse(JSON.parse(configFile.toString()));
|
||||
|
||||
if (!parsedConfig.success) {
|
||||
@ -69,7 +77,7 @@ export const generateEnvFile = async (appId: string, config: Record<string, unkn
|
||||
|
||||
await Promise.all(
|
||||
parsedConfig.data.form_fields.map(async (field) => {
|
||||
const formValue = config[field.env_variable];
|
||||
const formValue = form[field.env_variable];
|
||||
const envVar = field.env_variable;
|
||||
|
||||
if (formValue || typeof formValue === 'boolean') {
|
||||
@ -89,11 +97,11 @@ export const generateEnvFile = async (appId: string, config: Record<string, unkn
|
||||
}),
|
||||
);
|
||||
|
||||
if (config.exposed && config.domain && typeof config.domain === 'string') {
|
||||
if (form.exposed && form.domain && typeof form.domain === 'string') {
|
||||
envMap.set('APP_EXPOSED', 'true');
|
||||
envMap.set('APP_DOMAIN', config.domain);
|
||||
envMap.set('APP_DOMAIN', form.domain);
|
||||
envMap.set('APP_PROTOCOL', 'https');
|
||||
envMap.set('APP_HOST', config.domain);
|
||||
envMap.set('APP_HOST', form.domain);
|
||||
} else {
|
||||
envMap.set('APP_DOMAIN', `${internalIp}:${parsedConfig.data.port}`);
|
||||
envMap.set('APP_HOST', internalIp);
|
||||
@ -101,12 +109,17 @@ export const generateEnvFile = async (appId: string, config: Record<string, unkn
|
||||
}
|
||||
|
||||
// Create app-data folder if it doesn't exist
|
||||
const appDataDirectoryExists = await fs.promises.stat(path.join(APP_DATA_DIR, sanitizePath(appId))).catch(() => false);
|
||||
const appDataDirectoryExists = await fs.promises
|
||||
.stat(path.join(APP_DATA_DIR, sanitizePath(appId)))
|
||||
.catch(() => false);
|
||||
if (!appDataDirectoryExists) {
|
||||
await fs.promises.mkdir(path.join(APP_DATA_DIR, sanitizePath(appId)), { recursive: true });
|
||||
}
|
||||
|
||||
await fs.promises.writeFile(path.join(APP_DATA_DIR, sanitizePath(appId), 'app.env'), envMapToString(envMap));
|
||||
await fs.promises.writeFile(
|
||||
path.join(APP_DATA_DIR, sanitizePath(appId), 'app.env'),
|
||||
envMapToString(envMap),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
@ -150,19 +163,32 @@ export const copyDataDir = async (id: string) => {
|
||||
|
||||
const processFile = async (file: string) => {
|
||||
if (file.endsWith('.template')) {
|
||||
const template = await fs.promises.readFile(path.join(DATA_DIR, 'apps', sanitizePath(id), 'data', file), 'utf-8');
|
||||
const template = await fs.promises.readFile(
|
||||
path.join(DATA_DIR, 'apps', sanitizePath(id), 'data', file),
|
||||
'utf-8',
|
||||
);
|
||||
const renderedTemplate = renderTemplate(template, envMap);
|
||||
|
||||
await fs.promises.writeFile(path.join(APP_DATA_DIR, sanitizePath(id), 'data', file.replace('.template', '')), renderedTemplate);
|
||||
await fs.promises.writeFile(
|
||||
path.join(APP_DATA_DIR, sanitizePath(id), 'data', file.replace('.template', '')),
|
||||
renderedTemplate,
|
||||
);
|
||||
} else {
|
||||
await fs.promises.copyFile(path.join(DATA_DIR, 'apps', sanitizePath(id), 'data', file), path.join(APP_DATA_DIR, sanitizePath(id), 'data', file));
|
||||
await fs.promises.copyFile(
|
||||
path.join(DATA_DIR, 'apps', sanitizePath(id), 'data', file),
|
||||
path.join(APP_DATA_DIR, sanitizePath(id), 'data', file),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const processDir = async (p: string) => {
|
||||
await fs.promises.mkdir(path.join(APP_DATA_DIR, sanitizePath(id), 'data', p), { recursive: true });
|
||||
await fs.promises.mkdir(path.join(APP_DATA_DIR, sanitizePath(id), 'data', p), {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
const files = await fs.promises.readdir(path.join(DATA_DIR, 'apps', sanitizePath(id), 'data', p));
|
||||
const files = await fs.promises.readdir(
|
||||
path.join(DATA_DIR, 'apps', sanitizePath(id), 'data', p),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
files.map(async (file) => {
|
||||
@ -191,6 +217,8 @@ export const copyDataDir = async (id: string) => {
|
||||
|
||||
// Remove any .gitkeep files from the app-data folder at any level
|
||||
if (await pathExists(path.join(APP_DATA_DIR, sanitizePath(id), 'data'))) {
|
||||
await execAsync(`find ${APP_DATA_DIR}/${sanitizePath(id)}/data -name .gitkeep -delete`).catch(() => {});
|
||||
await execAsync(`find ${APP_DATA_DIR}/${sanitizePath(id)}/data -name .gitkeep -delete`).catch(
|
||||
() => {},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
3254
pnpm-lock.yaml
3254
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user