diff --git a/deploy/.env.example b/deploy/.env.example index 746c9488..fcb5dad7 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -24,6 +24,13 @@ LOGGING_FILE_PATH=/usr/src/app/logs # =================== PMA_HOST=db +# =================== +# Health monitoring (optional) +# =================== +# Uncomment to protect /actuator/health with HTTP Basic auth (for uptime monitors) +# DROP_PROJECT_ACTUATOR_USERNAME=actuator +# DROP_PROJECT_ACTUATOR_PASSWORD=changeme + # =================== # Backup (optional) # =================== diff --git a/docker-deployment.md b/docker-deployment.md index e803a5c6..20abc3c5 100644 --- a/docker-deployment.md +++ b/docker-deployment.md @@ -159,6 +159,23 @@ For GitHub OAuth or LTI/Moodle integration, see the [Authentication and Authorization](https://github.com/drop-project-edu/drop-project/wiki/Authentication-and-Authorization) wiki page. +## Health monitoring + +Drop Project exposes a health endpoint at `/actuator/health` that returns `{"status":"UP"}` when the application is +running normally, or `{"status":"DOWN"}` if a problem is detected. By default, the disk-space check triggers when free +disk drops below **500 MB** — you can override this with `management.health.diskspace.threshold=1GB` (or any size) in +your `conf/drop-project.properties`. The endpoint is protected by the same authentication as the rest of the app, so to +allow an uptime monitor such as [UptimeRobot](https://uptimerobot.com) to reach it, set a dedicated username and +password in your `.env` file: + +```properties +DROP_PROJECT_ACTUATOR_USERNAME=actuator +DROP_PROJECT_ACTUATOR_PASSWORD=your-secret-password +``` + +UptimeRobot can then use **HTTP Basic** authentication when polling `https://yourapp.com/actuator/health` with keyword +`UP`. + ## Updating Pull the latest Drop Project image and recreate the container: diff --git a/pom.xml b/pom.xml index eadf4d3a..d674f320 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.dropProject drop-project - 1.0.0-beta.4 + 1.0.0-beta.5 jar DropProject @@ -180,6 +180,10 @@ 1.23.6 + + org.springframework.boot + spring-boot-starter-actuator + org.springframework.boot spring-boot-starter-cache diff --git a/src/main/kotlin/org/dropProject/config/ActuatorSecurityConfig.kt b/src/main/kotlin/org/dropProject/config/ActuatorSecurityConfig.kt new file mode 100644 index 00000000..7e7fb214 --- /dev/null +++ b/src/main/kotlin/org/dropProject/config/ActuatorSecurityConfig.kt @@ -0,0 +1,56 @@ +/*- + * ========================LICENSE_START================================= + * DropProject + * %% + * Copyright (C) 2019 Pedro Alves + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +package org.dropproject.config + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.core.annotation.Order +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.http.SessionCreationPolicy +import org.springframework.security.core.userdetails.User +import org.springframework.security.provisioning.InMemoryUserDetailsManager +import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.web.util.matcher.AntPathRequestMatcher + +@Configuration +@ConditionalOnProperty(prefix = "drop-project.actuator", name = ["username"]) +class ActuatorSecurityConfig(val dropProjectProperties: DropProjectProperties) { + + @Bean + @Order(1) + fun actuatorFilterChain(http: HttpSecurity): SecurityFilterChain { + val actuatorUser = User.withUsername(dropProjectProperties.actuator.username) + .password("{noop}${dropProjectProperties.actuator.password}") + .roles("ACTUATOR") + .build() + val userDetailsService = InMemoryUserDetailsManager(actuatorUser) + + http + .securityMatcher(AntPathRequestMatcher("/actuator/**")) + .authorizeHttpRequests { it.anyRequest().hasRole("ACTUATOR") } + .httpBasic { } + .userDetailsService(userDetailsService) + .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } + .csrf { it.disable() } + + return http.build() + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/dropProject/config/DropProjectProperties.kt b/src/main/kotlin/org/dropProject/config/DropProjectProperties.kt index c68200eb..1e144c70 100644 --- a/src/main/kotlin/org/dropProject/config/DropProjectProperties.kt +++ b/src/main/kotlin/org/dropProject/config/DropProjectProperties.kt @@ -61,7 +61,10 @@ data class DropProjectProperties( val footer: Footer = Footer(), /** MCP configuration */ - val mcp: Mcp = Mcp() + val mcp: Mcp = Mcp(), + + /** Actuator configuration */ + val actuator: Actuator = Actuator() ) { data class Storage( @@ -122,6 +125,13 @@ data class DropProjectProperties( val enabled: Boolean = true ) + data class Actuator( + /** Username for accessing the /actuator/health endpoint */ + val username: String = "", + /** Password for accessing the /actuator/health endpoint */ + val password: String = "" + ) + override fun toString(): String { return super.toString() } diff --git a/src/main/kotlin/org/dropProject/controllers/AdminController.kt b/src/main/kotlin/org/dropProject/controllers/AdminController.kt index cc543b44..1fdf958a 100644 --- a/src/main/kotlin/org/dropProject/controllers/AdminController.kt +++ b/src/main/kotlin/org/dropProject/controllers/AdminController.kt @@ -19,11 +19,15 @@ */ package org.dropproject.controllers +import org.apache.commons.io.FileUtils import org.dropproject.config.AsyncConfigurer +import org.dropproject.config.DropProjectProperties import org.dropproject.dao.AssignmentTag import org.dropproject.dao.SubmissionStatus +import org.dropproject.data.AssignmentDiskUsage import org.dropproject.forms.AdminDashboardForm import org.dropproject.repository.AssignmentTagRepository +import org.dropproject.repository.JUnitReportRepository import org.dropproject.repository.SubmissionRepository import org.dropproject.services.MavenInvoker import org.dropproject.services.SubmissionService @@ -36,6 +40,8 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes import org.springframework.transaction.annotation.Transactional import jakarta.validation.Valid import org.dropproject.repository.AssignmentRepository +import java.io.File +import org.springframework.data.domain.PageRequest /** * AdminController contains MVC controller functions that handle requests related with DP's administration @@ -48,7 +54,9 @@ class AdminController(val mavenInvoker: MavenInvoker, val assignmentRepository: AssignmentRepository, val assignmentTagRepository: AssignmentTagRepository, val asyncConfigurer: AsyncConfigurer, - val submissionService: SubmissionService) { + val submissionService: SubmissionService, + val junitReportRepository: JUnitReportRepository, + val dropProjectProperties: DropProjectProperties) { val LOG = LoggerFactory.getLogger(this.javaClass.name) @@ -157,6 +165,59 @@ class AdminController(val mavenInvoker: MavenInvoker, return "redirect:/admin/tags" } + /** + * Controller that shows disk usage per assignment, combining file-system + * (submissions + mavenized folders) and database (junit_report + build_report) + * space, sorted by total size descending. + */ + @GetMapping("/diskUsage") + fun showDiskUsage(model: ModelMap, + @RequestParam(defaultValue = "50") maxResults: Int): String { + val assignments = assignmentRepository.findAll(PageRequest.of(0, maxResults)).content + val total = assignments.size + LOG.info("Computing disk usage for $total assignments") + + val diskUsageList = assignments.mapIndexed { index, assignment -> + LOG.info("Processing assignment ${assignment.id} (${index + 1} of $total)") + + val uploadFolder = File(dropProjectProperties.storage.uploadLocation, assignment.id) + val gitFolder = File(dropProjectProperties.storage.gitLocation, assignment.id) + val mavenizedFolder = File(dropProjectProperties.mavenizedProjects.rootLocation, assignment.id) + + fun sizeOf(folder: File): Long = + try { FileUtils.sizeOfDirectory(folder) } + catch (e: java.io.UncheckedIOException) { + if (e.cause is java.nio.file.AccessDeniedException) { + LOG.warn("Access denied reading disk usage for ${folder.absolutePath}: ${e.cause?.message}") + 0L + } else throw e + } + + val submissionsSize = + (if (uploadFolder.exists()) sizeOf(uploadFolder) else 0L) + + (if (gitFolder.exists()) sizeOf(gitFolder) else 0L) + val mavenizedSize = if (mavenizedFolder.exists()) sizeOf(mavenizedFolder) else 0L + + val junitDbSize = junitReportRepository.getTotalXmlSizeByAssignmentId(assignment.id) ?: 0L + val buildReportDbSize = submissionRepository.getTotalBuildReportSizeByAssignmentId(assignment.id) ?: 0L + + LOG.info("Assignment ${assignment.id}: submissionsSize=$submissionsSize, mavenizedSize=$mavenizedSize, junitDbSize=$junitDbSize, buildReportDbSize=$buildReportDbSize") + + AssignmentDiskUsage( + assignmentId = assignment.id, + assignmentName = assignment.name, + submissionsSize = submissionsSize, + mavenizedSize = mavenizedSize, + junitReportDbSize = junitDbSize, + buildReportDbSize = buildReportDbSize + ) + }.sortedByDescending { it.totalSize } + + LOG.info("Disk usage computation complete") + model["diskUsageList"] = diskUsageList + return "admin-disk-usage" + } + /** * Controller that handles requests for cleaning up non-final submission files. * Removes all files related to non-final submissions for a given assignment. diff --git a/src/main/kotlin/org/dropProject/controllers/AssignmentController.kt b/src/main/kotlin/org/dropProject/controllers/AssignmentController.kt index 1100b166..103a6123 100644 --- a/src/main/kotlin/org/dropProject/controllers/AssignmentController.kt +++ b/src/main/kotlin/org/dropProject/controllers/AssignmentController.kt @@ -294,6 +294,16 @@ class AssignmentController( if (!(assignmentForm.acl.isNullOrBlank())) { val userIds = assignmentForm.acl!!.split(",") + // validate each userId + for (userId in userIds) { + val trimmedUserId = userId.trim() + if (trimmedUserId.contains(" ")) { + bindingResult.rejectValue("acl", "acl.invalidFormat", + "Error: User IDs must be comma-separated. '$trimmedUserId' appears to contain spaces.") + return "assignment-form" + } + } + // first delete existing to prevent duplicates assignmentACLRepository.deleteByAssignmentId(assignmentForm.assignmentId!!) @@ -337,6 +347,7 @@ class AssignmentController( acceptsStudentTests = assignmentForm.acceptsStudentTests, minStudentTests = assignmentForm.minStudentTests, calculateStudentTestsCoverage = assignmentForm.calculateStudentTestsCoverage, + coverageVisibleToStudents = assignmentForm.coverageVisibleToStudents, mandatoryTestsSuffix = assignmentForm.mandatoryTestsSuffix, cooloffPeriod = assignmentForm.cooloffPeriod, maxMemoryMb = assignmentForm.maxMemoryMb, submissionMethod = assignmentForm.submissionMethod!!, @@ -462,6 +473,7 @@ class AssignmentController( acceptsStudentTests = assignment.acceptsStudentTests, minStudentTests = assignment.minStudentTests, calculateStudentTestsCoverage = assignment.calculateStudentTestsCoverage, + coverageVisibleToStudents = assignment.coverageVisibleToStudents, mandatoryTestsSuffix = assignment.mandatoryTestsSuffix, cooloffPeriod = assignment.cooloffPeriod, hiddenTestsVisibility = assignment.hiddenTestsVisibility, diff --git a/src/main/kotlin/org/dropProject/dao/Assignment.kt b/src/main/kotlin/org/dropProject/dao/Assignment.kt index 7a05f2fb..fd94aa9b 100644 --- a/src/main/kotlin/org/dropProject/dao/Assignment.kt +++ b/src/main/kotlin/org/dropProject/dao/Assignment.kt @@ -88,6 +88,7 @@ enum class AssignmentVisibility { * asked to implement * @property calculateStudentTestsCoverage is an optional Boolean, indicating if the test coverage should be calculated * for student's own tests + * @property coverageVisibleToStudents is an optional Boolean, indicating if the coverage results should be visible to students * @property cooloffPeriod is an optional Integer with the number of minutes that students must wait between consecutive * submissions * @property maxMemoryMb is an optional Integer, indicating the maximum number of Mb that the student's code can use @@ -141,6 +142,7 @@ data class Assignment( var acceptsStudentTests: Boolean = false, var minStudentTests: Int? = null, var calculateStudentTestsCoverage: Boolean = false, + var coverageVisibleToStudents: Boolean = false, var hiddenTestsVisibility: TestVisibility? = null, var mandatoryTestsSuffix: String? = null, var cooloffPeriod: Int? = null, // minutes diff --git a/src/main/kotlin/org/dropProject/data/AssignmentDiskUsage.kt b/src/main/kotlin/org/dropProject/data/AssignmentDiskUsage.kt new file mode 100644 index 00000000..c9a24fa6 --- /dev/null +++ b/src/main/kotlin/org/dropProject/data/AssignmentDiskUsage.kt @@ -0,0 +1,55 @@ +/*- + * ========================LICENSE_START================================= + * DropProject + * %% + * Copyright (C) 2019 - 2025 Pedro Alves + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================LICENSE_END================================== + */ +package org.dropproject.data + +/** + * Holds disk usage statistics for a single assignment, combining file-system + * space (submissions and mavenized folders) with database space (build_report + * and junit_report tables). + */ +data class AssignmentDiskUsage( + val assignmentId: String, + val assignmentName: String, + /** Bytes occupied by submission folders (upload + git) on the file system */ + val submissionsSize: Long, + /** Bytes occupied by mavenized project folders on the file system */ + val mavenizedSize: Long, + /** Bytes occupied by the junit_report rows in the database */ + val junitReportDbSize: Long, + /** Bytes occupied by the build_report rows in the database */ + val buildReportDbSize: Long +) { + val totalSize: Long get() = submissionsSize + mavenizedSize + junitReportDbSize + buildReportDbSize + + val submissionsSizeFormatted: String get() = formatBytes(submissionsSize) + val mavenizedSizeFormatted: String get() = formatBytes(mavenizedSize) + val junitReportDbSizeFormatted: String get() = formatBytes(junitReportDbSize) + val buildReportDbSizeFormatted: String get() = formatBytes(buildReportDbSize) + val totalSizeFormatted: String get() = formatBytes(totalSize) + + companion object { + fun formatBytes(bytes: Long): String = when { + bytes >= 1_073_741_824L -> "%.1f GB".format(bytes / 1_073_741_824.0) + bytes >= 1_048_576L -> "%.1f MB".format(bytes / 1_048_576.0) + bytes >= 1_024L -> "%.1f KB".format(bytes / 1_024.0) + else -> "$bytes B" + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/dropProject/forms/AssignmentForm.kt b/src/main/kotlin/org/dropProject/forms/AssignmentForm.kt index 6cc69c7f..2e1063c5 100644 --- a/src/main/kotlin/org/dropProject/forms/AssignmentForm.kt +++ b/src/main/kotlin/org/dropProject/forms/AssignmentForm.kt @@ -63,6 +63,7 @@ data class AssignmentForm( var acceptsStudentTests: Boolean = false, var minStudentTests: Int? = null, var calculateStudentTestsCoverage: Boolean = false, + var coverageVisibleToStudents: Boolean = false, var hiddenTestsVisibility: TestVisibility? = null, var mandatoryTestsSuffix: String? = null, var cooloffPeriod: Int? = null, diff --git a/src/main/kotlin/org/dropProject/repository/JUnitReportRepository.kt b/src/main/kotlin/org/dropProject/repository/JUnitReportRepository.kt index ed8d5bc4..590c762a 100644 --- a/src/main/kotlin/org/dropProject/repository/JUnitReportRepository.kt +++ b/src/main/kotlin/org/dropProject/repository/JUnitReportRepository.kt @@ -20,6 +20,8 @@ package org.dropproject.repository import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param import org.springframework.transaction.annotation.Transactional import org.dropproject.dao.JUnitReport import org.dropproject.dao.ProjectGroup @@ -35,4 +37,7 @@ interface JUnitReportRepository : JpaRepository { @Transactional fun deleteBySubmissionId(submissionId: Long) + + @Query("SELECT SUM(LENGTH(jr.xmlReport)) FROM JUnitReport jr WHERE jr.submissionId IN (SELECT s.id FROM Submission s WHERE s.assignmentId = :assignmentId)") + fun getTotalXmlSizeByAssignmentId(@Param("assignmentId") assignmentId: String): Long? } diff --git a/src/main/kotlin/org/dropProject/repository/SubmissionRepository.kt b/src/main/kotlin/org/dropProject/repository/SubmissionRepository.kt index a7efc3df..d465c347 100644 --- a/src/main/kotlin/org/dropProject/repository/SubmissionRepository.kt +++ b/src/main/kotlin/org/dropProject/repository/SubmissionRepository.kt @@ -67,4 +67,7 @@ interface SubmissionRepository : JpaRepository { fun deleteAllByAssignmentId(assignmentId: String) fun findByGroupIn(groups: List): List + + @Query("SELECT SUM(LENGTH(s.buildReport.buildReport)) FROM Submission s WHERE s.assignmentId = :assignmentId AND s.buildReport IS NOT NULL") + fun getTotalBuildReportSizeByAssignmentId(@Param("assignmentId") assignmentId: String): Long? } diff --git a/src/main/kotlin/org/dropProject/services/AssignmentService.kt b/src/main/kotlin/org/dropProject/services/AssignmentService.kt index bed9f7cd..16e0f1e4 100644 --- a/src/main/kotlin/org/dropProject/services/AssignmentService.kt +++ b/src/main/kotlin/org/dropProject/services/AssignmentService.kt @@ -307,6 +307,7 @@ class AssignmentService( existingAssignment.acceptsStudentTests = assignmentForm.acceptsStudentTests existingAssignment.minStudentTests = assignmentForm.minStudentTests existingAssignment.calculateStudentTestsCoverage = assignmentForm.calculateStudentTestsCoverage + existingAssignment.coverageVisibleToStudents = assignmentForm.coverageVisibleToStudents existingAssignment.mandatoryTestsSuffix = assignmentForm.mandatoryTestsSuffix existingAssignment.cooloffPeriod = assignmentForm.cooloffPeriod existingAssignment.maxMemoryMb = assignmentForm.maxMemoryMb diff --git a/src/main/resources/drop-project.properties b/src/main/resources/drop-project.properties index c30d997c..fb32dc90 100644 --- a/src/main/resources/drop-project.properties +++ b/src/main/resources/drop-project.properties @@ -30,6 +30,13 @@ drop-project.async.timeout=180 spring.web.locale=en_US spring.web.locale-resolver=fixed +# actuator +management.endpoints.web.exposure.include=health +management.health.diskspace.threshold=500MB +# set username and password to protect /actuator/health with HTTP Basic auth (useful for uptime monitors) +drop-project.actuator.username=actuator +drop-project.actuator.password=changeme + # logging properties logging.file.path=${LOGGING_FILE_PATH:logs} spring.main.banner-mode=off diff --git a/src/main/resources/templates/admin-disk-usage.html b/src/main/resources/templates/admin-disk-usage.html new file mode 100644 index 00000000..c648cfb2 --- /dev/null +++ b/src/main/resources/templates/admin-disk-usage.html @@ -0,0 +1,54 @@ + + + + + + + + +
+ +
+ +

