diff --git a/helpers/Dojo/AssignmentValidator.ts b/helpers/Dojo/AssignmentValidator.ts new file mode 100644 index 0000000000000000000000000000000000000000..c904a29fca936f9ddd886cc9c518ab8385c826c4 --- /dev/null +++ b/helpers/Dojo/AssignmentValidator.ts @@ -0,0 +1,258 @@ +import { TypedEmitter } from 'tiny-typed-emitter'; +import AssignmentValidatorEvents from '../../types/Dojo/AssignmentValidatorEvents'; +import SharedAssignmentHelper from '../../../shared/helpers/Dojo/SharedAssignmentHelper'; +import path from 'node:path'; +import AssignmentCheckerError from '../../../shared/types/Dojo/AssignmentCheckerError'; +import fs from 'fs-extra'; +import JSON5 from 'json5'; +import ClientsSharedConfig from '../../config/ClientsSharedConfig'; +import YAML from 'yaml'; +import DojoDockerCompose from '../../types/Dojo/DojoDockerCompose'; +import { exec, spawn } from 'child_process'; +import AssignmentFile from '../../../shared/types/Dojo/AssignmentFile'; +import ExerciseDockerCompose from './ExerciseDockerCompose'; +import util from 'util'; + + +const execAsync = util.promisify(exec); + + +class AssignmentValidator { + readonly events: TypedEmitter<AssignmentValidatorEvents> = new TypedEmitter<AssignmentValidatorEvents>(); + + public displayableLogs: string = ''; + public allLogs: string = ''; + + public isFinished: boolean = false; + public success: boolean = false; + public exitCode: number = -1; + + constructor(private folderAssignment: string) { + this.events.on('logs', (log: string, _error: boolean, displayable: boolean) => { + this.allLogs += log; + this.displayableLogs += displayable ? log : ''; + }); + + this.events.on('finished', (success: boolean, exitCode: number) => { + this.isFinished = true; + this.success = success; + this.exitCode = exitCode; + }); + } + + run(doDown: boolean = false) { + (async () => { + let dockerComposeFile: DojoDockerCompose; + let assignmentFile: AssignmentFile; + + const emitError = (subStepName: string, subStepMessage: string, stepName: string, stepMessage: string, code: AssignmentCheckerError) => { + this.events.emit('endSubStep', subStepName, subStepMessage, true); + this.events.emit('endStep', stepName, stepMessage, true); + this.events.emit('finished', false, code); + }; + + + /* + //////////////////////////////////////////////////////////////////////////////////////////////////////////// Step 1: Check requirements + - Check if Docker daemon is running + - Check if required files exists + */ + { + this.events.emit('step', 'REQUIREMENTS_CHECKING', 'Please wait while we are checking if Docker daemon is running...'); + + + // Check requirements + this.events.emit('subStep', 'DOCKER_RUNNING', 'Checking if Docker daemon is running'); + try { + await execAsync(`cd "${ this.folderAssignment }";docker ps`); + } catch ( error ) { + emitError('DOCKER_RUNNING', `Docker daemon isn't running`, 'REQUIREMENTS_CHECKING', `Some requirements are not satisfied.`, AssignmentCheckerError.DOCKER_DAEMON_NOT_RUNNING); + return; + } + this.events.emit('endSubStep', 'DOCKER_RUNNING', 'Docker daemon is running', false); + + + // Check if required files exists + this.events.emit('subStep', 'REQUIRED_FILES_EXISTS', 'Checking if required files exists'); + const files = fs.readdirSync(this.folderAssignment); + const missingFiles = ClientsSharedConfig.assignment.neededFiles.map((file: string): [ string, boolean ] => [ file, files.includes(file) ]).filter((file: [ string, boolean ]) => !file[1]); + + if ( missingFiles.length > 0 ) { + emitError('REQUIRED_FILES_EXISTS', `The exercise folder is missing the following files: ${ missingFiles.map((file: [ string, boolean ]) => file[0]).join(', ') }`, 'REQUIREMENTS_CHECKING', 'Some requirements are not satisfied', AssignmentCheckerError.REQUIRED_FILES_MISSING); + return; + } + this.events.emit('endSubStep', 'REQUIRED_FILES_EXISTS', 'All required files exists', false); + + + this.events.emit('endStep', 'REQUIREMENTS_CHECKING', 'All requirements are satisfied', false); + } + + + /* + //////////////////////////////////////////////////////////////////////////////////////////////////////////// Step 2: dojo_assignment.json file validation + - Structure validation + - Immutable files validation (Check if exists and if the given type is correct) + */ + { + this.events.emit('step', 'ASSIGNMENT_FILE_VALIDATION', 'Please wait while we are validating dojo_assignment.json file...'); + + + // Structure validation + this.events.emit('subStep', 'ASSIGNMENT_FILE_SCHEMA_VALIDATION', 'Validating dojo_assignment.json file schema'); + const validationResults = SharedAssignmentHelper.validateDescriptionFile(path.join(this.folderAssignment, ClientsSharedConfig.assignment.filename)); + if ( !validationResults.isValid ) { + emitError('ASSIGNMENT_FILE_SCHEMA_VALIDATION', `dojo_assignment.json file schema is invalid.\nHere are the errors:\n${ JSON5.stringify(validationResults.errors) }`, 'ASSIGNMENT_FILE_VALIDATION', 'dojo_assignment.json file is invalid', AssignmentCheckerError.ASSIGNMENT_FILE_SCHEMA_ERROR); + return; + } + assignmentFile = validationResults.results!; + this.events.emit('endSubStep', 'ASSIGNMENT_FILE_SCHEMA_VALIDATION', 'dojo_assignment.json file schema is valid', false); + + + // Immutable files validation (Check if exists and if the given type is correct) + this.events.emit('subStep', 'ASSIGNMENT_FILE_IMMUTABLES_VALIDATION', 'Validating immutable files'); + for ( const immutable of validationResults.results!.immutable ) { + const immutablePath = path.join(this.folderAssignment, immutable.path); + if ( !fs.existsSync(immutablePath) ) { + emitError('ASSIGNMENT_FILE_IMMUTABLES_VALIDATION', `Immutable path not found: ${ immutable.path }`, 'ASSIGNMENT_FILE_VALIDATION', 'dojo_assignment.json file is invalid', AssignmentCheckerError.IMMUTABLE_PATH_NOT_FOUND); + return; + } + + const isDirectory = fs.lstatSync(immutablePath).isDirectory(); + if ( isDirectory && !immutable.isDirectory ) { + emitError('ASSIGNMENT_FILE_IMMUTABLES_VALIDATION', `Immutable (${ immutable.path }) is declared as a file but is a directory.`, 'ASSIGNMENT_FILE_VALIDATION', 'dojo_assignment.json file is invalid', AssignmentCheckerError.IMMUTABLE_PATH_IS_NOT_DIRECTORY); + return; + } else if ( !isDirectory && immutable.isDirectory === true ) { + emitError('ASSIGNMENT_FILE_IMMUTABLES_VALIDATION', `Immutable (${ immutable.path }) is declared as a directory but is a file.`, 'ASSIGNMENT_FILE_VALIDATION', 'dojo_assignment.json file is invalid', AssignmentCheckerError.IMMUTABLE_PATH_IS_DIRECTORY); + return; + } + } + this.events.emit('endSubStep', 'ASSIGNMENT_FILE_IMMUTABLES_VALIDATION', 'Immutable files are valid', false); + + + this.events.emit('endStep', 'ASSIGNMENT_FILE_VALIDATION', 'dojo_assignment.json file is valid', false); + } + + + /* + //////////////////////////////////////////////////////////////////////////////////////////////////////////// Step 3: Docker Compose file validation + - Global validation + - Validation of the containers and volumes named in dojo_assignment.json + */ + { + this.events.emit('step', 'DOCKER_COMPOSE_VALIDATION', 'Please wait while we are validating docker compose file...'); + + + // Global validation + this.events.emit('subStep', 'DOCKER_COMPOSE_STRUCTURE_VALIDATION', 'Docker compose file structure validation'); + try { + dockerComposeFile = YAML.parse(fs.readFileSync(path.join(this.folderAssignment, 'docker-compose.yml'), 'utf8')) as DojoDockerCompose; + } catch ( error ) { + emitError('DOCKER_COMPOSE_STRUCTURE_VALIDATION', `Docker compose file yaml structure is invalid.`, 'DOCKER_COMPOSE_VALIDATION', 'Docker compose file is invalid', AssignmentCheckerError.COMPOSE_FILE_YAML_ERROR); + return; + } + + try { + await new Promise<void>((resolve, reject) => { + const dockerComposeValidation = spawn(`docker compose -f docker-compose.yml config --quiet`, { + cwd : this.folderAssignment, + shell: true + }); + + dockerComposeValidation.on('exit', (code) => { + code !== null && code == 0 ? resolve() : reject(); + }); + }); + } catch ( error ) { + emitError('DOCKER_COMPOSE_STRUCTURE_VALIDATION', `Docker compose file structure is invalid.`, 'DOCKER_COMPOSE_VALIDATION', 'Docker compose file is invalid', AssignmentCheckerError.COMPOSE_FILE_SCHEMA_ERROR); + return; + } + this.events.emit('endSubStep', 'DOCKER_COMPOSE_STRUCTURE_VALIDATION', 'Docker compose file structure is valid', false); + + + // Validation of the containers and volumes named in dojo_assignment.json + this.events.emit('subStep', 'DOCKER_COMPOSE_CONTENT_VALIDATION', 'Docker compose file content validation'); + if ( !(assignmentFile.result.container in dockerComposeFile!.services) ) { + emitError('DOCKER_COMPOSE_CONTENT_VALIDATION', `Container specified in ${ ClientsSharedConfig.assignment.filename } is missing from compose file.`, 'DOCKER_COMPOSE_VALIDATION', 'Docker compose file is invalid', AssignmentCheckerError.COMPOSE_FILE_CONTAINER_MISSING); + return; + } + if ( assignmentFile.result.volume && (!dockerComposeFile!.volumes || !(assignmentFile.result.volume in dockerComposeFile!.volumes)) ) { + emitError('DOCKER_COMPOSE_CONTENT_VALIDATION', `Volume specified in ${ ClientsSharedConfig.assignment.filename } is missing from compose file.`, 'DOCKER_COMPOSE_VALIDATION', 'Docker compose file is invalid', AssignmentCheckerError.COMPOSE_FILE_VOLUME_MISSING); + return; + } + this.events.emit('endSubStep', 'DOCKER_COMPOSE_CONTENT_VALIDATION', 'Docker compose file content is valid', false); + + + this.events.emit('endStep', 'DOCKER_COMPOSE_VALIDATION', 'Docker compose file is valid', false); + } + + + /* + //////////////////////////////////////////////////////////////////////////////////////////////////////////// Step 4: Dockerfiles validation + - Check if file exists + - TODO - Dockerfile structure linter - Issue #51 - https://github.com/hadolint/hadolint + */ + { + this.events.emit('step', 'DOCKERFILE_VALIDATION', 'Please wait while we are validating dockerfiles...'); + + + this.events.emit('subStep', 'DOCKERFILE_EXIST', 'Docker compose file content validation'); + const dockerfilesPaths = Object.values(dockerComposeFile!.services).filter((service) => service.build).map((service) => path.join(this.folderAssignment, service.build!.context ?? '', service.build!.dockerfile!)); + const filesNotFound = dockerfilesPaths.filter((dockerfilePath) => !fs.existsSync(dockerfilePath)); + if ( filesNotFound.length > 0 ) { + emitError('DOCKERFILE_VALIDATION', `Dockerfiles not found: ${ filesNotFound.join(', ') }`, 'DOCKERFILE_VALIDATION', 'Dockerfiles are invalid', AssignmentCheckerError.DOCKERFILE_NOT_FOUND); + return; + } + this.events.emit('endSubStep', 'DOCKER_COMPOSE_CONTENT_VALIDATION', 'Docker compose file content is valid', false); + + + this.events.emit('endStep', 'DOCKERFILE_VALIDATION', 'Dockerfiles are valid', false); + } + + + /* + //////////////////////////////////////////////////////////////////////////////////////////////////////////// Step 5: Run + - Make a run of the assignment (If the return code is 0, the assignment is not valid because it means that there no need of modification for succeed the exercise) + */ + { + this.events.emit('step', 'ASSIGNMENT_RUN', 'Please wait while we are running the assignment...'); + + + const exerciseDockerCompose = new ExerciseDockerCompose(ClientsSharedConfig.dockerCompose.projectName, assignmentFile, this.folderAssignment); + + try { + await new Promise<void>((resolve, reject) => { + exerciseDockerCompose.events.on('logs', (log: string, error: boolean, displayable: boolean) => { + this.events.emit('logs', log, error, displayable); + }); + + exerciseDockerCompose.events.on('step', (name: string, message: string) => { + this.events.emit('subStep', name, message); + }); + + exerciseDockerCompose.events.on('endStep', (stepName: string, message: string, error: boolean) => { + this.events.emit('endSubStep', stepName, message, error); + }); + + exerciseDockerCompose.events.on('finished', (success: boolean, exitCode: number) => { + exitCode != 0 ? resolve() : reject(); + }); + + exerciseDockerCompose.run(doDown); + }); + } catch ( error ) { + this.events.emit('endStep', 'ASSIGNMENT_RUN', 'Assignment is already solved', true); + this.events.emit('finished', false, AssignmentCheckerError.COMPOSE_RUN_SUCCESSFULLY); + } + + + this.events.emit('endStep', 'ASSIGNMENT_RUN', 'Assignment run successfully', false); + } + + + this.events.emit('finished', true, 0); + })(); + } +} + + +export default AssignmentValidator; \ No newline at end of file diff --git a/types/Dojo/AssignmentValidatorEvents.ts b/types/Dojo/AssignmentValidatorEvents.ts new file mode 100644 index 0000000000000000000000000000000000000000..eb13e4ad0c1497bb23f23feb89cbd0c4e02115cd --- /dev/null +++ b/types/Dojo/AssignmentValidatorEvents.ts @@ -0,0 +1,11 @@ +interface AssignmentValidatorEvents { + step: (name: string, message: string) => void; + subStep: (name: string, message: string) => void; + endStep: (subStepName: string, message: string, error: boolean) => void; + endSubStep: (subStepName: string, message: string, error: boolean) => void; + logs: (log: string, error: boolean, displayable: boolean) => void; + finished: (success: boolean, exitCode: number) => void; +} + + +export default AssignmentValidatorEvents; \ No newline at end of file diff --git a/types/Dojo/DojoDockerCompose.ts b/types/Dojo/DojoDockerCompose.ts new file mode 100644 index 0000000000000000000000000000000000000000..03b83f014c520d39289dad39b154d71dee49be4c --- /dev/null +++ b/types/Dojo/DojoDockerCompose.ts @@ -0,0 +1,15 @@ +interface DojoDockerCompose { + services: { + [serviceName: string]: Partial<{ + container_name: string; image: string; build: Partial<{ + context: string; dockerfile: string; + }> + }> + }; + volumes?: { + [volumeName: string]: null + }; +} + + +export default DojoDockerCompose; \ No newline at end of file