Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions deploy/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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)
# ===================
Expand Down
17 changes: 17 additions & 0 deletions docker-deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 5 additions & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

<groupId>org.dropProject</groupId>
<artifactId>drop-project</artifactId>
<version>1.0.0-beta.4</version>
<version>1.0.0-beta.5</version>
<packaging>jar</packaging>

<name>DropProject</name>
Expand Down Expand Up @@ -180,6 +180,10 @@
<version>1.23.6</version>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
Expand Down
56 changes: 56 additions & 0 deletions src/main/kotlin/org/dropProject/config/ActuatorSecurityConfig.kt
Original file line number Diff line number Diff line change
@@ -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()
}
}
12 changes: 11 additions & 1 deletion src/main/kotlin/org/dropProject/config/DropProjectProperties.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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()
}
Expand Down
63 changes: 62 additions & 1 deletion src/main/kotlin/org/dropProject/controllers/AdminController.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)

Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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!!)

Expand Down Expand Up @@ -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!!,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/main/kotlin/org/dropProject/dao/Assignment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
55 changes: 55 additions & 0 deletions src/main/kotlin/org/dropProject/data/AssignmentDiskUsage.kt
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
1 change: 1 addition & 0 deletions src/main/kotlin/org/dropProject/forms/AssignmentForm.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -35,4 +37,7 @@ interface JUnitReportRepository : JpaRepository<JUnitReport, Long> {

@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?
}
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,7 @@ interface SubmissionRepository : JpaRepository<Submission, Long> {
fun deleteAllByAssignmentId(assignmentId: String)

fun findByGroupIn(groups: List<ProjectGroup>): List<Submission>

@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?
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions src/main/resources/drop-project.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading