From 2b5f0ad68d3269248a51d373b60052fd1e6d79a8 Mon Sep 17 00:00:00 2001 From: Laurenz Blumentritt <38919977+laurenzfb@users.noreply.github.com> Date: Tue, 26 Mar 2024 00:18:49 +0100 Subject: [PATCH] Programming exercises: Show build logs for submission results (#8170) --- .../artemis/service/BuildLogEntryService.java | 103 ++++++++++++++++++ .../www1/artemis/service/ResultService.java | 25 ++++- .../LocalCIResultProcessingService.java | 3 +- .../ProgrammingExerciseGradingService.java | 15 ++- .../www1/artemis/web/rest/ResultResource.java | 22 ++++ .../web/rest/localci/BuildLogResource.java | 52 +++++++++ .../resources/config/application-artemis.yml | 3 + src/main/webapp/app/entities/result.model.ts | 1 + .../participation-submission.component.html | 3 + .../participation-submission.component.ts | 89 +++++++++------ .../participation/participation.service.ts | 4 + .../shared/result/result.component.ts | 3 + src/main/webapp/i18n/de/result.json | 5 +- src/main/webapp/i18n/en/result.json | 5 +- .../localvcci/LocalCIIntegrationTest.java | 72 ++++++++++++ .../LocalCIResourceIntegrationTest.java | 23 ++++ ...participation-submission.component.spec.ts | 13 +++ .../service/participation.service.spec.ts | 18 +++ 18 files changed, 420 insertions(+), 39 deletions(-) create mode 100644 src/main/java/de/tum/in/www1/artemis/web/rest/localci/BuildLogResource.java diff --git a/src/main/java/de/tum/in/www1/artemis/service/BuildLogEntryService.java b/src/main/java/de/tum/in/www1/artemis/service/BuildLogEntryService.java index a7a4603b1ea5..0d2ac9839b05 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/BuildLogEntryService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/BuildLogEntryService.java @@ -2,13 +2,25 @@ import static de.tum.in.www1.artemis.config.Constants.PROFILE_CORE; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Set; import java.util.stream.Collectors; +import org.apache.commons.io.FileUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Profile; +import org.springframework.core.io.FileSystemResource; +import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import de.tum.in.www1.artemis.domain.BuildLogEntry; @@ -22,10 +34,15 @@ @Service public class BuildLogEntryService { + private static final Logger log = LoggerFactory.getLogger(BuildLogEntryService.class); + private final BuildLogEntryRepository buildLogEntryRepository; private final ProgrammingSubmissionRepository programmingSubmissionRepository; + @Value("${artemis.continuous-integration.build-log.file-expiry-days:30}") + private int expiryDays; + public BuildLogEntryService(BuildLogEntryRepository buildLogEntryRepository, ProgrammingSubmissionRepository programmingSubmissionRepository) { this.buildLogEntryRepository = buildLogEntryRepository; this.programmingSubmissionRepository = programmingSubmissionRepository; @@ -256,4 +273,90 @@ public void deleteBuildLogEntriesForProgrammingSubmission(ProgrammingSubmission programmingSubmissionRepository.save(programmingSubmission); buildLogEntryRepository.deleteByProgrammingSubmissionId(programmingSubmission.getId()); } + + /** + * Save the build logs for a given submission to a file + * + * @param buildLogEntries the build logs to save + * @param resultId the id of the result for which to save the build logs + */ + public void saveBuildLogsToFile(List buildLogEntries, String resultId) { + + Path buildLogsPath = Path.of("buildLogs"); + + if (!Files.exists(buildLogsPath)) { + try { + Files.createDirectory(buildLogsPath); + } + catch (Exception e) { + throw new IllegalStateException("Could not create directory for build logs", e); + } + } + + Path logPath = buildLogsPath.resolve(resultId + ".log"); + + StringBuilder logsStringBuilder = new StringBuilder(); + for (BuildLogEntry buildLogEntry : buildLogEntries) { + logsStringBuilder.append(buildLogEntry.getTime()).append("\t").append(buildLogEntry.getLog()); + } + + try { + FileUtils.writeStringToFile(logPath.toFile(), logsStringBuilder.toString(), StandardCharsets.UTF_8); + log.debug("Saved build logs for result {} to file {}", resultId, logPath); + } + catch (IOException e) { + throw new RuntimeException(e); + } + } + + /** + * Retrieves the build logs for a given submission from a file. + * + * @param resultId the id of the result for which to retrieve the build logs + * @return the build logs as a string or null if the file could not be found (e.g. if the build logs have been deleted) + */ + public FileSystemResource retrieveBuildLogsFromFileForResult(String resultId) { + Path buildLogsPath = Path.of("buildLogs"); + Path logPath = buildLogsPath.resolve(resultId + ".log"); + + FileSystemResource fileSystemResource = new FileSystemResource(logPath); + if (fileSystemResource.exists()) { + log.debug("Retrieved build logs for result {} from file {}", resultId, logPath); + return fileSystemResource; + } + else { + log.warn("Could not find build logs for result {} in file {}", resultId, logPath); + return null; + } + } + + /** + * Deletes all build log files that are older than {@link #expiryDays} days on a schedule + */ + @Scheduled(cron = "${artemis.continuous-integration.build-log.cleanup-schedule:0 0 3 1 * ?}") + public void deleteOldBuildLogsFiles() { + log.info("Deleting old build log files"); + ZonedDateTime now = ZonedDateTime.now(); + Path buildLogsPath = Path.of("buildLogs"); + + try (DirectoryStream stream = Files.newDirectoryStream(buildLogsPath)) { + for (Path file : stream) { + ZonedDateTime lastModified = ZonedDateTime.ofInstant(Files.getLastModifiedTime(file).toInstant(), now.getZone()); + if (lastModified.isBefore(now.minusDays(expiryDays))) { + Files.deleteIfExists(file); + log.info("Deleted old build log file {}", file); + } + } + } + catch (IOException e) { + log.error("Error occurred while trying to delete old build log files", e); + } + } + + public boolean resultHasLogFile(String resultId) { + Path buildLogsPath = Path.of("buildLogs"); + Path logPath = buildLogsPath.resolve(resultId + ".log"); + return Files.exists(logPath); + } + } diff --git a/src/main/java/de/tum/in/www1/artemis/service/ResultService.java b/src/main/java/de/tum/in/www1/artemis/service/ResultService.java index 1fe8d93fe01b..55e0c174c114 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/ResultService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/ResultService.java @@ -65,13 +65,16 @@ public class ResultService { private final LongFeedbackTextRepository longFeedbackTextRepository; + private final BuildLogEntryService buildLogEntryService; + public ResultService(UserRepository userRepository, ResultRepository resultRepository, Optional ltiNewResultService, ResultWebsocketService resultWebsocketService, ComplaintResponseRepository complaintResponseRepository, RatingRepository ratingRepository, FeedbackRepository feedbackRepository, LongFeedbackTextRepository longFeedbackTextRepository, ComplaintRepository complaintRepository, ParticipantScoreRepository participantScoreRepository, AuthorizationCheckService authCheckService, ExerciseDateService exerciseDateService, TemplateProgrammingExerciseParticipationRepository templateProgrammingExerciseParticipationRepository, SolutionProgrammingExerciseParticipationRepository solutionProgrammingExerciseParticipationRepository, - ProgrammingExerciseStudentParticipationRepository programmingExerciseStudentParticipationRepository, StudentExamRepository studentExamRepository) { + ProgrammingExerciseStudentParticipationRepository programmingExerciseStudentParticipationRepository, StudentExamRepository studentExamRepository, + BuildLogEntryService buildLogEntryService) { this.userRepository = userRepository; this.resultRepository = resultRepository; this.ltiNewResultService = ltiNewResultService; @@ -88,6 +91,7 @@ public ResultService(UserRepository userRepository, ResultRepository resultRepos this.solutionProgrammingExerciseParticipationRepository = solutionProgrammingExerciseParticipationRepository; this.programmingExerciseStudentParticipationRepository = programmingExerciseStudentParticipationRepository; this.studentExamRepository = studentExamRepository; + this.buildLogEntryService = buildLogEntryService; } /** @@ -402,6 +406,25 @@ public Result getResultForParticipationAndCheckAccess(Long participationId, Long return result; } + /** + * Get a map of result ids to their availability of build log files. + * + * @param results the results for which to check the availability of build logs + * @return a map of result ids to their availability of build log files + */ + public Map getLogsAvailabilityForResults(List results) { + Map logsAvailability = new HashMap<>(); + for (Result result : results) { + if (buildLogEntryService.resultHasLogFile(result.getId().toString())) { + logsAvailability.put(result.getId(), true); + } + else { + logsAvailability.put(result.getId(), false); + } + } + return logsAvailability; + } + @NotNull private List saveFeedbackWithHibernateWorkaround(@NotNull Result result, List feedbackList) { // Avoid hibernate exception diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/LocalCIResultProcessingService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/LocalCIResultProcessingService.java index 97c62cad48e1..185215369572 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/LocalCIResultProcessingService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/LocalCIResultProcessingService.java @@ -122,7 +122,8 @@ public void processResult() { if (participation.getProgrammingExercise() == null) { participation.setProgrammingExercise(programmingExerciseRepository.findByParticipationIdOrElseThrow(participation.getId())); } - Result result = programmingExerciseGradingService.processNewProgrammingExerciseResult(participation, resultQueueItem.buildResult()); + Result result = programmingExerciseGradingService.processNewProgrammingExerciseResult(participation, buildResult); + if (result != null) { programmingMessagingService.notifyUserAboutNewResult(result, participation); addResultToBuildAgentsRecentBuildJobs(buildJob, result); diff --git a/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseGradingService.java b/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseGradingService.java index 9740e93241e0..f66f1ca4a038 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseGradingService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseGradingService.java @@ -167,7 +167,7 @@ public Result processNewProgrammingExerciseResult(@NotNull ProgrammingExercisePa newResult.setSubmission(latestSubmission); newResult.setRatedIfNotAfterDueDate(); // NOTE: the result is not saved yet, but is connected to the submission, the submission is not completely saved yet - return processNewProgrammingExerciseResult(participation, newResult); + return processNewProgrammingExerciseResult(participation, newResult, buildResult.extractBuildLogs()); } catch (ContinuousIntegrationException ex) { log.error("Result for participation {} could not be created", participation.getId(), ex); @@ -255,7 +255,7 @@ protected ProgrammingSubmission createAndSaveFallbackSubmission(ProgrammingExerc * @param newResult that contains the build result with its feedbacks. * @return the result after processing and persisting. */ - private Result processNewProgrammingExerciseResult(final ProgrammingExerciseParticipation participation, final Result newResult) { + private Result processNewProgrammingExerciseResult(final ProgrammingExerciseParticipation participation, final Result newResult, final List buildLogs) { ProgrammingExercise programmingExercise = participation.getProgrammingExercise(); boolean isSolutionParticipation = participation instanceof SolutionProgrammingExerciseParticipation; boolean isTemplateParticipation = participation instanceof TemplateProgrammingExerciseParticipation; @@ -284,7 +284,12 @@ private Result processNewProgrammingExerciseResult(final ProgrammingExercisePart // Adding back dropped submission updatedLatestSemiAutomaticResult.setSubmission(programmingSubmission); programmingSubmissionRepository.save(programmingSubmission); - resultRepository.save(updatedLatestSemiAutomaticResult); + Result result = resultRepository.save(updatedLatestSemiAutomaticResult); + + // Save the build logs to the file system + if (buildLogs != null && !buildLogs.isEmpty()) { + buildLogService.saveBuildLogsToFile(buildLogs, result.getId().toString()); + } return updatedLatestSemiAutomaticResult; } @@ -303,6 +308,10 @@ private Result processNewProgrammingExerciseResult(final ProgrammingExercisePart programmingSubmission.addResult(processedResult); programmingSubmissionRepository.save(programmingSubmission); + if (buildLogs != null && !buildLogs.isEmpty()) { + buildLogService.saveBuildLogsToFile(buildLogs, processedResult.getId().toString()); + } + return processedResult; } diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/ResultResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/ResultResource.java index f9208ecf4931..077dbc191f37 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/ResultResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/ResultResource.java @@ -6,6 +6,7 @@ import java.net.URISyntaxException; import java.time.ZonedDateTime; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; @@ -154,6 +155,27 @@ public ResponseEntity> getResultDetails(@PathVariable Long partic return new ResponseEntity<>(resultService.filterFeedbackForClient(result), HttpStatus.OK); } + /** + * GET /participations/:participationId/results/logs-available : get the logs availability for the results of a participation. + * + * @param participationId the id of the participation to the results + * @return the ResponseEntity with status 200 (OK) and with body the map of resultId and log availability, status 404 (Not Found) if the participation does not exist or 403 + * (forbidden) if the user does not have permissions to access the participation. + */ + @GetMapping("participations/{participationId}/results/logs-available") + @EnforceAtLeastTutor + public ResponseEntity> getLogsAvailabilityForResultsOfParticipation(@PathVariable long participationId) { + log.debug("REST request to get logs availability for results of participation : {}", participationId); + Participation participation = participationRepository.findByIdElseThrow(participationId); + List results = resultRepository.findAllByParticipationIdOrderByCompletionDateDesc(participationId); + + Map logsAvailable = resultService.getLogsAvailabilityForResults(results); + + participationAuthCheckService.checkCanAccessParticipationElseThrow(participation); + + return new ResponseEntity<>(logsAvailable, HttpStatus.OK); + } + /** * DELETE /participations/:participationId/results/:resultId : delete the "id" result. * diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/localci/BuildLogResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/localci/BuildLogResource.java new file mode 100644 index 000000000000..05ae9c82d3c5 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/localci/BuildLogResource.java @@ -0,0 +1,52 @@ +package de.tum.in.www1.artemis.web.rest.localci; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import de.tum.in.www1.artemis.security.annotations.EnforceAtLeastEditor; +import de.tum.in.www1.artemis.service.BuildLogEntryService; + +@Profile("localci") +@RestController +@RequestMapping("/api") +public class BuildLogResource { + + private static final Logger log = LoggerFactory.getLogger(BuildLogResource.class); + + private final BuildLogEntryService buildLogEntryService; + + public BuildLogResource(BuildLogEntryService buildLogEntryService) { + this.buildLogEntryService = buildLogEntryService; + } + + /** + * GET /build-log/{resultId} : get the build log for a given result + * + * @param resultId the id of the result for which to retrieve the build log + * @return the ResponseEntity with status 200 (OK) and the build log in the body, or with status 404 (Not Found) if the build log could not be found + */ + @GetMapping("/build-log/{resultId}") + @EnforceAtLeastEditor + public ResponseEntity getBuildLogForSubmission(@PathVariable long resultId) { + log.debug("REST request to get the build log for result {}", resultId); + HttpHeaders responseHeaders = new HttpHeaders(); + FileSystemResource buildLog = buildLogEntryService.retrieveBuildLogsFromFileForResult(String.valueOf(resultId)); + if (buildLog == null) { + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + } + responseHeaders.setContentType(MediaType.TEXT_PLAIN); + responseHeaders.setContentDispositionFormData("attachment", "build-" + resultId + ".log"); + return new ResponseEntity<>(buildLog, responseHeaders, HttpStatus.OK); + } +} diff --git a/src/main/resources/config/application-artemis.yml b/src/main/resources/config/application-artemis.yml index 2c87688ad22c..f180992e5fe8 100644 --- a/src/main/resources/config/application-artemis.yml +++ b/src/main/resources/config/application-artemis.yml @@ -87,6 +87,9 @@ artemis: artemis-authentication-token-value: build-timeout: 30 # Does cancel jenkins builds after 30 minutes to remove build that get stuck notification-plugin: "ls1tum/artemis-notification-plugin:1.0.0" # Docker image for the generic notification plugin. This value is set in an CI variable in GitLab CI. + build-log: + file-expiry-days: 30 # The amount of days until build log files can be deleted + cleanup-schedule: 0 0 3 1 * ? # Cron expression for schedule to delete old build log files git: name: Artemis email: artemis@xcit.tum.de diff --git a/src/main/webapp/app/entities/result.model.ts b/src/main/webapp/app/entities/result.model.ts index 96dbe428f99e..91d612514696 100644 --- a/src/main/webapp/app/entities/result.model.ts +++ b/src/main/webapp/app/entities/result.model.ts @@ -23,6 +23,7 @@ export class Result implements BaseEntity { public testCaseCount?: number; public passedTestCaseCount?: number; public codeIssueCount?: number; + public logsAvailable?: boolean; public submission?: Submission; public assessor?: User; diff --git a/src/main/webapp/app/exercises/shared/participation-submission/participation-submission.component.html b/src/main/webapp/app/exercises/shared/participation-submission/participation-submission.component.html index 3fa61a8c99be..efe5d373f553 100644 --- a/src/main/webapp/app/exercises/shared/participation-submission/participation-submission.component.html +++ b/src/main/webapp/app/exercises/shared/participation-submission/participation-submission.component.html @@ -99,6 +99,9 @@

{{ 'artemisApp.result.delete.buttonText' | artemisTranslate }} + @if (result?.logsAvailable) { + + } } } diff --git a/src/main/webapp/app/exercises/shared/participation-submission/participation-submission.component.ts b/src/main/webapp/app/exercises/shared/participation-submission/participation-submission.component.ts index 3ac49ec49f15..d9c8deb77f6d 100644 --- a/src/main/webapp/app/exercises/shared/participation-submission/participation-submission.component.ts +++ b/src/main/webapp/app/exercises/shared/participation-submission/participation-submission.component.ts @@ -53,6 +53,7 @@ export class ParticipationSubmissionComponent implements OnInit { eventSubscriber: Subscription; isLoading = true; commitHashURLTemplate?: string; + logsAvailable?: { [key: string]: boolean }; // Icons faTrash = faTrash; @@ -91,39 +92,54 @@ export class ParticipationSubmissionComponent implements OnInit { if (queryParams?.['isTmpOrSolutionProgrParticipation'] != undefined) { this.isTmpOrSolutionProgrParticipation = queryParams['isTmpOrSolutionProgrParticipation'] === 'true'; } - if (this.isTmpOrSolutionProgrParticipation) { - // Find programming exercise of template and solution programming participation - this.programmingExerciseService.findWithTemplateAndSolutionParticipation(params['exerciseId'], true).subscribe((exerciseResponse) => { - this.exercise = exerciseResponse.body!; - this.exerciseStatusBadge = dayjs().isAfter(dayjs(this.exercise.dueDate!)) ? 'bg-danger' : 'bg-success'; - const templateParticipation = (this.exercise as ProgrammingExercise).templateParticipation; - const solutionParticipation = (this.exercise as ProgrammingExercise).solutionParticipation; + this.participationService.getLogsAvailabilityForResultsOfParticipation(this.participationId).subscribe((logsAvailable) => { + this.logsAvailable = logsAvailable; + if (this.isTmpOrSolutionProgrParticipation) { + // Find programming exercise of template and solution programming participation + this.programmingExerciseService.findWithTemplateAndSolutionParticipation(params['exerciseId'], true).subscribe((exerciseResponse) => { + this.exercise = exerciseResponse.body!; + this.exerciseStatusBadge = dayjs().isAfter(dayjs(this.exercise.dueDate!)) ? 'bg-danger' : 'bg-success'; + const templateParticipation = (this.exercise as ProgrammingExercise).templateParticipation; + const solutionParticipation = (this.exercise as ProgrammingExercise).solutionParticipation; - // Check if requested participationId belongs to the template or solution participation - if (this.participationId === templateParticipation?.id) { - this.participation = templateParticipation; - this.submissions = templateParticipation.submissions!; - // This is needed to access the exercise in the result details - templateParticipation.programmingExercise = this.exercise; - } else if (this.participationId === solutionParticipation?.id) { - this.participation = solutionParticipation; - this.submissions = solutionParticipation.submissions!; - // This is needed to access the exercise in the result details - solutionParticipation.programmingExercise = this.exercise; - } else { - // Should not happen - alert(this.translateService.instant('artemisApp.participation.noParticipation')); - } + // Check if requested participationId belongs to the template or solution participation + if (this.participationId === templateParticipation?.id) { + this.participation = templateParticipation; + this.submissions = templateParticipation.submissions!; + // This is needed to access the exercise in the result details + templateParticipation.programmingExercise = this.exercise; + } else if (this.participationId === solutionParticipation?.id) { + this.participation = solutionParticipation; + this.submissions = solutionParticipation.submissions!; + // This is needed to access the exercise in the result details + solutionParticipation.programmingExercise = this.exercise; + } else { + // Should not happen + alert(this.translateService.instant('artemisApp.participation.noParticipation')); + } + + if (this.submissions) { + this.submissions.forEach((submission: ProgrammingSubmission) => { + if (submission.results) { + submission.results.forEach((result: Result) => { + result.logsAvailable = this.logsAvailable?.[result.id!]; + }); + } + }); + } + + this.isLoading = false; + }); + } else { + // Get exercise for release and due dates + this.exerciseService.find(params['exerciseId']).subscribe((exerciseResponse) => { + this.exercise = exerciseResponse.body!; + this.updateStatusBadgeColor(); + }); + this.fetchParticipationAndSubmissionsForStudent(); this.isLoading = false; - }); - } else { - // Get exercise for release and due dates - this.exerciseService.find(params['exerciseId']).subscribe((exerciseResponse) => { - this.exercise = exerciseResponse.body!; - this.updateStatusBadgeColor(); - }); - this.fetchParticipationAndSubmissionsForStudent(); - } + } + }); }); // Get active profiles, to distinguish between Bitbucket and GitLab @@ -157,9 +173,13 @@ export class ParticipationSubmissionComponent implements OnInit { this.participation.submissions = submissions; } // set the submission to every result so it can be accessed via the result + // set the build log availability for every result submissions.forEach((submission: Submission) => { if (submission.results) { - submission.results.forEach((result: Result) => (result.submission = submission)); + submission.results.forEach((result: Result) => { + result.submission = submission; + result.logsAvailable = this.logsAvailable?.[result.id!]; + }); } }); } @@ -242,6 +262,11 @@ export class ParticipationSubmissionComponent implements OnInit { } } + viewBuildLogs(resultId: number): void { + const url = `/api/build-log/${resultId}`; + window.open(url, '_blank'); + } + private updateResults(submission: Submission, result: Result) { submission.results = submission.results?.filter((remainingResult) => remainingResult.id !== result.id); this.dialogErrorSource.next(''); diff --git a/src/main/webapp/app/exercises/shared/participation/participation.service.ts b/src/main/webapp/app/exercises/shared/participation/participation.service.ts index 25ccc3d71556..fac8c276c4fb 100644 --- a/src/main/webapp/app/exercises/shared/participation/participation.service.ts +++ b/src/main/webapp/app/exercises/shared/participation/participation.service.ts @@ -107,6 +107,10 @@ export class ParticipationService { return !!exercise?.dueDate && dayjs().isAfter(exercise.dueDate); } + getLogsAvailabilityForResultsOfParticipation(participationId: number): Observable<{ [key: string]: boolean }> { + return this.http.get<{ [key: string]: boolean }>(`${this.resourceUrl}/${participationId}/results/logs-available`); + } + protected convertParticipationDatesFromClient(participation: StudentParticipation): StudentParticipation { // return a copy of the object return Object.assign({}, participation, { diff --git a/src/main/webapp/app/exercises/shared/result/result.component.ts b/src/main/webapp/app/exercises/shared/result/result.component.ts index bb75cf02f17e..191f936ad10c 100644 --- a/src/main/webapp/app/exercises/shared/result/result.component.ts +++ b/src/main/webapp/app/exercises/shared/result/result.component.ts @@ -60,6 +60,7 @@ export class ResultComponent implements OnInit, OnChanges { submission?: Submission; badge: Badge; resultTooltip?: string; + logsAvailable?: boolean; latestDueDate: dayjs.Dayjs | undefined; @@ -123,6 +124,8 @@ export class ResultComponent implements OnInit, OnChanges { // Note: it can still happen here that this.result is undefined, e.g. when this.participation.results.length == 0 this.submission = this.result?.submission; + this.logsAvailable = this.result?.logsAvailable; + this.evaluate(); this.translateService.onLangChange.subscribe(() => { diff --git a/src/main/webapp/i18n/de/result.json b/src/main/webapp/i18n/de/result.json index ea89a7443d83..6af723bc0af9 100644 --- a/src/main/webapp/i18n/de/result.json +++ b/src/main/webapp/i18n/de/result.json @@ -111,7 +111,10 @@ "notExecutedTests": "{{numberOfNotExecutedTests}} Tests wurden nicht ausgeführt.", "notExecutedTestsTooltip": "Diese Tests werden eventuell nur nach der Einreichungsfrist ausgeführt oder es gab ein Problem während der Ausführung der Tests.", "scaIssueCount": "Du hast {{issues}} Code-Issues.", - "manualFeedbackCount": "Du hast {{feedbacks}} manuelle Feedbacks bekommen." + "manualFeedbackCount": "Du hast {{feedbacks}} manuelle Feedbacks bekommen.", + "buildLogs": { + "viewLogs": "Build-Logs anzeigen" + } } } } diff --git a/src/main/webapp/i18n/en/result.json b/src/main/webapp/i18n/en/result.json index ecf70d26e2df..57d2b9dc6892 100644 --- a/src/main/webapp/i18n/en/result.json +++ b/src/main/webapp/i18n/en/result.json @@ -111,7 +111,10 @@ "notExecutedTests": "{{numberOfNotExecutedTests}} tests were not executed.", "notExecutedTestsTooltip": "The tests might only get executed after the due date or there was an issue during the execution of the tests.", "scaIssueCount": "You have {{issues}} code issues.", - "manualFeedbackCount": "You received {{feedbacks}} manual feedbacks." + "manualFeedbackCount": "You received {{feedbacks}} manual feedbacks.", + "buildLogs": { + "viewLogs": "View build logs" + } } } } diff --git a/src/test/java/de/tum/in/www1/artemis/localvcci/LocalCIIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/localvcci/LocalCIIntegrationTest.java index 25cc053bd3a2..727cfe8fceb8 100644 --- a/src/test/java/de/tum/in/www1/artemis/localvcci/LocalCIIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/localvcci/LocalCIIntegrationTest.java @@ -11,7 +11,9 @@ import java.io.IOException; import java.io.InputStream; +import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.Paths; import java.util.*; import org.junit.jupiter.api.AfterEach; @@ -20,13 +22,19 @@ import org.mockito.ArgumentMatcher; import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.FileSystemResource; import org.springframework.http.HttpStatus; import org.springframework.security.test.context.support.WithMockUser; +import com.github.dockerjava.api.async.ResultCallback; import com.github.dockerjava.api.command.CopyArchiveFromContainerCmd; +import com.github.dockerjava.api.command.ExecStartCmd; import com.github.dockerjava.api.exception.NotFoundException; +import com.github.dockerjava.api.model.Frame; import de.tum.in.www1.artemis.domain.BuildJob; +import de.tum.in.www1.artemis.domain.ProgrammingSubmission; +import de.tum.in.www1.artemis.domain.Result; import de.tum.in.www1.artemis.domain.Team; import de.tum.in.www1.artemis.domain.enumeration.BuildStatus; import de.tum.in.www1.artemis.domain.enumeration.ExerciseMode; @@ -35,6 +43,8 @@ import de.tum.in.www1.artemis.domain.participation.ProgrammingExerciseStudentParticipation; import de.tum.in.www1.artemis.exception.VersionControlException; import de.tum.in.www1.artemis.repository.BuildJobRepository; +import de.tum.in.www1.artemis.repository.ProgrammingSubmissionTestRepository; +import de.tum.in.www1.artemis.service.BuildLogEntryService; import de.tum.in.www1.artemis.service.connectors.localvc.LocalVCServletService; import de.tum.in.www1.artemis.util.LocalRepository; import de.tum.in.www1.artemis.web.websocket.programmingSubmission.BuildTriggerWebsocketError; @@ -47,6 +57,12 @@ class LocalCIIntegrationTest extends AbstractLocalCILocalVCIntegrationTest { @Autowired private BuildJobRepository buildJobRepository; + @Autowired + private ProgrammingSubmissionTestRepository programmingSubmissionRepository; + + @Autowired + private BuildLogEntryService buildLogEntryService; + private LocalRepository studentAssignmentRepository; private LocalRepository testsRepository; @@ -300,6 +316,62 @@ void testStaticCodeAnalysis() throws IOException { localVCLocalCITestService.testLatestSubmission(studentParticipation.getId(), commitHash, 1, false, true, 15); } + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void testBuildLogs() throws IOException { + + // Adapt Docker Client mock to return build logs + ExecStartCmd execStartCmd = mock(ExecStartCmd.class); + doReturn(execStartCmd).when(dockerClient).execStartCmd(anyString()); + doReturn(execStartCmd).when(execStartCmd).withDetach(anyBoolean()); + doAnswer(invocation -> { + // Use a raw type for the callback to avoid generic type issues + ResultCallback callback = invocation.getArgument(0); + + // Simulate receiving log entries. + Frame logEntryFrame1 = mock(Frame.class); + when(logEntryFrame1.getPayload()).thenReturn("Dummy log entry".getBytes()); + callback.onNext(logEntryFrame1); + + // Simulate the command completing + callback.onComplete(); + + return null; + }).when(execStartCmd).exec(any()); + + FileSystemResource buildLogs = null; + + ProgrammingExerciseStudentParticipation studentParticipation = localVCLocalCITestService.createParticipation(programmingExercise, student1Login); + + try { + localVCServletService.processNewPush(commitHash, studentAssignmentRepository.originGit.getRepository()); + localVCLocalCITestService.testLatestSubmission(studentParticipation.getId(), commitHash, 1, false); + + var submissionOptional = programmingSubmissionRepository.findFirstByParticipationIdWithResultsOrderByLegalSubmissionDateDesc(studentParticipation.getId()); + + long resultId = submissionOptional.map(ProgrammingSubmission::getLatestResult).map(Result::getId).orElseThrow(() -> new AssertionError("Submission has no results")); + + // Assert that the build logs for the result are stored in the file system + assertThat(buildLogEntryService.resultHasLogFile(String.valueOf(resultId))).isTrue(); + + // Retrieve the build logs from the file system + buildLogs = buildLogEntryService.retrieveBuildLogsFromFileForResult(String.valueOf(resultId)); + assertThat(buildLogs).isNotNull(); + assertThat(buildLogs.getFile().exists()).isTrue(); + + String content = new String(Files.readAllBytes(Paths.get(buildLogs.getFile().getAbsolutePath()))); + + // Assert that the content contains the expected log entry + assertThat(content).contains("Dummy log entry"); + } + finally { + // Delete log file + if (buildLogs != null && buildLogs.getFile().exists()) { + Files.deleteIfExists(Paths.get(buildLogs.getFile().getAbsolutePath())); + } + } + } + private void verifyUserNotification(Participation participation, String errorMessage) { BuildTriggerWebsocketError expectedError = new BuildTriggerWebsocketError(errorMessage, participation.getId()); await().untilAsserted( diff --git a/src/test/java/de/tum/in/www1/artemis/localvcci/LocalCIResourceIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/localvcci/LocalCIResourceIntegrationTest.java index a06b187c3459..1e2998f4240f 100644 --- a/src/test/java/de/tum/in/www1/artemis/localvcci/LocalCIResourceIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/localvcci/LocalCIResourceIntegrationTest.java @@ -2,6 +2,8 @@ import static org.assertj.core.api.Assertions.assertThat; +import java.nio.file.Files; +import java.nio.file.Path; import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.List; @@ -15,8 +17,10 @@ import com.hazelcast.core.HazelcastInstance; import com.hazelcast.map.IMap; +import de.tum.in.www1.artemis.domain.BuildLogEntry; import de.tum.in.www1.artemis.domain.enumeration.BuildStatus; import de.tum.in.www1.artemis.domain.enumeration.RepositoryType; +import de.tum.in.www1.artemis.service.BuildLogEntryService; import de.tum.in.www1.artemis.service.connectors.localci.buildagent.SharedQueueProcessingService; import de.tum.in.www1.artemis.service.connectors.localci.dto.*; @@ -34,6 +38,9 @@ class LocalCIResourceIntegrationTest extends AbstractLocalCILocalVCIntegrationTe @Autowired private SharedQueueProcessingService sharedQueueProcessingService; + @Autowired + private BuildLogEntryService buildLogEntryService; + protected IQueue queuedJobs; protected IMap processingJobs; @@ -138,4 +145,20 @@ void testGetBuildAgents_returnsAgents() throws Exception { void testGetBuildAgents_instructorAccessForbidden() throws Exception { request.get("/api/admin/build-agents", HttpStatus.FORBIDDEN, List.class); } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testGetBuildLogsForResult() throws Exception { + try { + BuildLogEntry buildLogEntry = new BuildLogEntry(ZonedDateTime.now(), "Dummy log"); + buildLogEntryService.saveBuildLogsToFile(List.of(buildLogEntry), "0"); + var response = request.get("/api/build-log/0", HttpStatus.OK, String.class); + assertThat(response).contains("Dummy log"); + } + finally { + Path buildLogFile = Path.of("build-logs", "0.log"); + Files.deleteIfExists(buildLogFile); + } + } + } diff --git a/src/test/javascript/spec/component/participation-submission/participation-submission.component.spec.ts b/src/test/javascript/spec/component/participation-submission/participation-submission.component.spec.ts index a40cd5280952..e1f097b1b4e5 100644 --- a/src/test/javascript/spec/component/participation-submission/participation-submission.component.spec.ts +++ b/src/test/javascript/spec/component/participation-submission/participation-submission.component.spec.ts @@ -164,6 +164,9 @@ describe('ParticipationSubmissionComponent', () => { jest.spyOn(exerciseService, 'find').mockReturnValue(of(new HttpResponse({ body: exercise }))); findAllSubmissionsOfParticipationStub.mockReturnValue(of({ body: submissions })); + const getLogsAvailabilityForResultsOfParticipationStub = jest.spyOn(participationService, 'getLogsAvailabilityForResultsOfParticipation'); + getLogsAvailabilityForResultsOfParticipationStub.mockReturnValue(of({ '4': true })); + fixture.detectChanges(); tick(); @@ -206,6 +209,9 @@ describe('ParticipationSubmissionComponent', () => { const findWithTemplateAndSolutionParticipationStub = jest.spyOn(programmingExerciseService, 'findWithTemplateAndSolutionParticipation'); findWithTemplateAndSolutionParticipationStub.mockReturnValue(of(new HttpResponse({ body: programmingExercise }))); + const getLogsAvailabilityForResultsOfParticipationStub = jest.spyOn(participationService, 'getLogsAvailabilityForResultsOfParticipation'); + getLogsAvailabilityForResultsOfParticipationStub.mockReturnValue(of({ '4': true })); + fixture.detectChanges(); tick(); @@ -243,6 +249,9 @@ describe('ParticipationSubmissionComponent', () => { const findWithTemplateAndSolutionParticipationStub = jest.spyOn(programmingExerciseService, 'findWithTemplateAndSolutionParticipation'); findWithTemplateAndSolutionParticipationStub.mockReturnValue(of(new HttpResponse({ body: programmingExercise }))); + const getLogsAvailabilityForResultsOfParticipationStub = jest.spyOn(participationService, 'getLogsAvailabilityForResultsOfParticipation'); + getLogsAvailabilityForResultsOfParticipationStub.mockReturnValue(of({ '4': true })); + fixture.detectChanges(); tick(); @@ -267,6 +276,8 @@ describe('ParticipationSubmissionComponent', () => { deleteProgrammingAssessmentStub.mockReturnValue(of({})); findAllSubmissionsOfParticipationStub.mockReturnValue(of({ body: [submissionWithTwoResults] })); jest.spyOn(participationService, 'find').mockReturnValue(of(new HttpResponse({ body: participation1 }))); + const getLogsAvailabilityForResultsOfParticipationStub = jest.spyOn(participationService, 'getLogsAvailabilityForResultsOfParticipation'); + getLogsAvailabilityForResultsOfParticipationStub.mockReturnValue(of({ '4': true })); }); it('should delete result of fileUploadSubmission', fakeAsync(() => { @@ -321,6 +332,8 @@ describe('ParticipationSubmissionComponent', () => { deleteTextAssessmentStub.mockReturnValue(throwError(() => error)); findAllSubmissionsOfParticipationStub.mockReturnValue(of({ body: [submissionWithTwoResults2] })); jest.spyOn(participationService, 'find').mockReturnValue(of(new HttpResponse({ body: participation1 }))); + const getLogsAvailabilityForResultsOfParticipationStub = jest.spyOn(participationService, 'getLogsAvailabilityForResultsOfParticipation'); + getLogsAvailabilityForResultsOfParticipationStub.mockReturnValue(of({ '4': true })); }); it('should not delete result of fileUploadSubmission because of server error', fakeAsync(() => { diff --git a/src/test/javascript/spec/service/participation.service.spec.ts b/src/test/javascript/spec/service/participation.service.spec.ts index 8373b81242ae..8d25bdd58e54 100644 --- a/src/test/javascript/spec/service/participation.service.spec.ts +++ b/src/test/javascript/spec/service/participation.service.spec.ts @@ -245,6 +245,24 @@ describe('Participation Service', () => { tick(); })); + it('should get logs availability for participation results', fakeAsync(() => { + let resultGetLogsAvailability: any; + const logsAvailability: { [key: string]: boolean } = { '1': true, '2': false }; + const returnedFromService = logsAvailability; + const expected = { ...returnedFromService }; + + service + .getLogsAvailabilityForResultsOfParticipation(1) + .pipe(take(1)) + .subscribe((resp) => (resultGetLogsAvailability = resp)); + + const req = httpMock.expectOne({ method: 'GET' }); + req.flush(returnedFromService); + tick(); + + expect(resultGetLogsAvailability).toEqual(expected); + })); + it.each([ ['attachment; filename="FixArtifactDownload-Tests-1.0.jar"', 'FixArtifactDownload-Tests-1.0.jar'], ['', 'artifact'],