import CommanderCommand from '../../CommanderCommand'; import Config from '../../../config/Config'; import fs from 'node:fs'; import ora from 'ora'; import util from 'util'; import { exec } from 'child_process'; import chalk from 'chalk'; import * as os from 'os'; import path from 'path'; import ClientsSharedConfig from '../../../sharedByClients/config/ClientsSharedConfig'; import AssignmentFile from '../../../shared/types/Dojo/AssignmentFile'; import ExerciseDockerCompose from '../../../sharedByClients/helpers/Dojo/ExerciseDockerCompose'; import SharedAssignmentHelper from '../../../shared/helpers/Dojo/SharedAssignmentHelper'; import ExerciseCheckerError from '../../../shared/types/Dojo/ExerciseCheckerError'; import ClientsSharedExerciseHelper from '../../../sharedByClients/helpers/Dojo/ClientsSharedExerciseHelper'; import ExerciseResultsSanitizerAndValidator from '../../../sharedByClients/helpers/Dojo/ExerciseResultsSanitizerAndValidator'; const execAsync = util.promisify(exec); class ExerciseRunCommand extends CommanderCommand { protected commandName: string = 'run'; private readonly dateISOString: string = (new Date()).toISOString().replace(/:/g, '_').replace(/\./g, '_'); private readonly folderResultsVolume: string = path.join(os.homedir(), 'DojoExecutions', `dojo_execLogs_${ this.dateISOString }`); private readonly folderResultsDojo: string = path.join(this.folderResultsVolume, `Dojo/`); private readonly folderResultsExercise: string = path.join(this.folderResultsVolume, `Exercise/`); private readonly projectName: string = `${ ClientsSharedConfig.dockerCompose.projectName }`; private readonly fileComposeLogs: string = path.join(this.folderResultsDojo, `dockerComposeLogs.txt`); protected defineCommand() { this.command .description('locally run an exercise') .option('-p, --path <value>', 'exercise path', Config.folders.defaultLocalExercise) .option('-v, --verbose', 'verbose mode (display docker compose logs in live)') .action(this.commandAction.bind(this)); } private displayExecutionLogs() { ora({ text : `${ chalk.magenta('Execution logs folder:') } ${ this.folderResultsVolume }`, indent: 0 }).start().info(); } protected async commandAction(options: { path: string, verbose: boolean }): Promise<void> { const localExercisePath: string = options.path ?? Config.folders.defaultLocalExercise; let assignmentFile: AssignmentFile; let exerciseDockerCompose: ExerciseDockerCompose; let exerciseResultsValidation: ExerciseResultsSanitizerAndValidator; let haveResultsVolume: boolean; // Step 1: Check requirements (if it's an exercise folder and if Docker daemon is running) { console.log(chalk.cyan('Please wait while we are checking and creating dependencies...')); // Create result temp folder fs.mkdirSync(this.folderResultsVolume, { recursive: true }); fs.mkdirSync(this.folderResultsDojo, { recursive: true }); fs.mkdirSync(this.folderResultsExercise, { recursive: true }); ora({ text : `Checking exercise content:`, indent: 4 }).start().info(); // Exercise folder { const spinner: ora.Ora = ora({ text : `Checking exercise folder`, indent: 8 }).start(); const files = fs.readdirSync(options.path); const missingFiles = Config.exercise.neededFiles.map((file: string): [ string, boolean ] => [ file, files.includes(file) ]).filter((file: [ string, boolean ]) => !file[1]); if ( missingFiles.length > 0 ) { spinner.fail(`The exercise folder is missing the following files: ${ missingFiles.map((file: [ string, boolean ]) => file[0]).join(', ') }`); return; } spinner.succeed(`The exercise folder contains all the needed files`); } // dojo_assignment.json validity { const spinner: ora.Ora = ora({ text : `Checking ${ ClientsSharedConfig.assignment.filename } file`, indent: 8 }).start(); const validationResults = SharedAssignmentHelper.validateDescriptionFile(path.join(options.path, ClientsSharedConfig.assignment.filename)); if ( !validationResults.isValid ) { spinner.fail(`The ${ ClientsSharedConfig.assignment.filename } file is invalid: ${ JSON.stringify(validationResults.errors) }`); return; } else { assignmentFile = validationResults.results!; } haveResultsVolume = assignmentFile.result.volume !== undefined; spinner.succeed(`The ${ ClientsSharedConfig.assignment.filename } file is valid`); } // Docker daemon { const spinner: ora.Ora = ora({ text : `Checking Docker daemon`, indent: 4 }).start(); try { await execAsync(`docker ps`); } catch ( error ) { spinner.fail(`The Docker daemon is not running`); return; } spinner.succeed(`The Docker daemon is running`); } } // Step 2: Run docker-compose file { console.log(chalk.cyan('Please wait while we are running the exercise...')); let composeFileOverride: string[] = []; const composeOverridePath: string = path.join(localExercisePath, 'docker-compose-override.yml'); if ( haveResultsVolume ) { const composeOverride = fs.readFileSync(path.join(__dirname, '../../../../assets/docker-compose-override.yml'), 'utf8').replace('{{VOLUME_NAME}}', assignmentFile.result.volume!).replace('{{MOUNT_PATH}}', this.folderResultsExercise); fs.writeFileSync(composeOverridePath, composeOverride); composeFileOverride = [ composeOverridePath ]; } exerciseDockerCompose = new ExerciseDockerCompose(this.projectName, assignmentFile, localExercisePath, composeFileOverride); try { await new Promise<void>((resolve, reject) => { let spinner: ora.Ora; if ( options.verbose ) { exerciseDockerCompose.events.on('logs', (log: string, _error: boolean, displayable: boolean) => { if ( displayable ) { console.log(log); } }); } exerciseDockerCompose.events.on('step', (name: string, message: string) => { spinner = ora({ text : message, indent: 4 }).start(); if ( options.verbose && name == 'COMPOSE_RUN' ) { spinner.info(); } }); exerciseDockerCompose.events.on('endStep', (stepName: string, message: string, error: boolean) => { if ( error ) { if ( options.verbose && stepName == 'COMPOSE_RUN' ) { ora({ text : message, indent: 4 }).start().fail(); } else { spinner.fail(message); } } else { if ( options.verbose && stepName == 'COMPOSE_RUN' ) { ora({ text : message, indent: 4 }).start().succeed(); } else { spinner.succeed(message); } } }); exerciseDockerCompose.events.on('finished', (success: boolean) => { success ? resolve() : reject(); }); exerciseDockerCompose.run(true); }); } catch ( error ) { /* empty */ } fs.rmSync(composeOverridePath, { force: true }); fs.writeFileSync(this.fileComposeLogs, exerciseDockerCompose.allLogs); if ( !exerciseDockerCompose.success ) { this.displayExecutionLogs(); return; } } // Step 3: Get results { console.log(chalk.cyan('Please wait while we are checking the results...')); exerciseResultsValidation = new ExerciseResultsSanitizerAndValidator(this.folderResultsDojo, this.folderResultsExercise, exerciseDockerCompose.exitCode); try { await new Promise<void>((resolve, reject) => { let spinner: ora.Ora; exerciseResultsValidation.events.on('step', (name: string, message: string) => { spinner = ora({ text : message, indent: 4 }).start(); }); exerciseResultsValidation.events.on('endStep', (stepName: string, message: string, error: boolean) => { if ( error ) { if ( stepName == 'CHECK_SIZE' ) { spinner.warn(message); } else { spinner.fail(message); } } else { spinner.succeed(message); } }); exerciseResultsValidation.events.on('finished', (success: boolean, exitCode: number) => { success || exitCode == ExerciseCheckerError.EXERCISE_RESULTS_FOLDER_TOO_BIG ? resolve() : reject(); }); exerciseResultsValidation.run(); }); } catch ( error ) { this.displayExecutionLogs(); return; } } // Step 4: Display results + Volume location { ClientsSharedExerciseHelper.displayExecutionResults(exerciseResultsValidation.exerciseResults!, exerciseDockerCompose.exitCode, { INFO : chalk.bold, SUCCESS: chalk.green, FAILURE: chalk.red }, `\n\n${ chalk.bold('Execution results folder') } : ${ this.folderResultsVolume }`); } } } export default new ExerciseRunCommand();