From c8cfd32b3072202cb921cfdecd5b289b7e774d35 Mon Sep 17 00:00:00 2001 From: Savio Dias <91362589+Savio629@users.noreply.github.com> Date: Sat, 21 Sep 2024 16:17:41 +0530 Subject: [PATCH] [93] New Docker command to generate DockerFiles of specified services (#34) * feat: docker command to generate dockerfiles of services * fix: updated monitor to monitoring * fix: updated nest collection * feat: updated the stencil docker command * fix: switched to generate command structure * fix: console table when wrong command is sent * fix: updated existing unit test and implemented e2e test * fix: updated test * feat: Added support for path for A la carte setup * fix: added the docker command for temporal * fix: fixed docker flag * fix: updated test cases for docker command * fix: updated docker e2e test * fix: removed logging flags and command * fix: updated docker command e2e test * feat: Added Docker command readme * fix: added minio and fusionauth * fix: fix the e2e test * fix: increased the timeout * fix: updated the docs * fix: removed comments of logging * fix: updated readme * fix: for generation of docker using spec * fix: updated time for test --------- Co-authored-by: Yash Mittal --- Docker-readme.md | 73 ++++++++++ actions/docker.action.ts | 96 +++++++++++++ actions/generate.action.ts | 2 +- actions/index.ts | 1 + actions/new.action.ts | 123 ++-------------- commands/command.loader.ts | 3 + commands/docker.command.ts | 35 +++++ commands/generate.command.ts | 45 +----- commands/new.command.ts | 15 +- lib/monitoring/class.monitoring.ts | 15 +- lib/schematics/abstract.collection.ts | 5 +- lib/schematics/nest.collection.ts | 100 +++++++++---- lib/temporal/class.temporal.ts | 36 ++++- lib/ui/messages.ts | 3 + lib/ui/table.ts | 41 ++++++ package.json | 7 +- test/e2e-test/docker.e2e-spec.ts | 150 ++++++++++++++++++++ test/e2e-test/jest-e2e.json | 9 ++ test/lib/schematics/nest.collection.spec.ts | 145 ++++++++++++++----- 19 files changed, 658 insertions(+), 246 deletions(-) create mode 100644 Docker-readme.md create mode 100644 actions/docker.action.ts create mode 100644 commands/docker.command.ts create mode 100644 lib/ui/table.ts create mode 100644 test/e2e-test/docker.e2e-spec.ts create mode 100644 test/e2e-test/jest-e2e.json diff --git a/Docker-readme.md b/Docker-readme.md new file mode 100644 index 00000000..dc61e0d0 --- /dev/null +++ b/Docker-readme.md @@ -0,0 +1,73 @@ +## Docker Command + +``` +stencil docker [options] +stencil do [options] +``` +**Description** + +Creates a docker service/container for given command. +- Tooling specific setup + - Creates a folder with the given `` inside docker directory +- À la Carte setup / Adhoc setup + - Creates a docker compose or updates existing docker compose with desired `` + +**Arguments** + +| Argument | Description | +|-----------|--------------| +| `` | The name of the new project | + +**Options** + +| Option | Description | +|-----------|--------------| +| `--path [path]` | The path where the docker compose will be generated | + + +Note: Docker command supports multiple services at a time. + +**Services** + +- Tooling specific setup + + +| Name | Alias | Description | +|---|---|---| +| `logging` | `lg` | Generates a docker service for logging | +| `monitoringService` |`ms` | Generates a docker service for monitoring | +| `temporal` | `tp` | Generates a docker service for temporal | + +- À la Carte setup / Adhoc setup + + +| Name | Alias | Description | +|---|---|---| +| `postgres` | `pg` | Generate a docker compose for postgres | +| `hasura` |`hs` | Generate a docker compose for hasura | +| `minio` |`mi` | Generate a docker compose for minio | +| `fusionauth` |`fa` | Generate a docker compose for fusionauth | + + +## How to include new docker services ? + +**Stencil-cli** + +1. Include the docker service in `lib/schematics/nest.collection.ts` with its name, alias and description. + +**Schematics** +1. Create a folder inside of **schematics** package under `src/lib/docker/nameOfDockerService` +2. If the dockerService is a tooling setup then refer existing tooling setups such as temporal,monitoringService, logging. + - Create `src/lib/docker/files/ts/nameOfDockerService` and paste all the necessary files needed to be generated when the service is called. + - Create factory file, schema file and interface of the dockerService inside `src/lib/docker/nameOfDockerService` by refering existing tooling setup. + 3. If the dockerService is a adhoc setup then refer existing adhoc setup such as postgres, hasura. + - Create factory file, schema file and interface of the dockerService inside `src/lib/docker/nameOfDockerService` by refering existing adhoc setup. + - Paste the `docker-compose` and `.env` content inside of factory file refering existing adhoc setup. + +## Which files will be changed/updated? + +**Tooling setup** +- Basically whenever tooling setup is generated eg. `stencil docker temporal` , `docker/temporal` will be created. + +**Adhoc Setup** +- Whenever adhoc setup is generated eg. `stencil docker postgres` , then` docker-compose`, `.env` and `docker-start.sh` is generated/updated. diff --git a/actions/docker.action.ts b/actions/docker.action.ts new file mode 100644 index 00000000..433ec042 --- /dev/null +++ b/actions/docker.action.ts @@ -0,0 +1,96 @@ +import { + AbstractPackageManager, + PackageManagerFactory, +} from '../lib/package-managers'; +import { AbstractCollection, Collection, CollectionFactory, SchematicOption,} from '../lib/schematics'; +import { AbstractAction } from './abstract.action'; +import { Input } from '../commands'; +import * as chalk from 'chalk'; +import * as fs from 'fs'; +import * as yaml from 'js-yaml'; +import { join } from 'path'; + +export class DockerAction extends AbstractAction { + private manager!: AbstractPackageManager; + public async handle(commandInputs: Input[],options: Input[]) { + this.manager = await PackageManagerFactory.find(); + + const serviceName = commandInputs[0].value as string; + + const specFilePath = await this.getSpecFilePath(); + + if (specFilePath) { + await this.updateSpecFile(specFilePath, serviceName); + } + + const collection: AbstractCollection = CollectionFactory.create( + Collection.NESTJS, + ); + const schematicOptions: SchematicOption[] = this.mapSchematicOptions(commandInputs); + schematicOptions.push( + new SchematicOption('language', 'ts'), + ); + const path = options.find((option) => option.name === 'path')!.value as string; + schematicOptions.push(new SchematicOption('path', path)); + try { + await collection.execute(commandInputs[0].value as string, schematicOptions,'docker'); + }catch(error){ + if (error && error.message) { + console.error(chalk.red(error.message)); + } + } + } + private async getSpecFilePath(): Promise { + try { + const nestCliConfig = await fs.promises.readFile('nest-cli.json', 'utf-8'); + const config = JSON.parse(nestCliConfig); + if (config.specFile) { + return config.specFile; + } else { + return null; + } + } catch (error) { + console.error(chalk.red('Error reading nest-cli.json'), error); + return null; + } + } + + private async updateSpecFile(specFilePath: string, serviceName: string): Promise { + try { + const specFileFullPath = join(process.cwd(), specFilePath); + const specFileContent = await fs.promises.readFile(specFileFullPath, 'utf-8'); + const spec = yaml.load(specFileContent) as any; + + if (!spec.docker) { + spec.docker = []; + } + + let updated = false; + if (!spec.docker.includes(serviceName)) { + spec.docker.push(serviceName); + updated = true; + } + + if (updated) { + const updatedYaml = yaml.dump(spec); + await fs.promises.writeFile(specFileFullPath, updatedYaml, 'utf-8'); + } + + return updated; + } catch (error) { + console.error(chalk.red('Error reading or updating spec.yaml'), error); + return false; + } + } + + private mapSchematicOptions = (inputs: Input[]): SchematicOption[] => { + const excludedInputNames = ['path','schematic', 'spec', 'flat', 'specFileSuffix']; + const options: SchematicOption[] = []; + inputs.forEach((input) => { + if (!excludedInputNames.includes(input.name) && input.value !== undefined) { + options.push(new SchematicOption(input.name, input.value)); + } + }); + return options; +}; +} \ No newline at end of file diff --git a/actions/generate.action.ts b/actions/generate.action.ts index 22c3ff01..92879a94 100644 --- a/actions/generate.action.ts +++ b/actions/generate.action.ts @@ -136,7 +136,7 @@ const generateFiles = async (inputs: Input[]) => { if (!schematicInput) { throw new Error('Unable to find a schematic for this configuration'); } - await collection.execute(schematicInput.value as string, schematicOptions); + await collection.execute(schematicInput.value as string, schematicOptions, 'schematic'); } catch (error) { if (error && error.message) { console.error(chalk.red(error.message)); diff --git a/actions/index.ts b/actions/index.ts index 29083e82..84db39e6 100644 --- a/actions/index.ts +++ b/actions/index.ts @@ -6,3 +6,4 @@ export * from './list.action'; export * from './new.action'; export * from './start.action'; export * from './add.action'; +export * from './docker.action'; \ No newline at end of file diff --git a/actions/new.action.ts b/actions/new.action.ts index ca418e4e..8b834861 100644 --- a/actions/new.action.ts +++ b/actions/new.action.ts @@ -49,6 +49,9 @@ export class NewAction extends AbstractAction { const shouldSkipGit = options.some( (option) => option.name === 'skip-git' && option.value === true, ); + const shouldSkipDocker = options.some( + (option) => option.name === 'skip-docker' && option.value === true, + ); const shouldInitializePrima = options.some( (option) => option.name === 'prisma' && option.value === 'yes', @@ -62,21 +65,14 @@ export class NewAction extends AbstractAction { (option) => option.name === 'fixtures' && option.value === 'yes', ); - const shouldInstallMonitoring = options.some( - (option) => option.name === 'monitoring' && option.value === 'yes', - ); - const shouldInitializeMonitoring = options.some( - (option) => option.name === 'monitoringService' && option.value === 'yes', + (option) => option.name === 'monitoring' && option.value === 'yes', ); const shouldInitializeTemporal = options.some( (option) => option.name === 'temporal' && option.value === 'yes', ); - const shouldInitializeLogging = options.some( - (option) => option.name === 'logging' && option.value === 'yes', - ); const shouldInitializeFileUpload = options.some( (option) => option.name === 'fileUpload' && option.value === 'yes', @@ -111,30 +107,20 @@ export class NewAction extends AbstractAction { ); } - await installMonitoring( - isDryRunEnabled as boolean, - projectDirectory, - shouldInstallMonitoring as boolean, - ); - await createMonitor( isDryRunEnabled as boolean, projectDirectory, - shouldInstallMonitoring as boolean, shouldInitializeMonitoring as boolean, + shouldSkipDocker as boolean, ); await createTemporal( isDryRunEnabled as boolean, projectDirectory, shouldInitializeTemporal as boolean, + shouldSkipDocker as boolean, ); - await createLogging( - isDryRunEnabled as boolean, - projectDirectory, - shouldInitializeLogging as boolean, - ); await createFileUpload( isDryRunEnabled as boolean, @@ -146,7 +132,7 @@ export class NewAction extends AbstractAction { if (!shouldSkipGit) { await initializeGitRepository(projectDirectory); await createGitIgnoreFile(projectDirectory); - await createRegistry(projectDirectory, shouldInitializePrima,shouldInitializeUserService,shouldInstallMonitoring, shouldInitializeMonitoring,shouldInitializeTemporal,shouldInitializeLogging,shouldInitializeFileUpload); + await createRegistry(projectDirectory, shouldInitializePrima,shouldInitializeUserService,shouldInitializeMonitoring,shouldInitializeTemporal,shouldInitializeFileUpload); await copyEnvFile(projectDirectory, 'env-example', '.env'); } @@ -177,14 +163,10 @@ const getFixturesInput = (inputs: Input[]) => const getMonitoringInput = (inputs: Input[]) => inputs.find((options) => options.name === 'monitoring'); -const getMonitoringServiceInput = (inputs: Input[]) => - inputs.find((options) => options.name === 'monitoringService'); const getTemporalInput = (inputs: Input[]) => inputs.find((options) => options.name === 'temporal'); -const getLoggingInput = (inputs: Input[]) => - inputs.find((options) => options.name === 'logging'); const getFileUploadInput = (inputs: Input[]) => inputs.find((options) => options.name === 'fileUpload'); @@ -239,11 +221,6 @@ const askForMissingInformation = async (inputs: Input[], options: Input[]) => { replaceInputMissingInformation(options, answers); } - const monitoringServiceInput = getMonitoringServiceInput(options); - if (!monitoringServiceInput!.value) { - const answers = await askForMonitoringService(); - replaceInputMissingInformation(options, answers); - } const temporalInput = getTemporalInput(options); if (!temporalInput!.value) { @@ -251,12 +228,6 @@ const askForMissingInformation = async (inputs: Input[], options: Input[]) => { replaceInputMissingInformation(options, answers); } - const loggingInput = getLoggingInput(options); - if (!loggingInput!.value) { - const answers = await askForLogging(); - replaceInputMissingInformation(options, answers); - } - const fileUploadInput = getFileUploadInput(options); if (!fileUploadInput!.value) { const answers = await askForFileUpload(); @@ -291,7 +262,7 @@ const generateApplicationFiles = async (args: Input[], options: Input[]) => { const schematicOptions: SchematicOption[] = mapSchematicOptions( args.concat(options), ); - await collection.execute('application', schematicOptions); + await collection.execute('application', schematicOptions, 'schematic'); console.info(); }; @@ -422,37 +393,13 @@ const createFixtures = async ( } }; -const installMonitoring = async ( - dryRunMode: boolean, - createDirectory: string, - shouldInstallMonitoring: boolean, -) => { - if (!shouldInstallMonitoring) { - return; - } - - if (dryRunMode) { - console.info(); - console.info(chalk.green(MESSAGES.DRY_RUN_MODE)); - console.info(); - return; - } - - const MonitoringInstance = new ClassMonitoring(); - try { - await MonitoringInstance.addImport(createDirectory); - } catch (error) { - console.error('could not modify the app.module with monitoring'); - } -}; - const createMonitor = async ( dryRunMode: boolean, createDirectory: string, - shouldInstallMonitoring: boolean, shouldInitializeMonitoring: boolean, + shouldSkipDocker : boolean, ) => { - if (!shouldInstallMonitoring || !shouldInitializeMonitoring) { + if ( !shouldInitializeMonitoring) { return; } @@ -465,7 +412,7 @@ const createMonitor = async ( const MonitoringInstance = new ClassMonitoring(); try { - await MonitoringInstance.createFiles(createDirectory); + await MonitoringInstance.createFiles(createDirectory, shouldSkipDocker); } catch (error) { console.error('could not generate the monitor files'); } @@ -475,6 +422,7 @@ const createTemporal = async ( dryRunMode: boolean, createDirectory: string, shouldInitializeTemporal: boolean, + shouldSkipDocker : boolean, ) => { if (!shouldInitializeTemporal) { return; @@ -489,35 +437,12 @@ const createTemporal = async ( const TemporalInstance = new ClassTemporal(); try { - await TemporalInstance.create(createDirectory); + await TemporalInstance.create(createDirectory,shouldSkipDocker); } catch (error) { console.error('could not create the temporal files'); } }; -const createLogging = async ( - dryRunMode: boolean, - createDirectory: string, - shouldInitializeLogging: boolean, -) => { - if (!shouldInitializeLogging) { - return; - } - - if (dryRunMode) { - console.info(); - console.info(chalk.green(MESSAGES.DRY_RUN_MODE)); - console.info(); - return; - } - - const LoggingInstance = new ClassLogging(); - try { - await LoggingInstance.create(createDirectory); - } catch (error) { - console.error('could not create the logging folder'); - } -}; const createFileUpload = async ( dryRunMode: boolean, @@ -609,16 +534,6 @@ const askForMonitoring = async (): Promise => { return await prompt(questions); }; -const askForMonitoringService = async (): Promise => { - const questions: Question[] = [ - generateSelect('monitoringService')(MESSAGES.MONITORING_SERVICE_QUESTION)([ - 'yes', - 'no', - ]), - ]; - const prompt = inquirer.createPromptModule(); - return await prompt(questions); -}; const askForTemporal = async (): Promise => { const questions: Question[] = [ @@ -628,14 +543,6 @@ const askForTemporal = async (): Promise => { return await prompt(questions); }; -const askForLogging = async (): Promise => { - const questions: Question[] = [ - generateSelect('logging')(MESSAGES.LOGGING_QUESTION)(['yes', 'no']), - ]; - const prompt = inquirer.createPromptModule(); - return await prompt(questions); -}; - const askForFileUpload = async (): Promise => { const questions: Question[] = [ generateSelect('fileUpload')(MESSAGES.FILE_UPLOAD_QUESTION)(['yes', 'no']), @@ -694,10 +601,8 @@ const createRegistry = async ( dir: string, shouldInitializePrisma: boolean, shouldInitializeUserService: boolean, - shouldInstallMonitoring: boolean, shouldInitializeMonitoring: boolean, shouldInitializeTemporal: boolean, - shouldInitializeLogging: boolean, shouldInitializeFileUpload: boolean ): Promise => { const filePath = join(process.cwd(), dir, '.stencil'); @@ -705,10 +610,8 @@ const createRegistry = async ( const setupInfo = [ shouldInitializePrisma ? 'Prisma Setup' : '', shouldInitializeUserService ? 'User Services Setup' : '', - shouldInstallMonitoring ? 'Monitoring Installed' : '', shouldInitializeMonitoring ? 'Monitoring Setup' : '', shouldInitializeTemporal ? 'Temporal Setup' : '', - shouldInitializeLogging ? 'Logging Setup' : '', shouldInitializeFileUpload ? 'File Upload Setup' : '' ].filter(info => info !== '').join('\n'); diff --git a/commands/command.loader.ts b/commands/command.loader.ts index deb98590..c1f53731 100644 --- a/commands/command.loader.ts +++ b/commands/command.loader.ts @@ -8,6 +8,7 @@ import { ListAction, NewAction, StartAction, + DockerAction, } from '../actions'; import { ERROR_PREFIX } from '../lib/ui'; import { AddCommand } from './add.command'; @@ -17,6 +18,7 @@ import { InfoCommand } from './info.command'; import { ListCommand } from './list.command'; import { NewCommand } from './new.command'; import { StartCommand } from './start.command'; +import { DockerCommand } from './docker.command'; export class CommandLoader { public static async load(program: CommanderStatic): Promise { new NewCommand(new NewAction()).load(program); @@ -25,6 +27,7 @@ export class CommandLoader { new InfoCommand(new InfoAction()).load(program); new ListCommand(new ListAction()).load(program); new AddCommand(new AddAction()).load(program); + new DockerCommand(new DockerAction()).load(program); await new GenerateCommand(new GenerateAction()).load(program); this.handleInvalidCommand(program); diff --git a/commands/docker.command.ts b/commands/docker.command.ts new file mode 100644 index 00000000..c7efcb89 --- /dev/null +++ b/commands/docker.command.ts @@ -0,0 +1,35 @@ +import { Command, CommanderStatic } from 'commander'; +import { AbstractCommand } from './abstract.command'; +import { Input } from './command.input'; +import { getCollection, getSchematics, buildSchematicsListAsTable } from '../lib/ui/table'; + +export class DockerCommand extends AbstractCommand { + public async load(program: CommanderStatic) { + program + .command('docker ') + .alias('d') + .description(await this.buildDescription()) + .option( + '-p, --path [path]', + 'define the path to the docker file', + ) + .action(async (services: string[],command: Command) => { + const options: Input[] = []; + options.push({ + name: 'path', + value: command.path, + }); + const inputs: Input[] = services.map(service => ({ name: 'name', value: service })); + for (const input of inputs) { + await this.action.handle([input],options); + } + }); + } + private async buildDescription(): Promise { + const collection = await getCollection(); + return ( + 'Generate a docker service.\n \n' + + buildSchematicsListAsTable(await getSchematics(collection, 'docker')) + ); + } +} diff --git a/commands/generate.command.ts b/commands/generate.command.ts index d6613afe..572fb3b3 100644 --- a/commands/generate.command.ts +++ b/commands/generate.command.ts @@ -1,11 +1,8 @@ import * as chalk from 'chalk'; -import * as Table from 'cli-table3'; import { Command, CommanderStatic } from 'commander'; -import { AbstractCollection, CollectionFactory } from '../lib/schematics'; -import { Schematic } from '../lib/schematics/nest.collection'; -import { loadConfiguration } from '../lib/utils/load-configuration'; import { AbstractCommand } from './abstract.command'; import { Input } from './command.input'; +import { getCollection, getSchematics, buildSchematicsListAsTable } from '../lib/ui/table'; export class GenerateCommand extends AbstractCommand { public async load(program: CommanderStatic): Promise { @@ -104,47 +101,11 @@ export class GenerateCommand extends AbstractCommand { } private async buildDescription(): Promise { - const collection = await this.getCollection(); + const collection = await getCollection(); return ( 'Generate a Nest element.\n' + ` Schematics available on ${chalk.bold(collection)} collection:\n` + - this.buildSchematicsListAsTable(await this.getSchematics(collection)) + buildSchematicsListAsTable(await getSchematics(collection, 'schematic')) ); } - - private buildSchematicsListAsTable(schematics: Schematic[]): Promise { - const leftMargin = ' '; - const tableConfig = { - head: ['name', 'alias', 'description'], - chars: { - 'left': leftMargin.concat('│'), - 'top-left': leftMargin.concat('┌'), - 'bottom-left': leftMargin.concat('└'), - 'mid': '', - 'left-mid': '', - 'mid-mid': '', - 'right-mid': '', - }, - }; - const table: any = new Table(tableConfig); - for (const schematic of schematics) { - table.push([ - chalk.green(schematic.name), - chalk.cyan(schematic.alias), - schematic.description, - ]); - } - return table.toString(); - } - - private async getCollection(): Promise { - const configuration = await loadConfiguration(); - return configuration.collection; - } - - private async getSchematics(collection: string): Promise { - const abstractCollection: AbstractCollection = - CollectionFactory.create(collection); - return abstractCollection.getSchematics(); - } } diff --git a/commands/new.command.ts b/commands/new.command.ts index 6afbdb2a..1994e0bc 100644 --- a/commands/new.command.ts +++ b/commands/new.command.ts @@ -17,6 +17,7 @@ export class NewCommand extends AbstractCommand { ) .option('-g, --skip-git', 'Skip git repository initialization.', false) .option('-s, --skip-install', 'Skip package installation.', false) + .option('--sd, --skip-docker', 'Skip docker setup.', false) .option( '-p, --package-manager [packageManager]', 'Specify package manager.', @@ -48,18 +49,10 @@ export class NewCommand extends AbstractCommand { '--mo, --monitoring [monitoring]', 'If you want to have monitoring setup in the project', ) - .option( - '--ms, --monitoringService [monitoringService]', - 'If you want to have monitoring-service setup in the project', - ) .option( '--te, --temporal [temporal]', 'If you want to have temporal setup in the project', ) - .option( - '--lg, --logging [logging]', - 'If you want to have logging setup in the project', - ) .option( '--fu, --fileUpload [fileUpload]', 'If you want to have fileUpload setup in the project', @@ -71,6 +64,7 @@ export class NewCommand extends AbstractCommand { options.push({ name: 'dry-run', value: command.dryRun }); options.push({ name: 'skip-git', value: command.skipGit }); options.push({ name: 'skip-install', value: command.skipInstall }); + options.push({ name: 'skip-docker', value: command.skipDocker }); options.push({ name: 'strict', value: command.strict }); options.push({ name: 'packageManager', @@ -81,12 +75,7 @@ export class NewCommand extends AbstractCommand { options.push({ name: 'userService', value: command.userService }); options.push({ name: 'fixtures', value: command.fixtures }); options.push({ name: 'monitoring', value: command.monitoring }); - options.push({ - name: 'monitoringService', - value: command.monitoringService, - }); options.push({ name: 'temporal', value: command.temporal }); - options.push({ name: 'logging', value: command.logging }); options.push({ name: 'fileUpload', value: command.fileUpload }); if (!!command.language) { diff --git a/lib/monitoring/class.monitoring.ts b/lib/monitoring/class.monitoring.ts index ce47e5c5..109a72f8 100644 --- a/lib/monitoring/class.monitoring.ts +++ b/lib/monitoring/class.monitoring.ts @@ -3,7 +3,6 @@ import { join } from 'path'; import { MESSAGES } from '../ui'; import { normalizeToKebabOrSnakeCase } from '../utils/formatting'; import { StencilRunner } from '../runners/stencil.runner'; - export class ClassMonitoring { public async addImport(directory: string) { const normalizedDirectory = normalizeToKebabOrSnakeCase(directory); @@ -18,7 +17,7 @@ export class ClassMonitoring { public async initializeImport(normalizedDirectory: string): Promise { const stencilRunner = new StencilRunner(); - const userServiceCommand = 'g monitorModule'; + const userServiceCommand = 'g monitoring'; const commandArgs = `${userServiceCommand}`; try { @@ -32,10 +31,11 @@ export class ClassMonitoring { } } - public async createFiles(directory: string) { + public async createFiles(directory: string, shouldSkipDocker : boolean,) { const normalizedDirectory = normalizeToKebabOrSnakeCase(directory); try { - await this.generateFiles(normalizedDirectory); + await this.addImport(normalizedDirectory); + await this.generateFiles(normalizedDirectory,shouldSkipDocker); } catch (error) { console.error('Failed to create the monitor files'); } @@ -43,9 +43,12 @@ export class ClassMonitoring { console.info('Successfully created the monitor files'); } - public async generateFiles(normalizedDirectory: string): Promise { + public async generateFiles(normalizedDirectory: string,shouldSkipDocker: boolean): Promise { + if(shouldSkipDocker){ + return; + } const stencilRunner = new StencilRunner(); - const userServiceCommand = 'g monitor'; + const userServiceCommand = 'docker monitoringService'; const commandArgs = `${userServiceCommand}`; try { diff --git a/lib/schematics/abstract.collection.ts b/lib/schematics/abstract.collection.ts index bf268583..1e2982d2 100644 --- a/lib/schematics/abstract.collection.ts +++ b/lib/schematics/abstract.collection.ts @@ -1,5 +1,5 @@ import { AbstractRunner } from '../runners'; -import { Schematic } from './nest.collection'; +import { Docker, Schematic } from './nest.collection'; import { SchematicOption } from './schematic.option'; export abstract class AbstractCollection { @@ -17,8 +17,7 @@ export abstract class AbstractCollection { command = extraFlags ? command.concat(` ${extraFlags}`) : command; await this.runner.run(command); } - - public abstract getSchematics(): Schematic[]; + public abstract getSchematics(type: 'schematic' | 'docker'): Schematic[]|Docker[]; private buildCommandLine(name: string, options: SchematicOption[]): string { return `${this.collection}:${name}${this.buildOptions(options)}`; diff --git a/lib/schematics/nest.collection.ts b/lib/schematics/nest.collection.ts index 9bcf7538..8c0f665d 100644 --- a/lib/schematics/nest.collection.ts +++ b/lib/schematics/nest.collection.ts @@ -1,15 +1,21 @@ import { AbstractRunner } from '../runners'; import { AbstractCollection } from './abstract.collection'; import { SchematicOption } from './schematic.option'; - +import * as chalk from 'chalk'; +import { buildSchematicsListAsTable, getCollection, getSchematics } from '../ui/table'; export interface Schematic { name: string; alias: string; description: string; } +export interface Docker { + name: string; + alias: string; + description: string; +} export class NestCollection extends AbstractCollection { - private static schematics: Schematic[] = [ + public static schematics: Schematic[] = [ { name: 'application', alias: 'application', @@ -146,12 +152,7 @@ export class NestCollection extends AbstractCollection { description: 'Generate files in a .devcontainer file.', }, { - name: 'monitorModule', - alias: 'mm', - description: 'add monitor related imports to the app.module.ts file ', - }, - { - name: 'monitor', + name: 'monitoring', alias: 'mf', description: 'Generate monitor folder.', }, @@ -160,42 +161,85 @@ export class NestCollection extends AbstractCollection { alias: 'te', description: 'If you want to have temporal setup in the project.', }, + { + name: 'service-file-upload', + alias: 'fu', + description: 'If you want to have fileUpload setup in the project.', + }, + ]; + public static docker: Docker[] = [ + { + name: 'docker-app', + alias: 'do-app', + description: '', + }, { name: 'logging', alias: 'lg', - description: 'If you want to have logging setup in the project.', + description: 'Generate a docker service for logging', }, { - name: 'service-file-upload', - alias: 'fu', - description: 'If you want to have fileUpload setup in the project.', + name: 'monitoringService', + alias: 'ms', + description: 'Generate a docker service for monitoringService', + }, + { + name: 'temporal', + alias: 'tp', + description: 'Generate a docker service for temporal', + }, + { + name: 'postgres', + alias: 'pg', + description: 'Generate a docker compose for postgres', + }, + { + name: 'hasura', + alias: 'hs', + description: 'Generate a docker compose for hasura', + }, + { + name: 'minio', + alias: 'mi', + description: 'Generate a docker compose for minio', + }, + { + name: 'fusionauth', + alias: 'fa', + description: 'Generate a docker compose for fusionauth', }, ]; - constructor(runner: AbstractRunner) { super('@samagra-x/schematics', runner); } - public async execute(name: string, options: SchematicOption[]) { - const schematic: string = this.validate(name); + public async execute(name: string, options: SchematicOption[],type: 'schematic' | 'docker') { + const schematic: string = await this.validate(name,type); await super.execute(schematic, options); } - public getSchematics(): Schematic[] { - return NestCollection.schematics.filter( - (item) => item.name !== 'angular-app', - ); - } + public getSchematics(type: 'schematic' | 'docker'): Schematic[]|Docker[] { + if (type === 'schematic') { + return NestCollection.schematics.filter( + (item) => item.name !== 'angular-app', + ); + } else if (type === 'docker') { + return NestCollection.docker.filter( + (item) => item.name !== 'docker-app', + ); + } else { + throw new Error('Invalid type specified'); + } - private validate(name: string) { - const schematic = NestCollection.schematics.find( - (s) => s.name === name || s.alias === name, - ); + } - if (schematic === undefined || schematic === null) { - throw new Error( - `Invalid schematic "${name}". Please, ensure that "${name}" exists in this collection.`, - ); + private async validate(name: string, type: 'schematic' | 'docker'): Promise { + const schematic = (type === 'schematic' ? NestCollection.schematics : NestCollection.docker) + .find((s) => s.name === name || s.alias === name); + if (!schematic) { + const collection = await getCollection(); + console.info(buildSchematicsListAsTable(await getSchematics(collection, type))); + throw new Error(`Invalid schematic "${name}". Please, ensure that "${name}" exists in this collection.`); } return schematic.name; } diff --git a/lib/temporal/class.temporal.ts b/lib/temporal/class.temporal.ts index f8b92b53..8eafd315 100644 --- a/lib/temporal/class.temporal.ts +++ b/lib/temporal/class.temporal.ts @@ -3,13 +3,14 @@ import { join } from 'path'; import { MESSAGES } from '../ui'; import { normalizeToKebabOrSnakeCase } from '../utils/formatting'; import { StencilRunner } from '../runners/stencil.runner'; - export class ClassTemporal { - public async create(directory: string) { + public async create(directory: string,shouldSkipDocker: boolean) { const normalizedDirectory = normalizeToKebabOrSnakeCase(directory); try { - await this.createFileUpload(normalizedDirectory); + await this.createTemporal(normalizedDirectory); + await this.dockerSetup(normalizedDirectory,shouldSkipDocker); + } catch (error) { console.error('Failed setting up temporal'); } @@ -17,20 +18,41 @@ export class ClassTemporal { console.info('Successfully setup temporal in the project'); } - public async createFileUpload(normalizedDirectory: string): Promise { - console.info(chalk.grey(MESSAGES.HUSKY_INITIALISATION_START)); + public async createTemporal(normalizedDirectory: string): Promise { + console.info(chalk.grey(MESSAGES.TEMPORAL_START)); const stencilRunner = new StencilRunner(); const stencilCmd = 'g service-temporal'; - try { + try { await stencilRunner.run( stencilCmd, false, join(process.cwd(), normalizedDirectory), ); } catch (error) { - console.error(chalk.red(MESSAGES.HUSKY_INITIALISATION_ERROR)); + console.error(chalk.red(MESSAGES.TEMPORAL_ERROR)); } } + public async dockerSetup(normalizedDirectory: string,shouldSkipDocker: boolean): Promise { + if(shouldSkipDocker){ + return; + } + console.info(chalk.grey(MESSAGES.TEMPORAL_START)); + + const stencilRunner = new StencilRunner(); + const stencilCmd = 'docker temporal'; + + try { + await stencilRunner.run( + stencilCmd, + false, + join(process.cwd(), normalizedDirectory), + ); + } catch (error) { + console.error(chalk.red(MESSAGES.TEMPORAL_ERROR)); + } + } + + } diff --git a/lib/ui/messages.ts b/lib/ui/messages.ts index 5b71b41e..93635436 100644 --- a/lib/ui/messages.ts +++ b/lib/ui/messages.ts @@ -57,6 +57,9 @@ export const MESSAGES = { FILE_UPLOAD_QUESTION: 'Do you want to setup file upload in this project ?', FILE_UPLOAD_START: 'Starting to add file upload to app.module.ts', FILE_UPLOAD_ERROR: "Couldn't add file upload to app.module.ts", + NO_VALID_SERVICES_PROVIDED:'No valid services found to generate Docker files', + DOCKER_FILES_GENERATED: (name: string) => `Generated Docker files for ${name}`, + DOCKER_FILES_GENERATION_ERROR: (name: string) => `Failed to generate Docker files for ${name}`, PACKAGE_MANAGER_INSTALLATION_IN_PROGRESS: `Installation in progress... ${EMOJIS.COFFEE}`, PACKAGE_MANAGER_UPDATE_IN_PROGRESS: `Installation in progress... ${EMOJIS.COFFEE}`, PACKAGE_MANAGER_UPGRADE_IN_PROGRESS: `Installation in progress... ${EMOJIS.COFFEE}`, diff --git a/lib/ui/table.ts b/lib/ui/table.ts new file mode 100644 index 00000000..ab904a39 --- /dev/null +++ b/lib/ui/table.ts @@ -0,0 +1,41 @@ +// table.utils.ts +import * as chalk from 'chalk'; +import * as Table from 'cli-table3'; +import { Schematic,Docker} from '../schematics/nest.collection'; +import { loadConfiguration } from '../utils/load-configuration'; +import { AbstractCollection, CollectionFactory } from '../schematics'; + +export async function getCollection(): Promise { + const configuration = await loadConfiguration(); + return configuration.collection; +} + +export async function getSchematics(collection: string, type: 'schematic' | 'docker'): Promise { + const abstractCollection: AbstractCollection = CollectionFactory.create(collection); + return abstractCollection.getSchematics(type); +} + +export function buildSchematicsListAsTable(schematics: Schematic[]): string { + const leftMargin = ' '; + const tableConfig = { + head: ['name', 'alias', 'description'], + chars: { + 'left': leftMargin.concat('│'), + 'top-left': leftMargin.concat('┌'), + 'bottom-left': leftMargin.concat('└'), + 'mid': '', + 'left-mid': '', + 'mid-mid': '', + 'right-mid': '', + }, + }; + const table: any = new Table(tableConfig); + for (const schematic of schematics) { + table.push([ + chalk.green(schematic.name), + chalk.cyan(schematic.alias), + schematic.description, + ]); + } + return table.toString(); +} diff --git a/package.json b/package.json index 47ccbf64..e028f375 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "prepublish:npm": "npm run build", "test": "jest --config test/jest-config.json", "test:dev": "npm run clean && jest --config test/jest-config.json --watchAll", + "test:e2e": "jest --config test/e2e-test/jest-e2e.json", "prerelease": "npm run build", "release": "release-it", "prepare": "husky install" @@ -44,6 +45,7 @@ "cli-table3": "0.6.3", "commander": "4.1.1", "fork-ts-checker-webpack-plugin": "8.0.0", + "fs-extra": "^11.2.0", "inquirer": "8.2.6", "node-emoji": "1.11.0", "ora": "5.4.1", @@ -63,9 +65,11 @@ "@commitlint/config-angular": "17.7.0", "@swc/cli": "0.1.62", "@swc/core": "1.3.91", + "@types/fs-extra": "^11.0.4", "@types/inquirer": "9.0.3", "@types/jest": "29.5.5", - "@types/node": "18.18.3", + "@types/js-yaml": "^4.0.9", + "@types/node": "^18.19.41", "@types/node-emoji": "1.8.2", "@types/shelljs": "0.8.13", "@types/webpack-node-externals": "3.0.2", @@ -78,6 +82,7 @@ "gulp-clean": "0.4.0", "husky": "8.0.3", "jest": "29.7.0", + "js-yaml": "^4.1.0", "lint-staged": "14.0.1", "prettier": "3.0.3", "release-it": "16.2.1", diff --git a/test/e2e-test/docker.e2e-spec.ts b/test/e2e-test/docker.e2e-spec.ts new file mode 100644 index 00000000..168eee3d --- /dev/null +++ b/test/e2e-test/docker.e2e-spec.ts @@ -0,0 +1,150 @@ +import { exec } from 'child_process'; +import { join } from 'path'; +import { existsSync, readFileSync, rmSync } from 'fs'; +import * as yaml from 'js-yaml'; + +describe('Stencil cli e2e Test - DOCKER command', () => { + it('should run the Stencil CLI with the "docker" command and log the output', (done) => { + exec('stencil docker', (error, stdout, stderr) => { + expect(stderr).toContain("missing required argument 'services'"); + done(); + }); + }); + + it('should throw error for invalid "docker" command and log the output', (done) => { + const service = 'service1'; + exec(`stencil docker ${service}`, (error, stdout, stderr) => { + expect(stdout).toContain(''); + expect(stderr).toContain(`Invalid schematic "${service}". Please, ensure that "${service}" exists in this collection.`); + done(); + }); + }); + + it('should run "docker" command with tooling services and check if the folder is generated', (done) => { + const services = ['monitoringService', 'logging', 'temporal']; + const child = exec(`stencil docker ${services.join(' ')}`, (error, stdout, stderr) => { + if (error) { + console.error(`exec error: ${error}`); + child.kill(); + done(error); + return; + } + expect(stdout).toContain(''); + services.forEach((service) => { + expect(stderr).not.toContain(`Invalid schematic "${service}". Please, ensure that "${service}" exists in this collection.`); + const dockerFolder = join(process.cwd(), 'docker', service); + expect(existsSync(dockerFolder)).toBe(true); + rmSync(dockerFolder, { recursive: true, force: true }); + }); + rmSync(join(process.cwd(), 'docker'), { recursive: true, force: true }); + + child.kill(); + done(); + }); + },15000 ); + + it('should run "docker" command with adhoc services and check if the docker compose is updated', (done) => { + const services = ['postgres', 'hasura']; + const child = exec(`stencil docker ${services.join(' ')}`, (error, stdout, stderr) => { + if (error) { + console.error(`exec error: ${error}`); + child.kill(); + done(error); + return; + } + services.forEach((service) => { + expect(stderr).not.toContain(`Invalid schematic "${service}". Please, ensure that "${service}" exists in this collection.`); + }); + + const dockerComposeFile = join(process.cwd(), 'docker-compose.yml'); + expect(existsSync(dockerComposeFile)).toBe(true); + + const dockerComposeContent = readFileSync(dockerComposeFile, 'utf8'); + const dockerComposeConfig = yaml.load(dockerComposeContent) as any; + + expect(dockerComposeConfig.services.postgres).toBeDefined(); + expect(dockerComposeConfig.services.hasura).toBeDefined(); + + rmSync(dockerComposeFile, { force: true }); + rmSync(join(process.cwd(),'.env'), { recursive: true, force: true }); + rmSync(join(process.cwd(),'docker-start.sh'), { recursive: true, force: true }); + + child.kill(); + done(); + }); + }, 10000); + + it('should throw error for "docker" command with wrong path flag', (done) => { + const services = ['postgres', 'hasura']; + const child = exec(`stencil docker ${services.join(' ')} --path`, (error, stdout, stderr) => { + services.forEach((service) => { + expect(stderr).toContain(`Schematic input does not validate against the Schema: {"name":"${service}","language":"ts","path":true}`); + expect(stderr).toContain(`Data path "/path" must be string.`); + }); + + child.kill(); + done(); + }); + }, 10000); + + it('should run "docker" command with path flag', (done) => { + const services = ['postgres', 'hasura']; + const child = exec(`stencil docker ${services.join(' ')} --path temp`, (error, stdout, stderr) => { + if (error) { + console.error(`exec error: ${error}`); + child.kill(); + done(error); + return; + } + services.forEach((service) => { + expect(stderr).not.toContain(`Invalid schematic "${service}". Please, ensure that "${service}" exists in this collection.`); + }); + + const dockerComposeFile = join(process.cwd(),'temp', 'docker-compose.yml'); + expect(existsSync(dockerComposeFile)).toBe(true); + + const dockerComposeContent = readFileSync(dockerComposeFile, 'utf8'); + const dockerComposeConfig = yaml.load(dockerComposeContent) as any; + + expect(dockerComposeConfig.services.postgres).toBeDefined(); + expect(dockerComposeConfig.services.hasura).toBeDefined(); + + rmSync(dockerComposeFile, { force: true }); + rmSync(join(process.cwd(),'temp','.env'), { recursive: true, force: true }); + rmSync(join(process.cwd(),'docker-start.sh'), { recursive: true, force: true }); + rmSync(join(process.cwd(), 'temp'), { recursive: true, force: true }); + + child.kill(); + done(); + }); + },10000 ); + it('should not skip the docker files when --skip-docker flag is not used', (done) => { + exec(`stencil new service1 --ps no --us no --mo no --te yes --fu no --package-manager npm`, (error, stdout, stderr) => { + const serviceDir = join(process.cwd(), 'service1'); + const dockerDir = join(serviceDir, 'services'); + + expect(existsSync(serviceDir)).toBe(true); + + expect(existsSync(dockerDir)).toBe(false); + rmSync(join(process.cwd(), 'service1'), { recursive: true, force: true }); + + done(); + }); + },150000); + + +it('should skip the docker files when --skip-docker flag is used', (done) => { + exec(`stencil new service2 --skip-docker --ps no --us no --mo no --te yes --fu no --package-manager npm`, (error, stdout, stderr) => { + const serviceDir = join(process.cwd(), 'service2'); + const dockerDir = join(serviceDir, 'services'); + + expect(existsSync(serviceDir)).toBe(true); + + expect(existsSync(dockerDir)).toBe(false); + rmSync(join(process.cwd(), 'service2'), { recursive: true, force: true }); + + done(); + }); + },100000); + +}); diff --git a/test/e2e-test/jest-e2e.json b/test/e2e-test/jest-e2e.json new file mode 100644 index 00000000..e9d912f3 --- /dev/null +++ b/test/e2e-test/jest-e2e.json @@ -0,0 +1,9 @@ +{ + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": ".", + "testEnvironment": "node", + "testRegex": ".e2e-spec.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + } +} diff --git a/test/lib/schematics/nest.collection.spec.ts b/test/lib/schematics/nest.collection.spec.ts index ee898364..483cd29e 100644 --- a/test/lib/schematics/nest.collection.spec.ts +++ b/test/lib/schematics/nest.collection.spec.ts @@ -2,7 +2,7 @@ import { AbstractRunner } from '../../../lib/runners'; import { NestCollection } from '../../../lib/schematics/nest.collection'; describe('Nest Collection', () => { - [ + const schematics = [ 'application', 'class', 'configuration', @@ -22,24 +22,19 @@ describe('Nest Collection', () => { 'service', 'sub-app', 'resource', - ].forEach((schematic) => { - it(`should call runner with ${schematic} schematic name`, async () => { - const mock = jest.fn(); - mock.mockImplementation(() => { - return { - logger: {}, - run: jest.fn().mockImplementation(() => Promise.resolve()), - }; - }); - const mockedRunner = mock(); - const collection = new NestCollection(mockedRunner as AbstractRunner); - await collection.execute(schematic, []); - expect(mockedRunner.run).toHaveBeenCalledWith( - `@samagra-x/schematics:${schematic}`, - ); - }); - }); - [ + 'service-prisma', + 'service-user', + 'fixtures', + 'husky', + 'github', + 'prisma', + 'devcontainer', + 'monitoring', + 'service-temporal', + 'service-file-upload', + ]; + + const aliases = [ { name: 'application', alias: 'application' }, { name: 'class', alias: 'cl' }, { name: 'configuration', alias: 'config' }, @@ -59,38 +54,118 @@ describe('Nest Collection', () => { { name: 'service', alias: 's' }, { name: 'sub-app', alias: 'app' }, { name: 'resource', alias: 'res' }, - ].forEach((schematic) => { + ]; + + const dockerSchematics = [ + 'logging', + 'monitoringService', + 'temporal', + 'postgres', + 'hasura', + ]; + + const dockerAliases = [ + { name: 'logging', alias: 'lg' }, + { name: 'monitoringService', alias: 'ms' }, + { name: 'temporal', alias: 'tp' }, + { name: 'postgres', alias: 'pg' }, + { name: 'hasura', alias: 'hs' }, + ]; + + schematics.forEach((schematic) => { + it(`should call runner with ${schematic} schematic name`, async () => { + const mock = jest.fn(); + mock.mockImplementation(() => ({ + logger: {}, + run: jest.fn().mockImplementation(() => Promise.resolve()), + })); + const mockedRunner = mock(); + const collection = new NestCollection(mockedRunner as AbstractRunner); + await collection.execute(schematic, [], 'schematic'); + expect(mockedRunner.run).toHaveBeenCalledWith( + `@samagra-x/schematics:${schematic}`, + ); + }); + }); + + aliases.forEach((schematic) => { it(`should call runner with schematic ${schematic.name} name when use ${schematic.alias} alias`, async () => { const mock = jest.fn(); - mock.mockImplementation(() => { - return { - logger: {}, - run: jest.fn().mockImplementation(() => Promise.resolve()), - }; - }); + mock.mockImplementation(() => ({ + logger: {}, + run: jest.fn().mockImplementation(() => Promise.resolve()), + })); const mockedRunner = mock(); const collection = new NestCollection(mockedRunner as AbstractRunner); - await collection.execute(schematic.alias, []); + await collection.execute(schematic.alias, [], 'schematic'); expect(mockedRunner.run).toHaveBeenCalledWith( `@samagra-x/schematics:${schematic.name}`, ); }); }); - it('should throw an error when schematic name is not in nest collection', async () => { - const mock = jest.fn(); - mock.mockImplementation(() => { - return { + + dockerSchematics.forEach((schematic) => { + it(`should call runner with ${schematic} docker schematic name`, async () => { + const mock = jest.fn(); + mock.mockImplementation(() => ({ logger: {}, run: jest.fn().mockImplementation(() => Promise.resolve()), - }; + })); + const mockedRunner = mock(); + const collection = new NestCollection(mockedRunner as AbstractRunner); + await collection.execute(schematic, [], 'docker'); + expect(mockedRunner.run).toHaveBeenCalledWith( + `@samagra-x/schematics:${schematic}`, + ); + }); + }); + + dockerAliases.forEach((schematic) => { + it(`should call runner with docker schematic ${schematic.name} name when use ${schematic.alias} alias`, async () => { + const mock = jest.fn(); + mock.mockImplementation(() => ({ + logger: {}, + run: jest.fn().mockImplementation(() => Promise.resolve()), + })); + const mockedRunner = mock(); + const collection = new NestCollection(mockedRunner as AbstractRunner); + await collection.execute(schematic.alias, [], 'docker'); + expect(mockedRunner.run).toHaveBeenCalledWith( + `@samagra-x/schematics:${schematic.name}`, + ); }); + }); + + it('should throw an error when schematic name is not in nest collection', async () => { + const mock = jest.fn(); + mock.mockImplementation(() => ({ + logger: {}, + run: jest.fn().mockImplementation(() => Promise.resolve()), + })); + const mockedRunner = mock(); + const collection = new NestCollection(mockedRunner as AbstractRunner); + try { + await collection.execute('invalid-schematic', [], 'schematic'); + } catch (error) { + expect(error.message).toContain( + 'Invalid schematic "invalid-schematic". Please, ensure that "invalid-schematic" exists in this collection.', + ); + } + }); + + it('should throw an error when docker name is not in nest collection', async () => { + const mock = jest.fn(); + mock.mockImplementation(() => ({ + logger: {}, + run: jest.fn().mockImplementation(() => Promise.resolve()), + })); const mockedRunner = mock(); const collection = new NestCollection(mockedRunner as AbstractRunner); try { - await collection.execute('name', []); + await collection.execute('invalid-docker', [], 'docker'); } catch (error) { - expect(error.message).toEqual( - 'Invalid schematic "name". Please, ensure that "name" exists in this collection.', + expect(error.message).toContain( + 'Invalid schematic "invalid-docker". Please, ensure that "invalid-docker" exists in this collection.', ); } });