feat(worker): create new docker template yaml generation

This commit is contained in:
Nicolas Meienberger 2024-04-24 08:18:01 +02:00 committed by Nicolas Meienberger
parent ede5bf5f8a
commit 9814ec78cb
11 changed files with 3673 additions and 128 deletions

View File

@ -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';

View File

@ -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()];

View File

@ -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

View File

@ -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(),

View File

@ -2,5 +2,5 @@ module.exports = {
singleQuote: true,
semi: true,
trailingComma: 'all',
printWidth: 200,
printWidth: 100,
};

View File

@ -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"
}
}

View 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
"
`);
});
});

View 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,
},
},
});
};

View File

@ -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}`);

View File

@ -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(
() => {},
);
}
};

File diff suppressed because it is too large Load Diff