Disk Usage by Assignment

+ +

+ Shows file-system space (submissions and mavenized folders) and database space + (junit_report and build_report tables) per assignment, sorted by total size. +

+ + + + + + + + + + + + + + + + + + + + + + +
AssignmentSubmissions (FS)Mavenized (FS)JUnit Reports (DB)Build Reports (DB)Total
+ assignment-id +
+ Assignment Name +
0 B0 B0 B0 B0 B
+ +
+ +
+ + + + + \ No newline at end of file diff --git a/src/main/resources/templates/assignment-detail.html b/src/main/resources/templates/assignment-detail.html index e918c57a..f9afdeaf 100644 --- a/src/main/resources/templates/assignment-detail.html +++ b/src/main/resources/templates/assignment-detail.html @@ -137,7 +137,8 @@

Ass
- (calculate coverage) + (calculate coverage + - visible to studentsonly visible to teacher)
diff --git a/src/main/resources/templates/assignment-form.html b/src/main/resources/templates/assignment-form.html index eef75665..3d334dc6 100644 --- a/src/main/resources/templates/assignment-form.html +++ b/src/main/resources/templates/assignment-form.html @@ -122,6 +122,11 @@

Edit assignment

Calculate coverage of student tests?
+
+ +

Error

Check this you are asking your @@ -297,6 +302,15 @@

