Skip to content
Merged
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
51 changes: 50 additions & 1 deletion server/src/main/java/org/eclipse/openvsx/admin/ScanAPI.java
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
import java.time.LocalDateTime;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;

Expand Down Expand Up @@ -65,6 +66,7 @@ public class ScanAPI {
private final org.eclipse.openvsx.scanning.ExtensionScanCompletionService completionService;
private final org.eclipse.openvsx.scanning.ScannerRegistry scannerRegistry;
private final org.eclipse.openvsx.repositories.ScannerJobRepository scanJobRepository;
private final org.eclipse.openvsx.scanning.ExtensionScanService scanService;

public ScanAPI(
RepositoryService repositories,
Expand All @@ -73,7 +75,8 @@ public ScanAPI(
StorageUtilService storageUtil,
org.eclipse.openvsx.scanning.ExtensionScanCompletionService completionService,
org.eclipse.openvsx.scanning.ScannerRegistry scannerRegistry,
org.eclipse.openvsx.repositories.ScannerJobRepository scanJobRepository
org.eclipse.openvsx.repositories.ScannerJobRepository scanJobRepository,
org.eclipse.openvsx.scanning.ExtensionScanService scanService
) {
this.repositories = repositories;
this.admins = admins;
Expand All @@ -82,6 +85,7 @@ public ScanAPI(
this.completionService = completionService;
this.scannerRegistry = scannerRegistry;
this.scanJobRepository = scanJobRepository;
this.scanService = scanService;
}

/**
Expand Down Expand Up @@ -571,6 +575,51 @@ public ResponseEntity<ScanResultJson> getScan(
}
}

/**
* Retry all failed scanner jobs for a terminal scan.
*/
@PostMapping(
path = "/{scanId}/jobs/retry",
produces = MediaType.APPLICATION_JSON_VALUE
)
@CrossOrigin
@Operation(summary = "Retry all failed scanner jobs for a scan")
@ApiResponse(
responseCode = "200",
description = "Failed jobs re-queued; returns the updated scan in SCANNING state",
content = @Content(
mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = @Schema(implementation = ScanResultJson.class)
)
)
@ApiResponse(
responseCode = "400",
description = "Scan is not terminal or has no failed jobs to retry",
content = @Content()
)
@ApiResponse(
responseCode = "404",
description = "Scan or scanner jobs were not found",
content = @Content()
)
public ResponseEntity<ScanResultJson> retryFailedScannerJobs(
@PathVariable @Parameter(description = "Scan ID", example = "123") long scanId
) {
try {
var adminUser = admins.checkAdminUser();

var scan = Optional.ofNullable(repositories.findExtensionScan(scanId))
.orElseThrow(() -> new ErrorResultException("Scan not found: " + scanId, HttpStatus.NOT_FOUND));

var updatedScan = scanService.retryFailedJobs(scan);

logs.logAction(adminUser, ResultJson.success("Retrying failed scanner jobs for scan #" + scanId));
return ResponseEntity.ok(toScanResultJson(updatedScan));
} catch (ErrorResultException exc) {
return exc.toResponseEntity(ScanResultJson.class);
}
}

/**
* Make security decisions for one or more quarantined scans.
* Only valid for scans with QUARANTINED status.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,16 @@ public void addCheckResult(ScanCheckResult result) {
result.setScan(this);
}

/**
* Resets this {@code ExtensionScan} instance back to status {@code SCANNING}
* to retry a failed scanner jobs.
*/
public void resetToScanning() {
setStatus(ScanStatus.SCANNING);
setCompletedAt(null);
setErrorMessage(null);
}

@Override
public boolean equals(Object o) {
if (this == o) return true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
package org.eclipse.openvsx.entities;

import jakarta.persistence.*;
import org.eclipse.openvsx.util.TimeUtil;

import java.time.LocalDateTime;
import java.util.Objects;

Expand Down Expand Up @@ -248,7 +250,24 @@ public String getFileHashesJson() {
public void setFileHashesJson(String fileHashesJson) {
this.fileHashesJson = fileHashesJson;
}


/**
* Resets this {@code ScannerJob} instance back to status {@code QUEUED}
* to retry a specific scanner job.
*/
public void resetToQueued() {
setStatus(ScannerJob.JobStatus.QUEUED);
setErrorMessage(null);
setExternalJobId(null);
setPollAttempts(0);
setPollLeaseUntil(null);
setRecoveryInProgress(false);

var now = TimeUtil.getCurrentUTC();
setCreatedAt(now);
setUpdatedAt(now);
}

@Override
public boolean equals(Object o) {
if (this == o) return true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,9 @@ ScanCheckResult findFirstByScanAndCheckTypeOrderByStartedAtDesc(
* Delete all check results for a scan.
*/
void deleteByScan(ExtensionScan scan);

/**
* Delete the check result recorded for a specific scanner job.
*/
void deleteByScannerJobId(Long scannerJobId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import org.eclipse.openvsx.entities.*;
import org.eclipse.openvsx.repositories.FileDecisionRepository;
import org.eclipse.openvsx.repositories.RepositoryService;
import org.eclipse.openvsx.repositories.ScanCheckResultRepository;
import org.eclipse.openvsx.repositories.ScannerJobRepository;
import org.eclipse.openvsx.util.TimeUtil;
import org.slf4j.Logger;
Expand Down Expand Up @@ -56,19 +57,22 @@ public class ExtensionScanPersistenceService {
private final ObjectMapper objectMapper;
private final FileDecisionRepository fileDecisionRepository;
private final ScannerJobRepository scannerJobRepository;
private final ScanCheckResultRepository scanCheckResultRepository;
private final ScannerRegistry scannerRegistry;

public ExtensionScanPersistenceService(
RepositoryService repositories,
ObjectMapper objectMapper,
FileDecisionRepository fileDecisionRepository,
ScannerJobRepository scannerJobRepository,
ScanCheckResultRepository scanCheckResultRepository,
ScannerRegistry scannerRegistry
) {
this.repositories = repositories;
this.objectMapper = objectMapper;
this.fileDecisionRepository = fileDecisionRepository;
this.scannerJobRepository = scannerJobRepository;
this.scanCheckResultRepository = scanCheckResultRepository;
this.scannerRegistry = scannerRegistry;
}

Expand Down Expand Up @@ -176,6 +180,25 @@ public void removeScan(@Nonnull ExtensionScan scan) {
repositories.deleteExtensionScan(scan);
}

/**
* Reset a FAILED scanner job back to QUEUED and flip the parent scan to
* SCANNING so the completion service will process it once the job finishes.
* <p>
* Caller is expected to have already validated that {@code job} belongs to
* {@code scan}, that the job is FAILED, and that the scan is terminal.
*/
@Transactional(TxType.REQUIRES_NEW)
public void resetJobForRetry(@Nonnull ExtensionScan scan, @Nonnull ScannerJob job) {
// Drop the stale check result so the retry's outcome is the only record the UI shows
scanCheckResultRepository.deleteByScannerJobId(job.getId());

job.resetToQueued();
scannerJobRepository.save(job);

scan.resetToScanning();
repositories.saveExtensionScan(scan);
}

/**
* Records a validation failure with the given check type.
*/
Expand Down Expand Up @@ -572,10 +595,10 @@ private Map<String, String> parseFileHashes(String fileHashesJson) {

/**
* Delete all scan-related data for a specific extension version.
*
* <p>
* This should be called when an extension version is deleted to prevent
* orphaned scan jobs from trying to access deleted files.
*
* <p>
* Also marks any in-progress scans as ERRORED so they don't remain stuck.
*/
@Transactional
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import org.jobrunr.scheduling.JobRequestScheduler;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;

import jakarta.annotation.Nonnull;
Expand Down Expand Up @@ -255,6 +256,88 @@ public boolean submitScannerJobs(@Nonnull ExtensionScan scan, @Nonnull Extension
return true;
}

/**
* Retry a single FAILED scanner job for a terminal scan.
* <p>
* The job is moved back to QUEUED (preserving the row), the parent scan is
* flipped back to SCANNING so the completion service will pick up the
* result, and a fresh JobRunr request is enqueued.
*
* @throws ErrorResultException if the scan is not in a terminal state, the
* job does not belong to the scan, or the job is not in FAILED status.
*/
public void retryFailedJob(@Nonnull ExtensionScan scan, @Nonnull ScannerJob job) {
if (job.getStatus().isActive()) {
throw new ErrorResultException(
"Cannot retry: this job is currently in an active state (current: " + job.getStatus() + ")"
);
}

// reset the scan job for retry
persistenceService.resetJobForRetry(scan, job);

var scanner = scannerRegistry.getScanner(job.getScannerType());
if (scanner == null) {
throw new ErrorResultException(
String.format("Encountered unknown scanner type %s when retrying scan job with id %s",
job.getScannerType(), job.getScanId()
)
);
}

// Check if the scanner has a maxConcurrency set, in which case the invocation
// will be coordinated by the ScannerConcurrencyDispatcher, otherwise dispatch immediately
if (scanner.getMaxConcurrency() <= 0) {
try {
logger.info("Retrying scanner job {} ({}) for scanId={}, extension={}.{}",
job.getId(), job.getScannerType(), job.getScanId(), scan.getNamespaceName(), scan.getExtensionName()
);
jobScheduler.enqueue(new ScannerInvocationRequest(job.getScannerType(), job.getExtensionVersionId(), job.getScanId()));
} catch (Exception e) {
logger.error("Failed to enqueue retry scanner job for scanner {} (jobId={}, scanId={}): {}",
job.getScannerType(), job.getId(), job.getScanId(), e.getMessage()
);
}
}
}

/**
* Retry all FAILED scanner jobs for a terminal scan.
*
* @return the scan after it has been flipped back to SCANNING
* @throws ErrorResultException if the scan is not terminal, has no jobs,
* or has no failed jobs to retry.
*/
public ExtensionScan retryFailedJobs(@Nonnull ExtensionScan scan) {
if (!scan.getStatus().isCompleted()) {
throw new ErrorResultException(
String.format(
"Cannot retry failed jobs: scan #%d is not in a terminal state (current: %s)",
scan.getId(),
scan.getStatus()
),
HttpStatus.BAD_REQUEST
);
}

String scanId = String.valueOf(scan.getId());
var jobs = scanJobRepository.findByScanId(scanId);
if (jobs.isEmpty()) {
throw new ErrorResultException("No scanner jobs found for scan #" + scanId, HttpStatus.NOT_FOUND);
}

var failedJobs = jobs.stream()
.filter(job -> job.getStatus() == ScannerJob.JobStatus.FAILED)
.toList();

if (failedJobs.isEmpty()) {
throw new ErrorResultException("No failed scanner jobs found for scan #" + scanId, HttpStatus.BAD_REQUEST);
}

failedJobs.forEach(failedJob -> retryFailedJob(scan, failedJob));
return scan;
}

/**
* Check if there are any scanners registered for long-running scans.
*/
Expand Down
Loading