Edit assignment

$('#visibility').change(function() { toggleAssignees(); }); + + $('#calculateStudentTestsCoverage').change(function() { + if ($(this).is(':checked')) { + $('#coverageVisibleToStudentsDiv').show(); + } else { + $('#coverageVisibleToStudentsDiv').hide(); + $('#coverageVisibleToStudents').prop('checked', false); + } + }); /*]]>*/ diff --git a/src/main/resources/templates/build-report.html b/src/main/resources/templates/build-report.html index 7a132f60..90a3ff08 100644 --- a/src/main/resources/templates/build-report.html +++ b/src/main/resources/templates/build-report.html @@ -193,7 +193,12 @@

JUnit Summary (Student Tests) - Coverage: % (Only visible to teacher) + Coverage: % + (Only visible to teacher) + + + + Coverage: % diff --git a/src/main/resources/templates/layout/layout.html b/src/main/resources/templates/layout/layout.html index 8d46e1c8..a440e181 100644 --- a/src/main/resources/templates/layout/layout.html +++ b/src/main/resources/templates/layout/layout.html @@ -6,7 +6,7 @@ - + @@ -82,6 +82,9 @@
  • Tags
  • +
  • + Disk Usage +
  • diff --git a/src/main/resources/templates/teacher-assignments-list.html b/src/main/resources/templates/teacher-assignments-list.html index e66f42b7..72311d03 100644 --- a/src/main/resources/templates/teacher-assignments-list.html +++ b/src/main/resources/templates/teacher-assignments-list.html @@ -7,7 +7,7 @@ - +
    @@ -124,7 +124,7 @@
    - +