diff --git a/build.gradle b/build.gradle index e11aee694..530d84bf3 100644 --- a/build.gradle +++ b/build.gradle @@ -35,9 +35,8 @@ dependencies { compileOnly("io.micronaut:micronaut-http-validation") implementation("jakarta.persistence:jakarta.persistence-api:3.0.0") api 'io.seqera:lib-mail:1.0.0' - api 'io.seqera:wave-api:0.12.0' - api 'io.seqera:wave-utils:0.13.1' - + api 'io.seqera:wave-api:0.13.1' + api 'io.seqera:wave-utils:0.14.1' implementation("io.micronaut:micronaut-http-client") implementation("io.micronaut:micronaut-jackson-databind") implementation("io.micronaut.groovy:micronaut-runtime-groovy") @@ -72,6 +71,7 @@ dependencies { implementation 'software.amazon.awssdk:ses' implementation 'org.yaml:snakeyaml:2.0' implementation 'com.github.ben-manes.caffeine:caffeine:3.1.8' + implementation 'org.luaj:luaj-jse:3.0.1' //object storage dependency implementation("io.micronaut.objectstorage:micronaut-object-storage-aws") // include sts to allow the use of service account role - https://stackoverflow.com/a/73306570 diff --git a/src/main/groovy/io/seqera/wave/auth/RegistryAuthStore.groovy b/src/main/groovy/io/seqera/wave/auth/RegistryAuthStore.groovy index de729e340..ba1c834c4 100644 --- a/src/main/groovy/io/seqera/wave/auth/RegistryAuthStore.groovy +++ b/src/main/groovy/io/seqera/wave/auth/RegistryAuthStore.groovy @@ -51,7 +51,7 @@ class RegistryAuthStore extends AbstractStateStore { @Override protected String getPrefix() { - return 'registry-auth/v1:' + return 'registry-auth/v1' } @Override diff --git a/src/main/groovy/io/seqera/wave/auth/RegistryTokenStore.groovy b/src/main/groovy/io/seqera/wave/auth/RegistryTokenStore.groovy index 8595bcebc..f20eb2db8 100644 --- a/src/main/groovy/io/seqera/wave/auth/RegistryTokenStore.groovy +++ b/src/main/groovy/io/seqera/wave/auth/RegistryTokenStore.groovy @@ -44,7 +44,7 @@ class RegistryTokenStore extends AbstractStateStore { @Override protected String getPrefix() { - return 'registry-token/v1:' + return 'registry-token/v1' } @Override diff --git a/src/main/groovy/io/seqera/wave/configuration/ScanConfig.groovy b/src/main/groovy/io/seqera/wave/configuration/ScanConfig.groovy index 41749304b..3d1e9cdf0 100644 --- a/src/main/groovy/io/seqera/wave/configuration/ScanConfig.groovy +++ b/src/main/groovy/io/seqera/wave/configuration/ScanConfig.groovy @@ -75,6 +75,9 @@ class ScanConfig { @Value('${wave.scan.status.duration:1h}') Duration statusDuration + @Value('${wave.scan.id.duration:7d}') + Duration scanIdDuration + String getScanImage() { return scanImage } @@ -86,6 +89,11 @@ class ScanConfig { return result } + @Memoized + Path getWorkspace() { + Path.of(buildDirectory).toAbsolutePath() + } + String getRequestsCpu() { return requestsCpu } diff --git a/src/main/groovy/io/seqera/wave/controller/BuildController.groovy b/src/main/groovy/io/seqera/wave/controller/BuildController.groovy index a4c5378ca..de0a9479a 100644 --- a/src/main/groovy/io/seqera/wave/controller/BuildController.groovy +++ b/src/main/groovy/io/seqera/wave/controller/BuildController.groovy @@ -21,6 +21,7 @@ package io.seqera.wave.controller import groovy.transform.CompileStatic import groovy.util.logging.Slf4j import io.micronaut.core.annotation.Nullable +import io.micronaut.http.HttpHeaders import io.micronaut.http.HttpResponse import io.micronaut.http.MediaType import io.micronaut.http.annotation.Controller @@ -30,11 +31,8 @@ import io.micronaut.http.server.types.files.StreamedFile import io.micronaut.scheduling.TaskExecutors import io.micronaut.scheduling.annotation.ExecuteOn import io.seqera.wave.api.BuildStatusResponse -import io.seqera.wave.exception.BadRequestException import io.seqera.wave.service.builder.ContainerBuildService import io.seqera.wave.service.logs.BuildLogService -import io.seqera.wave.service.mirror.ContainerMirrorService -import io.seqera.wave.service.mirror.MirrorRequest import io.seqera.wave.service.persistence.WaveBuildRecord import jakarta.inject.Inject /** @@ -51,12 +49,9 @@ class BuildController { @Inject private ContainerBuildService buildService - @Inject - private ContainerMirrorService mirrorService - @Inject @Nullable - BuildLogService logService + private BuildLogService logService @Get("/v1alpha1/builds/{buildId}") HttpResponse getBuildRecord(String buildId) { @@ -79,25 +74,22 @@ class BuildController { @Get("/v1alpha1/builds/{buildId}/status") HttpResponse getBuildStatus(String buildId) { - final resp = buildResponse0(buildId) - resp != null - ? HttpResponse.ok(resp) + final build = buildService.getBuildRecord(buildId) + build != null + ? HttpResponse.ok(build.toStatusResponse()) : HttpResponse.notFound() } - protected BuildStatusResponse buildResponse0(String buildId) { - if( !buildId ) - throw new BadRequestException("Missing 'buildId' parameter") - // build IDs starting with the `mr-` prefix are interpreted as mirror requests - if( buildId.startsWith(MirrorRequest.ID_PREFIX) ) { - return mirrorService - .getMirrorEntry(buildId) - ?.toStatusResponse() - } - else { - return buildService - .getBuildRecord(buildId) - ?.toStatusResponse() - } + @Produces(MediaType.TEXT_PLAIN) + @Get(value="/v1alpha1/builds/{buildId}/condalock") + HttpResponse getCondaLock(String buildId){ + if( logService==null ) + throw new IllegalStateException("Build Logs service not configured") + final condaLock = logService.fetchCondaLockStream(buildId) + return condaLock + ? HttpResponse.ok(condaLock) + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"conda-env-${buildId}.lock\"") + : HttpResponse.notFound() } + } diff --git a/src/main/groovy/io/seqera/wave/controller/ContainerController.groovy b/src/main/groovy/io/seqera/wave/controller/ContainerController.groovy index f42ffc08f..cf5780ec9 100644 --- a/src/main/groovy/io/seqera/wave/controller/ContainerController.groovy +++ b/src/main/groovy/io/seqera/wave/controller/ContainerController.groovy @@ -19,6 +19,7 @@ package io.seqera.wave.controller import java.nio.file.Path +import java.time.Instant import java.util.concurrent.CompletableFuture import javax.annotation.PostConstruct @@ -39,7 +40,9 @@ import io.micronaut.scheduling.annotation.ExecuteOn import io.micronaut.security.annotation.Secured import io.micronaut.security.authentication.AuthorizationException import io.micronaut.security.rules.SecurityRule +import io.seqera.wave.api.ContainerStatusResponse import io.seqera.wave.api.ImageNameStrategy +import io.seqera.wave.api.ScanMode import io.seqera.wave.api.SubmitContainerTokenRequest import io.seqera.wave.api.SubmitContainerTokenResponse import io.seqera.wave.configuration.BuildConfig @@ -51,7 +54,6 @@ import io.seqera.wave.exchange.DescribeWaveContainerResponse import io.seqera.wave.model.ContainerCoordinates import io.seqera.wave.ratelimit.AcquireRequest import io.seqera.wave.ratelimit.RateLimiterService -import io.seqera.wave.service.ContainerRequestData import io.seqera.wave.service.UserService import io.seqera.wave.service.builder.BuildRequest import io.seqera.wave.service.builder.BuildTrack @@ -65,15 +67,17 @@ import io.seqera.wave.service.pairing.PairingService import io.seqera.wave.service.pairing.socket.PairingChannel import io.seqera.wave.service.persistence.PersistenceService import io.seqera.wave.service.persistence.WaveContainerRecord -import io.seqera.wave.service.token.ContainerTokenService -import io.seqera.wave.service.token.TokenData +import io.seqera.wave.service.request.ContainerRequest +import io.seqera.wave.service.request.ContainerRequestService +import io.seqera.wave.service.request.ContainerStatusService +import io.seqera.wave.service.request.TokenData +import io.seqera.wave.service.scan.ContainerScanService import io.seqera.wave.service.validation.ValidationService import io.seqera.wave.tower.PlatformId import io.seqera.wave.tower.User import io.seqera.wave.tower.auth.JwtAuth import io.seqera.wave.tower.auth.JwtAuthStore import io.seqera.wave.util.DataTimeUtils -import io.seqera.wave.util.LongRndKey import jakarta.inject.Inject import static io.micronaut.http.HttpHeaders.WWW_AUTHENTICATE import static io.seqera.wave.service.builder.BuildFormat.DOCKER @@ -100,63 +104,74 @@ import static java.util.concurrent.CompletableFuture.completedFuture @ExecuteOn(TaskExecutors.IO) class ContainerController { - @Inject HttpClientAddressResolver addressResolver - @Inject ContainerTokenService tokenService - @Inject UserService userService - @Inject JwtAuthStore jwtAuthStore + @Inject + private HttpClientAddressResolver addressResolver + + @Inject + private ContainerRequestService containerService + + @Inject + private UserService userService + + @Inject + private JwtAuthStore jwtAuthStore @Inject @Value('${wave.allowAnonymous}') - Boolean allowAnonymous + private Boolean allowAnonymous @Inject @Value('${wave.server.url}') - String serverUrl + private String serverUrl @Inject @Value('${tower.endpoint.url:`https://api.cloud.seqera.io`}') - String towerEndpointUrl - - @Value('${wave.scan.enabled:false}') - boolean scanEnabled + private String towerEndpointUrl @Inject - BuildConfig buildConfig + private BuildConfig buildConfig @Inject - ContainerBuildService buildService + private ContainerBuildService buildService @Inject - ContainerInspectService inspectService + private ContainerInspectService inspectService @Inject - RegistryProxyService registryProxyService + private RegistryProxyService registryProxyService @Inject - PersistenceService persistenceService + private PersistenceService persistenceService @Inject - ValidationService validationService + private ValidationService validationService @Inject - PairingService pairingService + private PairingService pairingService @Inject - PairingChannel pairingChannel + private PairingChannel pairingChannel @Inject - FreezeService freezeService + private FreezeService freezeService @Inject - ContainerInclusionService inclusionService + private ContainerInclusionService inclusionService @Inject @Nullable - RateLimiterService rateLimiterService + private RateLimiterService rateLimiterService @Inject private ContainerMirrorService mirrorService + @Inject + private ContainerStatusService statusService + + @Inject + @Nullable + private ContainerScanService scanService + @PostConstruct private void init() { log.info "Wave server url: $serverUrl; allowAnonymous: $allowAnonymous; tower-endpoint-url: $towerEndpointUrl; default-build-repo: $buildConfig.defaultBuildRepository; default-cache-repo: $buildConfig.defaultCacheRepository; default-public-repo: $buildConfig.defaultPublicRepository" @@ -251,13 +266,14 @@ class ContainerController { rateLimiterService.acquirePull(new AcquireRequest(identity.userId as String, ip)) // create request data final data = makeRequestData(req, identity, ip) - final token = tokenService.computeToken(data) + final token = containerService.computeToken(data) final target = targetImage(token.value, data.coordinates()) final resp = v2 ? makeResponseV2(data, token, target) : makeResponseV1(data, token, target) // persist request storeContainerRequest0(req, data, token, target, ip) + scanService?.scanOnRequest(data) // log the response log.debug "New container request fulfilled - token=$token.value; expiration=$token.expiration; container=$data.containerImage; build=$resp.buildId; identity=$identity" // return response @@ -276,9 +292,9 @@ class ContainerController { return true } - protected void storeContainerRequest0(SubmitContainerTokenRequest req, ContainerRequestData data, TokenData token, String target, String ip) { + protected void storeContainerRequest0(SubmitContainerTokenRequest req, ContainerRequest data, TokenData token, String target, String ip) { try { - final recrd = new WaveContainerRecord(req, data, token.value, target, ip, token.expiration) + final recrd = new WaveContainerRecord(req, data, target, ip, token.expiration) persistenceService.saveContainerRequest(recrd) } catch (Throwable e) { @@ -334,7 +350,6 @@ class ContainerController { final configJson = inspectService.credentialsConfigJson(containerSpec, buildRepository, cacheRepository, identity) final containerConfig = req.freeze ? req.containerConfig : null final offset = DataTimeUtils.offsetId(req.timestamp) - final scanId = scanEnabled && format==DOCKER ? LongRndKey.rndHex() : null // use 'imageSuffix' strategy by default for public repo images final nameStrategy = req.nameStrategy==null && buildRepository @@ -343,10 +358,15 @@ class ContainerController { checkContainerSpec(containerSpec) - // create a unique digest to identify the build request + // create a unique digest to identify the build req final containerId = makeContainerId(containerSpec, condaContent, platform, buildRepository, req.buildContext) final targetImage = makeTargetImage(format, buildRepository, containerId, condaContent, nameStrategy) final maxDuration = buildConfig.buildMaxDuration(req) + // default to async scan for build req for backward compatibility + final scanMode = req.scanMode!=null ? req.scanMode : ScanMode.async + // digest is not needed for builds, because they use a unique checksum in the container name + final scanId = scanService?.getScanId(targetImage, null, scanMode, req.format) + return new BuildRequest( containerId, containerSpec, @@ -387,7 +407,13 @@ class ContainerController { } } - ContainerRequestData makeRequestData(SubmitContainerTokenRequest req, PlatformId identity, String ip) { + protected String getContainerDigest(String containerImage, PlatformId identity) { + containerImage + ? registryProxyService.getImageDigest(containerImage, identity) + : null + } + + ContainerRequest makeRequestData(SubmitContainerTokenRequest req, PlatformId identity, String ip) { if( !req.containerImage && !req.containerFile ) throw new BadRequestException("Specify either 'containerImage' or 'containerFile' attribute") if( req.containerImage && req.containerFile ) @@ -406,11 +432,18 @@ class ContainerController { req = freezeService.freezeBuildRequest(req, identity) } + final digest = getContainerDigest(req.containerImage, identity) + if( !digest && req.containerImage ) + throw new BadRequestException("Container image '${req.containerImage}' does not exist or access is not authorized") + String targetImage String targetContent String condaContent String buildId boolean buildNew + String scanId + Boolean mirrorFlag + Boolean scanOnRequest = false if( req.containerFile ) { final build = makeBuildRequest(req, identity, ip) final track = checkBuild(build, req.dryRun) @@ -419,15 +452,19 @@ class ContainerController { condaContent = build.condaFile buildId = track.id buildNew = !track.cached + scanId = build.scanId + mirrorFlag = false } else if( req.mirrorRegistry ) { - final mirror = makeMirrorRequest(req, identity) + final mirror = makeMirrorRequest(req, identity, digest) final track = checkMirror(mirror, identity, req.dryRun) targetImage = track.targetImage targetContent = null condaContent = null buildId = track.id buildNew = !track.cached + scanId = mirror.scanId + mirrorFlag = true } else if( req.containerImage ) { // normalize container image @@ -437,11 +474,14 @@ class ContainerController { condaContent = null buildId = null buildNew = null + scanId = scanService?.getScanId(req.containerImage, digest, req.scanMode, req.format) + mirrorFlag = null + scanOnRequest = true } else throw new IllegalStateException("Specify either 'containerImage' or 'containerFile' attribute") - new ContainerRequestData( + ContainerRequest.create( identity, targetImage, targetContent, @@ -451,11 +491,16 @@ class ContainerController { buildId, buildNew, req.freeze, - req.mirrorRegistry!=null + mirrorFlag, + scanId, + req.scanMode, + req.scanLevels, + scanOnRequest, + Instant.now() ) } - protected MirrorRequest makeMirrorRequest(SubmitContainerTokenRequest request, PlatformId identity) { + protected MirrorRequest makeMirrorRequest(SubmitContainerTokenRequest request, PlatformId identity, String digest) { final coords = ContainerCoordinates.parse(request.containerImage) if( coords.registry == request.mirrorRegistry ) throw new BadRequestException("Source and target mirror registry as the same - offending value '${request.mirrorRegistry}'") @@ -464,16 +509,22 @@ class ContainerController { final platform = request.containerPlatform ? ContainerPlatform.of(request.containerPlatform) : ContainerPlatform.DEFAULT - final digest = registryProxyService.getImageDigest(request.containerImage, identity) - if( !digest ) - throw new BadRequestException("Container image '$request.containerImage' does not exist") + + final offset = DataTimeUtils.offsetId(request.timestamp) + final scanId = scanService?.getScanId(targetImage, digest, request.scanMode, request.format) + return MirrorRequest.create( request.containerImage, targetImage, digest, platform, Path.of(buildConfig.buildWorkspace).toAbsolutePath(), - configJson ) + configJson, + scanId, + Instant.now(), + offset, + identity + ) } protected BuildTrack checkMirror(MirrorRequest request, PlatformId identity, boolean dryRun) { @@ -489,7 +540,7 @@ class ContainerController { // check for existing image if( request.digest==targetDigest ) { log.debug "== Found cached request for request: $request" - final cache = persistenceService.loadMirrorEntry(request.targetImage, targetDigest) + final cache = persistenceService.loadMirrorResult(request.targetImage, targetDigest) return new BuildTrack(cache?.mirrorId, request.targetImage, true) } else { @@ -503,7 +554,7 @@ class ContainerController { @Get('/container-token/{token}') HttpResponse describeContainerRequest(String token) { - final data = persistenceService.loadContainerRequest(token) + final data = containerService.loadContainerRecord(token) if( !data ) throw new NotFoundException("Missing container record for token: $token") // return the response @@ -513,7 +564,7 @@ class ContainerController { @Secured(SecurityRule.IS_AUTHENTICATED) @Delete('/container-token/{token}') HttpResponse deleteContainerRequest(String token) { - final record = tokenService.evictRequest(token) + final record = containerService.evictRequest(token) if( !record ){ throw new NotFoundException("Missing container record for token: $token") } @@ -571,12 +622,20 @@ class ContainerController { .header(WWW_AUTHENTICATE, "Basic realm=Wave Authentication") } - @Get('/v1alpha2/container/{containerId}') - HttpResponse getContainerDetails(String containerId) { - final data = persistenceService.loadContainerRequest(containerId) + @Get('/v1alpha2/container/{requestId}') + HttpResponse getContainerDetails(String requestId) { + final data = containerService.loadContainerRecord(requestId) if( !data ) return HttpResponse.notFound() return HttpResponse.ok(data) } + @Get('/v1alpha2/container/{requestId}/status') + HttpResponse getContainerStatus(String requestId) { + final ContainerStatusResponse resp = statusService.getContainerStatus(requestId) + if( !resp ) + return HttpResponse.notFound() + return HttpResponse.ok(resp) + } + } diff --git a/src/main/groovy/io/seqera/wave/controller/MirrorController.groovy b/src/main/groovy/io/seqera/wave/controller/MirrorController.groovy index 75253b2d4..8aaf87a51 100644 --- a/src/main/groovy/io/seqera/wave/controller/MirrorController.groovy +++ b/src/main/groovy/io/seqera/wave/controller/MirrorController.groovy @@ -26,7 +26,7 @@ import io.micronaut.http.annotation.Get import io.micronaut.scheduling.TaskExecutors import io.micronaut.scheduling.annotation.ExecuteOn import io.seqera.wave.service.mirror.ContainerMirrorService -import io.seqera.wave.service.mirror.MirrorEntry +import io.seqera.wave.service.mirror.MirrorResult import jakarta.inject.Inject /** * Implements a controller for container mirror apis @@ -43,11 +43,11 @@ class MirrorController { private ContainerMirrorService mirrorService @Get("/v1alpha1/mirrors/{mirrorId}") - HttpResponse getMirrorRecord(String mirrorId) { - final result = mirrorService.getMirrorEntry(mirrorId) + HttpResponse getMirrorRecord(String mirrorId) { + final result = mirrorService.getMirrorResult(mirrorId) return result ? HttpResponse.ok(result) - : HttpResponse.notFound() + : HttpResponse.notFound() } } diff --git a/src/main/groovy/io/seqera/wave/controller/ScanController.groovy b/src/main/groovy/io/seqera/wave/controller/ScanController.groovy index 830c60eb9..230a8b4a9 100644 --- a/src/main/groovy/io/seqera/wave/controller/ScanController.groovy +++ b/src/main/groovy/io/seqera/wave/controller/ScanController.groovy @@ -47,7 +47,7 @@ class ScanController { @Get("/v1alpha1/scans/{scanId}") HttpResponse scanImage(String scanId){ - final record = containerScanService.getScanResult(scanId) + final record = containerScanService.getScanRecord(scanId) return record ? HttpResponse.ok(record) : HttpResponse.notFound() diff --git a/src/main/groovy/io/seqera/wave/controller/ViewController.groovy b/src/main/groovy/io/seqera/wave/controller/ViewController.groovy index 5b8c16aff..bbe9bcb1b 100644 --- a/src/main/groovy/io/seqera/wave/controller/ViewController.groovy +++ b/src/main/groovy/io/seqera/wave/controller/ViewController.groovy @@ -25,18 +25,23 @@ import groovy.util.logging.Slf4j import io.micronaut.context.annotation.Value import io.micronaut.core.annotation.Nullable import io.micronaut.http.HttpResponse +import io.micronaut.http.HttpStatus import io.micronaut.http.annotation.Controller import io.micronaut.http.annotation.Get import io.micronaut.http.annotation.QueryValue import io.micronaut.scheduling.TaskExecutors import io.micronaut.scheduling.annotation.ExecuteOn import io.micronaut.views.View +import io.seqera.wave.exception.HttpResponseException import io.seqera.wave.exception.NotFoundException import io.seqera.wave.service.builder.ContainerBuildService import io.seqera.wave.service.inspect.ContainerInspectService import io.seqera.wave.service.logs.BuildLogService +import io.seqera.wave.service.mirror.ContainerMirrorService +import io.seqera.wave.service.mirror.MirrorResult import io.seqera.wave.service.persistence.PersistenceService import io.seqera.wave.service.persistence.WaveBuildRecord +import io.seqera.wave.service.persistence.WaveScanRecord import io.seqera.wave.service.scan.ContainerScanService import io.seqera.wave.service.scan.ScanEntry import io.seqera.wave.util.JacksonHelper @@ -72,8 +77,43 @@ class ViewController { private ContainerInspectService inspectService @Inject + @Nullable private ContainerScanService scanService + @Inject + private ContainerMirrorService mirrorService + + @View("mirror-view") + @Get('/mirrors/{mirrorId}') + HttpResponse viewMirror(String mirrorId) { + final result = mirrorService.getMirrorResult(mirrorId) + if( !result ) + throw new NotFoundException("Unknown container mirror id '$mirrorId'") + return HttpResponse.ok(renderMirrorView(result)) + } + + protected Map renderMirrorView(MirrorResult result) { + // create template binding + final binding = new HashMap(20) + binding.mirror_id = result.mirrorId + binding.mirror_success = result.succeeded() + binding.mirror_failed = result.exitCode && result.exitCode != 0 + binding.mirror_in_progress = result.exitCode == null + binding.mirror_exitcode = result.exitCode ?: null + binding.mirror_logs = result.exitCode ? result.logs : null + binding.mirror_time = formatTimestamp(result.creationTime, result.offsetId) ?: '-' + binding.mirror_duration = formatDuration(result.duration) ?: '-' + binding.mirror_source_image = result.sourceImage + binding.mirror_target_image = result.targetImage + binding.mirror_platform = result.platform + binding.mirror_digest = result.digest ?: '-' + binding.mirror_user = result.userName ?: '-' + binding.put('server_url', serverUrl) + binding.scan_url = result.scanId && result.succeeded() ? "$serverUrl/view/scans/${result.scanId}" : null + binding.scan_id = result.scanId + return binding + } + @View("build-view") @Get('/builds/{buildId}') HttpResponse viewBuild(String buildId) { @@ -92,7 +132,7 @@ class ViewController { // go ahead with proper handling final record = buildService.getBuildRecord(buildId) if( !record ) - throw new NotFoundException("Unknown build id '$buildId'") + throw new NotFoundException("Unknown container build id '$buildId'") return HttpResponse.ok(renderBuildView(record)) } @@ -117,7 +157,7 @@ class ViewController { final rec = buildService.getLatestBuild(buildId) if( !rec ) return null - if( !rec.buildId.startsWith(buildId) ) + if( !rec.buildId.contains(buildId) ) return null if( rec.buildId==buildId ) return null @@ -152,6 +192,10 @@ class ViewController { binding.build_log_truncated = buildLog?.truncated binding.build_log_url = "$serverUrl/v1alpha1/builds/${result.buildId}/logs" } + //add conda lock file when available + if( buildLogService && result.condaFile ) { + binding.build_conda_lock_data = buildLogService.fetchCondaLockString(result.buildId) + } // result the main object return binding } @@ -175,6 +219,7 @@ class ViewController { binding.source_container_image = data.sourceImage ?: '-' binding.source_container_digest = data.sourceDigest ?: '-' + binding.wave_container_show = !data.freeze && !data.mirror ? true : null binding.wave_container_image = data.waveImage ?: '-' binding.wave_container_digest = data.waveDigest ?: '-' @@ -192,9 +237,16 @@ class ViewController { binding.build_id = data.buildId ?: '-' binding.build_cached = data.buildId ? !data.buildNew : '-' binding.build_freeze = data.buildId ? data.freeze : '-' - binding.build_url = data.buildId ? "$serverUrl/view/builds/${data.buildId}" : '#' + binding.build_url = data.buildId ? "$serverUrl/view/builds/${data.buildId}" : null binding.fusion_version = data.fusionVersion ?: '-' + binding.scan_id = data.scanId + binding.scan_url = data.scanId ? "$serverUrl/view/scans/${data.scanId}" : null + + binding.mirror_id = data.mirror ? data.buildId : null + binding.mirror_url = data.mirror ? "$serverUrl/view/mirrors/${data.buildId}" : null + binding.mirror_cached = data.mirror ? !data.buildNew : null + return HttpResponse.>ok(binding) } @@ -203,7 +255,8 @@ class ViewController { HttpResponse> viewScan(String scanId) { final binding = new HashMap(10) try { - final result = loadScanResult(scanId) + final result = loadScanRecord(scanId) + log.debug "Render scan record=$result" makeScanViewBinding(result, binding) } catch (NotFoundException e){ @@ -225,19 +278,13 @@ class ViewController { * @return The {@link ScanEntry} object associated with the specified build ID or throws the exception {@link NotFoundException} otherwise * @throws NotFoundException If the a record for the specified build ID cannot be found */ - protected ScanEntry loadScanResult(String scanId) { - final scanRecord = scanService.getScanResult(scanId) + protected WaveScanRecord loadScanRecord(String scanId) { + if( !scanService ) + throw new HttpResponseException(HttpStatus.SERVICE_UNAVAILABLE, "Scan service is not enabled - Check Wave configuration setting 'wave.scan.enabled'") + final scanRecord = scanService.getScanRecord(scanId) if( !scanRecord ) throw new NotFoundException("No scan report exists with id: ${scanId}") - - return ScanEntry.create( - scanRecord.id, - scanRecord.buildId, - scanRecord.containerImage, - scanRecord.startTime, - scanRecord.duration, - scanRecord.status, - scanRecord.vulnerabilities ) + return scanRecord } @View("inspect-view") @@ -262,9 +309,9 @@ class ViewController { return HttpResponse.>ok(binding) } - Map makeScanViewBinding(ScanEntry result, Map binding=new HashMap(10)) { + Map makeScanViewBinding(WaveScanRecord result, Map binding=new HashMap(10)) { binding.should_refresh = !result.done() - binding.scan_id = result.scanId + binding.scan_id = result.id binding.scan_container_image = result.containerImage ?: '-' binding.scan_exist = true binding.scan_completed = result.done() @@ -273,9 +320,16 @@ class ViewController { binding.scan_succeeded = result.status == ScanEntry.SUCCEEDED binding.scan_exitcode = result.exitCode binding.scan_logs = result.logs - + // build info binding.build_id = result.buildId - binding.build_url = "$serverUrl/view/builds/${result.buildId}" + binding.build_url = result.buildId ? "$serverUrl/view/builds/${result.buildId}" : null + // mirror info + binding.mirror_id = result.mirrorId + binding.mirror_url = result.mirrorId ? "$serverUrl/view/mirrors/${result.mirrorId}" : null + // container info + binding.request_id = result.requestId + binding.request_url = result.requestId ? "$serverUrl/view/containers/${result.requestId}" : null + binding.scan_time = formatTimestamp(result.startTime) ?: '-' binding.scan_duration = formatDuration(result.duration) ?: '-' if ( result.vulnerabilities ) diff --git a/src/main/groovy/io/seqera/wave/core/RegistryProxyService.groovy b/src/main/groovy/io/seqera/wave/core/RegistryProxyService.groovy index e8a8fae6a..1d9bf61de 100644 --- a/src/main/groovy/io/seqera/wave/core/RegistryProxyService.groovy +++ b/src/main/groovy/io/seqera/wave/core/RegistryProxyService.groovy @@ -203,7 +203,7 @@ class RegistryProxyService { static private List RETRY_ON_NOT_FOUND = HTTP_RETRYABLE_ERRORS + 404 - @Cacheable(value = 'cache-registry-proxy', atomic = true) + @Cacheable(value = 'cache-registry-proxy', atomic = true, parameters = ['image']) protected String getImageDigest0(String image, PlatformId identity, boolean retryOnNotFound) { final coords = ContainerCoordinates.parse(image) final route = RoutePath.v2manifestPath(coords, identity) diff --git a/src/main/groovy/io/seqera/wave/core/RouteHandler.groovy b/src/main/groovy/io/seqera/wave/core/RouteHandler.groovy index e5a055565..632af69b1 100644 --- a/src/main/groovy/io/seqera/wave/core/RouteHandler.groovy +++ b/src/main/groovy/io/seqera/wave/core/RouteHandler.groovy @@ -24,7 +24,7 @@ import java.util.regex.Pattern import groovy.transform.CompileStatic import groovy.util.logging.Slf4j import io.seqera.wave.exception.NotFoundException -import io.seqera.wave.service.token.ContainerTokenService +import io.seqera.wave.service.request.ContainerRequestService import jakarta.inject.Singleton /** * Helper service to decode container request paths @@ -38,9 +38,9 @@ class RouteHandler { final public static Pattern ROUTE_PATHS = ~'/v2(?:/wt)?/([a-z0-9][a-z0-9_.-]+(?:/[a-z0-9][a-z0-9_.-]+)?(?:/[a-zA-Z0-9][a-zA-Z0-9_.-]+)*)/(manifests|blobs|tags)/(.+)' - private ContainerTokenService tokenService + private ContainerRequestService tokenService - RouteHandler(ContainerTokenService tokenService) { + RouteHandler(ContainerRequestService tokenService) { this.tokenService = tokenService } diff --git a/src/main/groovy/io/seqera/wave/core/RoutePath.groovy b/src/main/groovy/io/seqera/wave/core/RoutePath.groovy index bfe1bffe1..caeb8b448 100644 --- a/src/main/groovy/io/seqera/wave/core/RoutePath.groovy +++ b/src/main/groovy/io/seqera/wave/core/RoutePath.groovy @@ -25,7 +25,7 @@ import groovy.transform.CompileStatic import groovy.transform.ToString import io.micronaut.core.annotation.Nullable import io.seqera.wave.model.ContainerCoordinates -import io.seqera.wave.service.ContainerRequestData +import io.seqera.wave.service.request.ContainerRequest import io.seqera.wave.tower.PlatformId import static io.seqera.wave.WaveDefault.DOCKER_IO /** @@ -68,9 +68,9 @@ class RoutePath implements ContainerPath { final String path /** - * The {@link ContainerRequestData} metadata associated with the wave request + * The {@link ContainerRequest} metadata associated with the wave request */ - final ContainerRequestData request + final ContainerRequest request /** * The unique token associated with the wave container request. it may be null when mapping @@ -119,13 +119,13 @@ class RoutePath implements ContainerPath { return token && isManifest() && isTag() } - static RoutePath v2path(String type, String registry, String image, String ref, ContainerRequestData request=null, String token=null) { + static RoutePath v2path(String type, String registry, String image, String ref, ContainerRequest request=null, String token=null) { assert type in ALLOWED_TYPES, "Unknown container path type: '$type'" new RoutePath(type, registry ?: DOCKER_IO, image, ref, "/v2/$image/$type/$ref", request, token) } static RoutePath v2manifestPath(ContainerCoordinates container, PlatformId identity=null) { - ContainerRequestData data = identity!=null ? new ContainerRequestData(identity) : null + ContainerRequest data = identity!=null ? ContainerRequest.of(identity) : null return new RoutePath('manifests', container.registry, container.image, container.reference, "/v2/${container.image}/manifests/${container.reference}", data) } @@ -144,7 +144,7 @@ class RoutePath implements ContainerPath { final image = m.group(2) final type = m.group(3) final reference = m.group(4) - final data = identity!=null ? new ContainerRequestData(identity) : null + final data = identity!=null ? ContainerRequest.of(identity) : null return v2path(type, registry, image, reference, data) } else diff --git a/src/main/groovy/io/seqera/wave/service/ContainerRequestData.groovy b/src/main/groovy/io/seqera/wave/service/ContainerRequestData.groovy deleted file mode 100644 index 2a6020947..000000000 --- a/src/main/groovy/io/seqera/wave/service/ContainerRequestData.groovy +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Wave, containers provisioning service - * Copyright (c) 2023-2024, Seqera Labs - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package io.seqera.wave.service - -import groovy.transform.Canonical -import groovy.transform.CompileStatic -import io.seqera.wave.api.ContainerConfig -import io.seqera.wave.core.ContainerPlatform -import io.seqera.wave.model.ContainerCoordinates -import io.seqera.wave.tower.PlatformId -import static io.seqera.wave.util.StringUtils.trunc -/** - * Model a container request - * - * @author Paolo Di Tommaso - */ -@Canonical -@CompileStatic -class ContainerRequestData { - - final PlatformId identity - final String containerImage - final String containerFile - final ContainerConfig containerConfig - final String condaFile - final ContainerPlatform platform - final String buildId - final Boolean buildNew - final Boolean freeze - final Boolean mirror - - boolean durable() { - return freeze || mirror - } - - PlatformId getIdentity() { - return identity - } - - ContainerCoordinates coordinates() { ContainerCoordinates.parse(containerImage) } - - @Override - String toString() { - return "ContainerRequestData[identity=${getIdentity()}; containerImage=$containerImage; containerFile=${trunc(containerFile)}; condaFile=${trunc(condaFile)}; containerConfig=${containerConfig}]" - } - -} diff --git a/src/main/groovy/io/seqera/wave/service/blob/BlobStoreImpl.groovy b/src/main/groovy/io/seqera/wave/service/blob/BlobStoreImpl.groovy index 583f6ee5b..b21ab9b14 100644 --- a/src/main/groovy/io/seqera/wave/service/blob/BlobStoreImpl.groovy +++ b/src/main/groovy/io/seqera/wave/service/blob/BlobStoreImpl.groovy @@ -48,7 +48,7 @@ class BlobStoreImpl extends AbstractStateStore implements BlobStateSt @Override protected String getPrefix() { - return 'wave-blobcache/v1:' + return 'wave-blobcache/v1' } @Override diff --git a/src/main/groovy/io/seqera/wave/service/builder/BuildCounterStore.groovy b/src/main/groovy/io/seqera/wave/service/builder/BuildCounterStore.groovy index e21e6bb25..0b5d5c609 100644 --- a/src/main/groovy/io/seqera/wave/service/builder/BuildCounterStore.groovy +++ b/src/main/groovy/io/seqera/wave/service/builder/BuildCounterStore.groovy @@ -28,6 +28,7 @@ import jakarta.inject.Singleton * * @author Paolo Di Tommaso */ +@Deprecated @Singleton @CompileStatic class BuildCounterStore extends AbstractCounterStore { diff --git a/src/main/groovy/io/seqera/wave/service/builder/BuildEntry.groovy b/src/main/groovy/io/seqera/wave/service/builder/BuildEntry.groovy index 7867b9db8..030a45cfb 100644 --- a/src/main/groovy/io/seqera/wave/service/builder/BuildEntry.groovy +++ b/src/main/groovy/io/seqera/wave/service/builder/BuildEntry.groovy @@ -38,6 +38,14 @@ class BuildEntry implements StateEntry, JobEntry, RequestIdAware { final BuildResult result + BuildRequest getRequest() { + return request + } + + BuildResult getResult() { + return result + } + @Override String getKey() { return request.targetImage @@ -45,8 +53,6 @@ class BuildEntry implements StateEntry, JobEntry, RequestIdAware { @Override String getRequestId() { - if( !request.buildId ) - throw new IllegalStateException("Missing build id") return request.buildId } @@ -66,4 +72,7 @@ class BuildEntry implements StateEntry, JobEntry, RequestIdAware { new BuildEntry(request, result) } + static BuildEntry create(BuildRequest request) { + new BuildEntry(request, BuildResult.create(request)) + } } diff --git a/src/main/groovy/io/seqera/wave/service/builder/BuildRequest.groovy b/src/main/groovy/io/seqera/wave/service/builder/BuildRequest.groovy index 9682fb536..4798b24b3 100644 --- a/src/main/groovy/io/seqera/wave/service/builder/BuildRequest.groovy +++ b/src/main/groovy/io/seqera/wave/service/builder/BuildRequest.groovy @@ -43,6 +43,8 @@ class BuildRequest { static final public String SEP = '_' + static final public String ID_PREFIX = "bd-" + /** * Unique request Id. This is computed as a consistent hash generated from * the container build assets e.g. Dockerfile. Therefore the same container build @@ -129,27 +131,29 @@ class BuildRequest { * Max allow time duration for this build */ final Duration maxDuration - - volatile String buildId - - volatile Path workDir - - BuildRequest(String containerId, - String containerFile, - String condaFile, - Path workspace, - String targetImage, - PlatformId identity, - ContainerPlatform platform, - String cacheRepository, - String ip, - String configJson, - String offsetId, - ContainerConfig containerConfig, - String scanId, - BuildContext buildContext, - BuildFormat format, - Duration maxDuration + + /** + * The build unique request id + */ + final String buildId + + BuildRequest( + String containerId, + String containerFile, + String condaFile, + Path workspace, + String targetImage, + PlatformId identity, + ContainerPlatform platform, + String cacheRepository, + String ip, + String configJson, + String offsetId, + ContainerConfig containerConfig, + String scanId, + BuildContext buildContext, + BuildFormat format, + Duration maxDuration ) { this.containerId = containerId @@ -169,6 +173,8 @@ class BuildRequest { this.buildContext = buildContext this.format = format this.maxDuration = maxDuration + // NOTE: this is meant to be updated - automatically - when the request is submitted + this.buildId = ID_PREFIX + containerId + SEP + '0' } BuildRequest(Map opts) { @@ -188,9 +194,8 @@ class BuildRequest { this.scanId = opts.scanId this.buildContext = opts.buildContext as BuildContext this.format = opts.format as BuildFormat - this.workDir = opts.workDir as Path - this.buildId = opts.buildId this.maxDuration = opts.maxDuration as Duration + this.buildId = opts.buildId } @Override @@ -216,7 +221,7 @@ class BuildRequest { } Path getWorkDir() { - return workDir + return workspace.resolve(buildId).toAbsolutePath() } String getTargetImage() { @@ -263,12 +268,6 @@ class BuildRequest { format==SINGULARITY } - BuildRequest withBuildId(String id) { - this.buildId = containerId + SEP + id - this.workDir = workspace.resolve(buildId).toAbsolutePath() - return this - } - static String legacyBuildId(String id) { if( !id ) return null diff --git a/src/main/groovy/io/seqera/wave/service/builder/BuildResult.groovy b/src/main/groovy/io/seqera/wave/service/builder/BuildResult.groovy index 03e5daf02..fbbf4f5a2 100644 --- a/src/main/groovy/io/seqera/wave/service/builder/BuildResult.groovy +++ b/src/main/groovy/io/seqera/wave/service/builder/BuildResult.groovy @@ -38,15 +38,18 @@ import groovy.transform.ToString @CompileStatic class BuildResult { - final String id + final String buildId final Integer exitStatus final String logs final Instant startTime final Duration duration final String digest - BuildResult(String id, Integer exitStatus, String logs, Instant startTime, Duration duration, String digest) { - this.id = id + // This field is deprecated. It's only used for de-serializing object objects + @Deprecated final private String id + + BuildResult(String buildId, Integer exitStatus, String logs, Instant startTime, Duration duration, String digest) { + this.buildId = buildId this.logs = logs?.replaceAll("\u001B\\[[;\\d]*m", "") // strip ansi escape codes this.exitStatus = exitStatus this.startTime = startTime @@ -57,7 +60,7 @@ class BuildResult { /* Do not remove - required by jackson de-ser */ protected BuildResult() {} - String getId() { id } + String getBuildId() { buildId ?: id } Duration getDuration() { duration } @@ -75,15 +78,15 @@ class BuildResult { @Override String toString() { - return "BuildResult[id=$id; exitStatus=$exitStatus; duration=$duration]" + return "BuildResult[buildId=$buildId; exitStatus=$exitStatus; duration=$duration]" } - static BuildResult completed(String buildId, Integer exitStatus, String content, Instant startTime, String digest) { - new BuildResult(buildId, exitStatus, content, startTime, Duration.between(startTime, Instant.now()), digest) + static BuildResult completed(String buildId, Integer exitStatus, String logs, Instant startTime, String digest) { + new BuildResult(buildId, exitStatus, logs, startTime, Duration.between(startTime, Instant.now()), digest) } - static BuildResult failed(String buildId, String content, Instant startTime) { - new BuildResult(buildId, -1, content, startTime, Duration.between(startTime, Instant.now()), null) + static BuildResult failed(String buildId, String logs, Instant startTime) { + new BuildResult(buildId, -1, logs, startTime, Duration.between(startTime, Instant.now()), null) } static BuildResult create(BuildRequest req) { diff --git a/src/main/groovy/io/seqera/wave/service/builder/BuildStateStore.groovy b/src/main/groovy/io/seqera/wave/service/builder/BuildStateStore.groovy index d36920bd6..b09811183 100644 --- a/src/main/groovy/io/seqera/wave/service/builder/BuildStateStore.groovy +++ b/src/main/groovy/io/seqera/wave/service/builder/BuildStateStore.groovy @@ -22,6 +22,8 @@ import java.time.Duration import java.util.concurrent.CompletableFuture import groovy.transform.CompileStatic +import io.seqera.wave.store.state.CountResult + /** * Define build request store operations * @@ -68,7 +70,7 @@ interface BuildStateStore { void storeBuild(String imageName, BuildEntry result, Duration ttl) /** - * Store a build result only if the specified key does not exit + * Store a {@link BuildEntry} object only if the specified key does not exit * * @param imageName The container image unique key * @param build The {@link BuildEntry} desired status to be stored @@ -76,6 +78,21 @@ interface BuildStateStore { */ boolean storeIfAbsent(String imageName, BuildEntry build) + /** + * Store a {@link BuildEntry} object only if the specified key does not exit. + * When the entry is stored the {@code buildId} fields in the request and response + * and incremented by 1. + * + * @param imageName + * The container image name used as store key. + * @param build + * A {@link BuildEntry} object to be stored + * @return + * A {@link CountResult} object holding the {@link BuildEntry} object with the updated {@code buildId} + * if the store is successful, or the currently store {@link BuildEntry} if the key already exist. + */ + CountResult putIfAbsentAndCount(String imageName, BuildEntry build) + /** * Remove the build status for the given image name * diff --git a/src/main/groovy/io/seqera/wave/service/builder/impl/BuildStateStoreImpl.groovy b/src/main/groovy/io/seqera/wave/service/builder/impl/BuildStateStoreImpl.groovy index ffc3b3e88..4b692604e 100644 --- a/src/main/groovy/io/seqera/wave/service/builder/impl/BuildStateStoreImpl.groovy +++ b/src/main/groovy/io/seqera/wave/service/builder/impl/BuildStateStoreImpl.groovy @@ -31,6 +31,8 @@ import io.seqera.wave.service.builder.BuildResult import io.seqera.wave.service.builder.BuildEntry import io.seqera.wave.service.builder.BuildStateStore import io.seqera.wave.store.state.AbstractStateStore +import io.seqera.wave.store.state.CountParams +import io.seqera.wave.store.state.CountResult import io.seqera.wave.store.state.impl.StateProvider import jakarta.inject.Named import jakarta.inject.Singleton @@ -56,7 +58,7 @@ class BuildStateStoreImpl extends AbstractStateStore implements Buil @Override protected String getPrefix() { - return 'wave-build/v2:' + return 'wave-build/v2' } @Override @@ -89,6 +91,21 @@ class BuildStateStoreImpl extends AbstractStateStore implements Buil return putIfAbsent(imageName, build, buildConfig.statusDuration) } + @Override + protected CountParams counterKey(String key, BuildEntry build) { + new CountParams( "build-counters/v1", build.request.containerId) + } + + @Override + protected String counterScript() { + /string.gsub(value, '"buildId"%s*:%s*"(.-)(%d+)"', '"buildId":"%1' .. counter_value .. '"')/ + } + + @Override + CountResult putIfAbsentAndCount(String imageName, BuildEntry build) { + super.putIfAbsentAndCount(imageName, build) + } + @Override void removeBuild(String imageName) { remove(imageName) diff --git a/src/main/groovy/io/seqera/wave/service/builder/impl/ContainerBuildServiceImpl.groovy b/src/main/groovy/io/seqera/wave/service/builder/impl/ContainerBuildServiceImpl.groovy index acc2b0181..a08c8a764 100644 --- a/src/main/groovy/io/seqera/wave/service/builder/impl/ContainerBuildServiceImpl.groovy +++ b/src/main/groovy/io/seqera/wave/service/builder/impl/ContainerBuildServiceImpl.groovy @@ -40,11 +40,10 @@ import io.seqera.wave.core.RegistryProxyService import io.seqera.wave.exception.HttpServerRetryableErrorException import io.seqera.wave.ratelimit.AcquireRequest import io.seqera.wave.ratelimit.RateLimiterService -import io.seqera.wave.service.builder.BuildCounterStore +import io.seqera.wave.service.builder.BuildEntry import io.seqera.wave.service.builder.BuildEvent import io.seqera.wave.service.builder.BuildRequest import io.seqera.wave.service.builder.BuildResult -import io.seqera.wave.service.builder.BuildEntry import io.seqera.wave.service.builder.BuildStateStore import io.seqera.wave.service.builder.BuildTrack import io.seqera.wave.service.builder.ContainerBuildService @@ -55,6 +54,7 @@ import io.seqera.wave.service.job.JobState import io.seqera.wave.service.metric.MetricsService import io.seqera.wave.service.persistence.PersistenceService import io.seqera.wave.service.persistence.WaveBuildRecord +import io.seqera.wave.service.scan.ContainerScanService import io.seqera.wave.service.stream.StreamService import io.seqera.wave.tower.PlatformId import io.seqera.wave.util.Retryable @@ -110,9 +110,6 @@ class ContainerBuildServiceImpl implements ContainerBuildService, JobHandlermax ) { dispatcher.notifyJobTimeout(jobSpec) + jobService.cleanup(jobSpec, null) return true } else { diff --git a/src/main/groovy/io/seqera/wave/service/job/JobQueue.groovy b/src/main/groovy/io/seqera/wave/service/job/JobQueue.groovy index c37d47881..acfd0a59c 100644 --- a/src/main/groovy/io/seqera/wave/service/job/JobQueue.groovy +++ b/src/main/groovy/io/seqera/wave/service/job/JobQueue.groovy @@ -37,7 +37,7 @@ import jakarta.inject.Singleton @CompileStatic class JobQueue extends AbstractMessageStream { - final private static String STREAM_NAME = 'jobs-queue/v1:' + final private static String STREAM_NAME = 'jobs-queue/v1' private volatile JobConfig config diff --git a/src/main/groovy/io/seqera/wave/service/job/impl/DockerJobOperation.groovy b/src/main/groovy/io/seqera/wave/service/job/impl/DockerJobOperation.groovy index 993d173f2..40035397d 100644 --- a/src/main/groovy/io/seqera/wave/service/job/impl/DockerJobOperation.groovy +++ b/src/main/groovy/io/seqera/wave/service/job/impl/DockerJobOperation.groovy @@ -42,7 +42,7 @@ class DockerJobOperation implements JobOperation { @Override JobState status(JobSpec jobSpec) { final state = getDockerContainerState(jobSpec.operationName) - log.trace "Docker container status name=$jobSpec.operationName; state=$state" + log.trace "Docker container status name=${jobSpec.operationName}; state=${state}" if (state.status == 'running') { return JobState.running() @@ -55,6 +55,7 @@ class DockerJobOperation implements JobOperation { return JobState.pending() } else { + log.warn "Unexpected state for container state=${state}" final logs = getDockerContainerLogs(jobSpec.operationName) return JobState.unknown(logs) } diff --git a/src/main/groovy/io/seqera/wave/service/logs/BuildLogService.groovy b/src/main/groovy/io/seqera/wave/service/logs/BuildLogService.groovy index deb042952..1e0f1bdd8 100644 --- a/src/main/groovy/io/seqera/wave/service/logs/BuildLogService.groovy +++ b/src/main/groovy/io/seqera/wave/service/logs/BuildLogService.groovy @@ -39,4 +39,8 @@ interface BuildLogService { StreamedFile fetchLogStream(String buildId) BuildLog fetchLogString(String buildId) + + String fetchCondaLockString(String buildId) + + StreamedFile fetchCondaLockStream(String buildId) } diff --git a/src/main/groovy/io/seqera/wave/service/logs/BuildLogServiceImpl.groovy b/src/main/groovy/io/seqera/wave/service/logs/BuildLogServiceImpl.groovy index 896330267..c7abfd3ce 100644 --- a/src/main/groovy/io/seqera/wave/service/logs/BuildLogServiceImpl.groovy +++ b/src/main/groovy/io/seqera/wave/service/logs/BuildLogServiceImpl.groovy @@ -41,6 +41,7 @@ import jakarta.inject.Inject import jakarta.inject.Named import jakarta.inject.Singleton import org.apache.commons.io.input.BoundedInputStream +import static org.apache.commons.lang3.StringUtils.strip /** * Implements Service to manage logs from an Object store * @@ -52,6 +53,10 @@ import org.apache.commons.io.input.BoundedInputStream @Requires(property = 'wave.build.logs.bucket') class BuildLogServiceImpl implements BuildLogService { + private static final String CONDA_LOCK_START = ">> CONDA_LOCK_START" + + private static final String CONDA_LOCK_END = "<< CONDA_LOCK_END" + @Inject @Named('build-logs') private ObjectStorageOperations objectStorageOperations @@ -69,13 +74,17 @@ class BuildLogServiceImpl implements BuildLogService { @Value('${wave.build.logs.maxLength:100000}') private long maxLength + @Nullable + @Value('${wave.build.logs.conda-lock-prefix}') + private String condaLockPrefix + @Inject @Named(TaskExecutors.IO) private volatile ExecutorService ioExecutor @PostConstruct private void init() { - log.info "Creating Build log service bucket=$bucket; prefix=$prefix; maxLength: ${maxLength}" + log.info "Creating Build log service bucket=$bucket; logs prefix=$prefix; maxLength: ${maxLength}; condaLock prefix=$condaLockPrefix" } protected String logKey(String buildId) { @@ -83,23 +92,27 @@ class BuildLogServiceImpl implements BuildLogService { return null if( !prefix ) return buildId + '.log' - final base = org.apache.commons.lang3.StringUtils.strip(prefix, '/') + final base = strip(prefix, '/') return "${base}/${buildId}.log" } @EventListener void onBuildEvent(BuildEvent event) { if(event.result.logs) { - CompletableFuture.supplyAsync(() -> storeLog(event.result.id, event.result.logs), ioExecutor) + CompletableFuture.supplyAsync(() -> storeLog(event.result.buildId, event.result.logs), ioExecutor) } } @Override void storeLog(String buildId, String content){ + try { + final String logs = removeCondaLockFile(content) log.debug "Storing logs for buildId: $buildId" - final uploadRequest = UploadRequest.fromBytes(content.getBytes(), logKey(buildId)) + final uploadRequest = UploadRequest.fromBytes(logs.bytes, logKey(buildId)) objectStorageOperations.upload(uploadRequest) + + storeCondaLock(buildId, content) } catch (Exception e) { log.warn "Unable to store logs for buildId: $buildId - reason: ${e.message}", e @@ -125,4 +138,67 @@ class BuildLogServiceImpl implements BuildLogService { final logs = new BoundedInputStream(result.getInputStream(), maxLength).getText() return new BuildLog(logs, logs.length()>=maxLength) } + + protected static removeCondaLockFile(String logs) { + if(logs.indexOf(CONDA_LOCK_START) < 0 ) { + return logs + } + return logs.replaceAll(/(?s)\n?#\d+ \d+\.\d+ $CONDA_LOCK_START.*?$CONDA_LOCK_END\n?/, '\n') + } + + protected void storeCondaLock(String buildId, String logs) { + if( !logs ) return + try { + String condaLock = extractCondaLockFile(logs) + if (condaLock){ + log.debug "Storing conda lock for buildId: $buildId" + final uploadRequest = UploadRequest.fromBytes(condaLock.bytes, condaLockKey(buildId)) + objectStorageOperations.upload(uploadRequest) + } + } + catch (Exception e) { + log.warn "Unable to store condalock for buildId: $buildId - reason: ${e.message}", e + } + } + + protected String condaLockKey(String buildId) { + if( !buildId ) + return null + if( !condaLockPrefix ) + return buildId + '.lock' + final base = strip(condaLockPrefix, '/') + return "${base}/${buildId}.lock" + } + + @Override + String fetchCondaLockString(String buildId) { + final result = fetchCondaLockStream(buildId) + if( !result ) + return null + return result.getInputStream().getText() + + } + + @Override + StreamedFile fetchCondaLockStream(String buildId) { + if( !buildId ) return null + final Optional> result = objectStorageOperations.retrieve(condaLockKey(buildId)) + return result.isPresent() ? result.get().toStreamedFile() : null + } + + protected static extractCondaLockFile(String logs) { + try { + int start = logs.lastIndexOf(CONDA_LOCK_START) + int end = logs.lastIndexOf(CONDA_LOCK_END) + if( start > end ) { // when build fails, there will be commands in the logs, so to avoid extracting wrong content + return null + } + return logs.substring(start + CONDA_LOCK_START.length(), end) + .replaceAll(/#\d+ \d+\.\d+\s*/, '') + } catch (Exception e) { + log.warn "Unable to extract conda lock file from logs - reason: ${e.message}", e + return null + } + } + } diff --git a/src/main/groovy/io/seqera/wave/service/mail/impl/MailServiceImpl.groovy b/src/main/groovy/io/seqera/wave/service/mail/impl/MailServiceImpl.groovy index 4eea0de03..537d83781 100644 --- a/src/main/groovy/io/seqera/wave/service/mail/impl/MailServiceImpl.groovy +++ b/src/main/groovy/io/seqera/wave/service/mail/impl/MailServiceImpl.groovy @@ -78,7 +78,7 @@ class MailServiceImpl implements MailService { spooler.sendMail(mail) } else { - log.debug "Missing email recipient from build id=$build.id - user=$user" + log.debug "Missing email recipient from build id=$build.buildId - user=$user" } } @@ -86,7 +86,7 @@ class MailServiceImpl implements MailService { // create template binding final binding = new HashMap(20) final status = result.succeeded() ? 'DONE': 'FAILED' - binding.build_id = result.id + binding.build_id = result.buildId binding.build_user = "${req.identity?.user ? req.identity.user.userName : '-'} (${req.ip})" binding.build_success = result.succeeded() binding.build_exit_status = result.exitStatus @@ -98,8 +98,7 @@ class MailServiceImpl implements MailService { binding.build_containerfile = req.containerFile ?: '-' binding.build_condafile = req.condaFile binding.build_digest = result.digest ?: '-' - binding.put('build_log_data', result.logs) - binding.build_url = "$serverUrl/view/builds/${result.id}" + binding.build_url = "$serverUrl/view/builds/${result.buildId}" binding.scan_url = req.scanId && result.succeeded() ? "$serverUrl/view/scans/${req.scanId}" : null binding.scan_id = req.scanId binding.put('server_url', serverUrl) diff --git a/src/main/groovy/io/seqera/wave/service/mirror/ContainerMirrorService.groovy b/src/main/groovy/io/seqera/wave/service/mirror/ContainerMirrorService.groovy index 93cc71bc0..175f2252f 100644 --- a/src/main/groovy/io/seqera/wave/service/mirror/ContainerMirrorService.groovy +++ b/src/main/groovy/io/seqera/wave/service/mirror/ContainerMirrorService.groovy @@ -21,7 +21,6 @@ package io.seqera.wave.service.mirror import java.util.concurrent.CompletableFuture import io.seqera.wave.service.builder.BuildTrack - /** * Define the contract for container images mirroring service * @@ -58,6 +57,6 @@ interface ContainerMirrorService { * The {@link MirrorEntry} object modelling the current state of the mirror operation, * or {@link null} otherwise */ - MirrorEntry getMirrorEntry(String id) + MirrorResult getMirrorResult(String id) } diff --git a/src/main/groovy/io/seqera/wave/service/mirror/ContainerMirrorServiceImpl.groovy b/src/main/groovy/io/seqera/wave/service/mirror/ContainerMirrorServiceImpl.groovy index 19944a71c..aadd9ff0d 100644 --- a/src/main/groovy/io/seqera/wave/service/mirror/ContainerMirrorServiceImpl.groovy +++ b/src/main/groovy/io/seqera/wave/service/mirror/ContainerMirrorServiceImpl.groovy @@ -23,6 +23,7 @@ import java.util.concurrent.ExecutorService import groovy.transform.CompileStatic import groovy.util.logging.Slf4j +import io.micronaut.core.annotation.Nullable import io.micronaut.scheduling.TaskExecutors import io.seqera.wave.service.builder.BuildTrack import io.seqera.wave.service.job.JobHandler @@ -30,10 +31,10 @@ import io.seqera.wave.service.job.JobService import io.seqera.wave.service.job.JobSpec import io.seqera.wave.service.job.JobState import io.seqera.wave.service.persistence.PersistenceService +import io.seqera.wave.service.scan.ContainerScanService import jakarta.inject.Inject import jakarta.inject.Named import jakarta.inject.Singleton - /** * Implement a service to mirror a container image to a repository specified by the user * @@ -58,12 +59,16 @@ class ContainerMirrorServiceImpl implements ContainerMirrorService, JobHandler */ @ToString(includeNames = true, includePackage = false) -@Singleton +@EqualsAndHashCode @CompileStatic -@Canonical class MirrorEntry implements StateEntry, JobEntry, RequestIdAware { - enum Status { PENDING, COMPLETED } - final String mirrorId - final String digest - final String sourceImage - final String targetImage - final ContainerPlatform platform - final Instant creationTime - final Status status - final Duration duration - final Integer exitCode - final String logs + final MirrorRequest request + + final MirrorResult result + + protected MirrorEntry() {} + + MirrorEntry(MirrorRequest request, MirrorResult result) { + this.request = request + this.result = result + } @Override String getKey() { - return targetImage + return request.targetImage } @Override String getRequestId() { - return mirrorId + return request.mirrorId } @Override boolean done() { - status==Status.COMPLETED + result?.status==MirrorResult.Status.COMPLETED } - boolean succeeded() { - status==Status.COMPLETED && exitCode==0 + /** + * Create a {@link MirrorEntry} object with the current {@link MirrorRequest} and + * the specified {@link MirrorResult} object + * + * @param result The {@link MirrorResult} object to be use as result + * @return The new {@link MirrorEntry} instance + */ + MirrorEntry withResult(MirrorResult result) { + new MirrorEntry(this.request, result) } - MirrorEntry complete(Integer exitCode, String logs ) { - new MirrorEntry( - this.mirrorId, - this.digest, - this.sourceImage, - this.targetImage, - this.platform, - this.creationTime, - Status.COMPLETED, - Duration.between(this.creationTime, Instant.now()), - exitCode, - logs - ) + /** + * Create a {@link MirrorEntry} object with the given {@link MirrorRequest} object + */ + static MirrorEntry of(MirrorRequest request) { + new MirrorEntry(request, MirrorResult.of(request)) } - static MirrorEntry from(MirrorRequest request) { - new MirrorEntry( - request.mirrorId, - request.digest, - request.sourceImage, - request.targetImage, - request.platform, - request.creationTime, - Status.PENDING - ) - } - - BuildStatusResponse toStatusResponse() { - final status = status == Status.COMPLETED - ? BuildStatusResponse.Status.COMPLETED - : BuildStatusResponse.Status.PENDING - final succeeded = exitCode!=null - ? exitCode==0 - : null - return new BuildStatusResponse( - mirrorId, - status, - creationTime, - duration, - succeeded ) - } } diff --git a/src/main/groovy/io/seqera/wave/service/mirror/MirrorRequest.groovy b/src/main/groovy/io/seqera/wave/service/mirror/MirrorRequest.groovy index 3abe05c44..bb541212c 100644 --- a/src/main/groovy/io/seqera/wave/service/mirror/MirrorRequest.groovy +++ b/src/main/groovy/io/seqera/wave/service/mirror/MirrorRequest.groovy @@ -25,8 +25,8 @@ import groovy.transform.Canonical import groovy.transform.CompileStatic import groovy.transform.ToString import io.seqera.wave.core.ContainerPlatform +import io.seqera.wave.tower.PlatformId import io.seqera.wave.util.LongRndKey - /** * Model a container mirror request * @@ -74,26 +74,89 @@ class MirrorRequest { */ final String authJson + /** + * The scanId associated this mirror request + */ + final String scanId + /** * The timestamp when the request has been submitted */ final Instant creationTime - static MirrorRequest create(String sourceImage, String targetImage, String digest, ContainerPlatform platform, Path workspace, String authJson, Instant ts=Instant.now()) { + /** + * Request timezone offset + */ + final String offsetId + + /** + * Platform identity of the user that created this request + */ + final PlatformId identity + + static MirrorRequest create(String sourceImage, String targetImage, String digest, ContainerPlatform platform, Path workspace, String authJson, String scanId, Instant ts, String offsetId, PlatformId identity) { assert sourceImage, "Argument 'sourceImage' cannot be null" assert targetImage, "Argument 'targetImage' cannot be empty" assert workspace, "Argument 'workspace' cannot be null" assert digest, "Argument 'digest' cannot be empty" - final id = LongRndKey.rndHex() + final mirrorId = ID_PREFIX + LongRndKey.rndHex() return new MirrorRequest( - ID_PREFIX + id, + mirrorId, sourceImage, targetImage, digest, platform, - workspace.resolve("mirror-${id}"), + workspace.resolve(mirrorId), authJson, - ts ) + scanId, + ts, + offsetId, + identity + ) + } + + String getMirrorId() { + return mirrorId + } + + String getSourceImage() { + return sourceImage + } + + String getTargetImage() { + return targetImage + } + + String getDigest() { + return digest + } + + ContainerPlatform getPlatform() { + return platform + } + + Path getWorkDir() { + return workDir + } + + String getAuthJson() { + return authJson + } + + String getScanId() { + return scanId + } + + Instant getCreationTime() { + return creationTime + } + + String getOffsetId() { + return offsetId + } + + PlatformId getIdentity() { + return identity } } diff --git a/src/main/groovy/io/seqera/wave/service/mirror/MirrorResult.groovy b/src/main/groovy/io/seqera/wave/service/mirror/MirrorResult.groovy new file mode 100644 index 000000000..4902ce581 --- /dev/null +++ b/src/main/groovy/io/seqera/wave/service/mirror/MirrorResult.groovy @@ -0,0 +1,158 @@ +/* + * Wave, containers provisioning service + * Copyright (c) 2023-2024, Seqera Labs + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package io.seqera.wave.service.mirror + +import java.time.Duration +import java.time.Instant + +import groovy.transform.Canonical +import groovy.transform.CompileStatic +import groovy.transform.ToString +import io.seqera.wave.core.ContainerPlatform +import jakarta.inject.Singleton +/** + * Model a container mirror entry object + * + * @author Paolo Di Tommaso + */ +@ToString(includeNames = true, includePackage = false) +@Singleton +@CompileStatic +@Canonical +class MirrorResult { + + enum Status { PENDING, COMPLETED } + + final String mirrorId + final String digest + final String sourceImage + final String targetImage + final ContainerPlatform platform + final Instant creationTime + final String offsetId + final String userName + final String userEmail + final Long userId + final String scanId + final Status status + final Duration duration + final Integer exitCode + final String logs + + boolean succeeded() { + status==Status.COMPLETED && exitCode==0 + } + + MirrorResult complete(Integer exitCode, String logs) { + new MirrorResult( + this.mirrorId, + this.digest, + this.sourceImage, + this.targetImage, + this.platform, + this.creationTime, + this.offsetId, + this.userName, + this.userEmail, + this.userId, + this.scanId, + Status.COMPLETED, + Duration.between(this.creationTime, Instant.now()), + exitCode, + logs + ) + } + + static MirrorResult of(MirrorRequest request) { + new MirrorResult( + request.mirrorId, + request.digest, + request.sourceImage, + request.targetImage, + request.platform, + request.creationTime, + request.offsetId, + request.identity?.user?.userName, + request.identity?.user?.email, + request.identity?.user?.id, + request.scanId, + Status.PENDING + ) + } + + String getMirrorId() { + return mirrorId + } + + String getDigest() { + return digest + } + + String getSourceImage() { + return sourceImage + } + + String getTargetImage() { + return targetImage + } + + ContainerPlatform getPlatform() { + return platform + } + + Instant getCreationTime() { + return creationTime + } + + String getOffsetId() { + return offsetId + } + + String getUserName() { + return userName + } + + String getUserEmail() { + return userEmail + } + + Long getUserId() { + return userId + } + + String getScanId() { + return scanId + } + + Status getStatus() { + return status + } + + Duration getDuration() { + return duration + } + + Integer getExitCode() { + return exitCode + } + + String getLogs() { + return logs + } +} diff --git a/src/main/groovy/io/seqera/wave/service/mirror/MirrorStateStore.groovy b/src/main/groovy/io/seqera/wave/service/mirror/MirrorStateStore.groovy index f518e3e27..31e61ba06 100644 --- a/src/main/groovy/io/seqera/wave/service/mirror/MirrorStateStore.groovy +++ b/src/main/groovy/io/seqera/wave/service/mirror/MirrorStateStore.groovy @@ -46,7 +46,7 @@ class MirrorStateStore extends AbstractStateStore { @Override protected String getPrefix() { - return 'wave-mirror/v1:' + return 'wave-mirror/v1' } @Override diff --git a/src/main/groovy/io/seqera/wave/service/pairing/PairingStore.groovy b/src/main/groovy/io/seqera/wave/service/pairing/PairingStore.groovy index 5132546f9..444c7b3b2 100644 --- a/src/main/groovy/io/seqera/wave/service/pairing/PairingStore.groovy +++ b/src/main/groovy/io/seqera/wave/service/pairing/PairingStore.groovy @@ -61,7 +61,7 @@ class PairingStore extends AbstractStateStore { @Override protected String getPrefix() { - return 'pairing-keys/v1:' + return 'pairing-keys/v1' } /** diff --git a/src/main/groovy/io/seqera/wave/service/persistence/PersistenceService.groovy b/src/main/groovy/io/seqera/wave/service/persistence/PersistenceService.groovy index 23dd2fcf9..c36a4c19f 100644 --- a/src/main/groovy/io/seqera/wave/service/persistence/PersistenceService.groovy +++ b/src/main/groovy/io/seqera/wave/service/persistence/PersistenceService.groovy @@ -21,6 +21,8 @@ package io.seqera.wave.service.persistence import groovy.transform.CompileStatic import io.seqera.wave.core.ContainerDigestPair import io.seqera.wave.service.mirror.MirrorEntry +import io.seqera.wave.service.mirror.MirrorResult + /** * A storage for statistic data * @@ -88,19 +90,12 @@ interface PersistenceService { */ WaveContainerRecord loadContainerRequest(String token) - /** - * Create a scan record, this signal that a container scan request has been created - * - * @param scanRecord Create a record with the object specified - */ - void createScanRecord(WaveScanRecord scanRecord) - /** * Store a {@link WaveScanRecord} object in the Surreal wave_scan table. * * @param data A {@link WaveScanRecord} object representing the a container scan request */ - void updateScanRecord(WaveScanRecord scanRecord) + void saveScanRecord(WaveScanRecord scanRecord) /** * Retrieve a {@link WaveScanRecord} object for the specified build ID @@ -116,7 +111,7 @@ interface PersistenceService { * @param mirrorId The ID of the mirror record * @return The corresponding {@link MirrorEntry} object or null if it cannot be found */ - MirrorEntry loadMirrorEntry(String mirrorId) + MirrorResult loadMirrorResult(String mirrorId) /** * Load a mirror state record given the target image name and the image digest @@ -125,13 +120,13 @@ interface PersistenceService { * @param digest The image content SHA256 digest * @return The corresponding {@link MirrorEntry} object or null if it cannot be found */ - MirrorEntry loadMirrorEntry(String targetImage, String digest) + MirrorResult loadMirrorResult(String targetImage, String digest) /** * Persists a {@link MirrorEntry} state record * * @param mirror {@link MirrorEntry} object */ - void saveMirrorEntry(MirrorEntry mirror) + void saveMirrorResult(MirrorResult mirror) } diff --git a/src/main/groovy/io/seqera/wave/service/persistence/WaveBuildRecord.groovy b/src/main/groovy/io/seqera/wave/service/persistence/WaveBuildRecord.groovy index b4278af2b..299a6f2bd 100644 --- a/src/main/groovy/io/seqera/wave/service/persistence/WaveBuildRecord.groovy +++ b/src/main/groovy/io/seqera/wave/service/persistence/WaveBuildRecord.groovy @@ -24,12 +24,12 @@ import java.time.Instant import groovy.transform.CompileStatic import groovy.transform.EqualsAndHashCode import groovy.transform.ToString +import io.seqera.wave.api.BuildStatusResponse import io.seqera.wave.service.builder.BuildEntry import io.seqera.wave.service.builder.BuildEvent import io.seqera.wave.service.builder.BuildFormat import io.seqera.wave.service.builder.BuildRequest import io.seqera.wave.service.builder.BuildResult -import io.seqera.wave.api.BuildStatusResponse /** * A collection of request and response properties to be stored * @@ -71,7 +71,7 @@ class WaveBuildRecord { } static private WaveBuildRecord create0(BuildRequest request, BuildResult result) { - if( result && request.buildId != result.id ) + if( result && request.buildId != result.buildId ) throw new IllegalStateException("Build id must match the result id") return new WaveBuildRecord( buildId: request.buildId, diff --git a/src/main/groovy/io/seqera/wave/service/persistence/WaveContainerRecord.groovy b/src/main/groovy/io/seqera/wave/service/persistence/WaveContainerRecord.groovy index 67a402ac7..a367c585c 100644 --- a/src/main/groovy/io/seqera/wave/service/persistence/WaveContainerRecord.groovy +++ b/src/main/groovy/io/seqera/wave/service/persistence/WaveContainerRecord.groovy @@ -27,7 +27,7 @@ import groovy.transform.ToString import groovy.util.logging.Slf4j import io.seqera.wave.api.ContainerConfig import io.seqera.wave.api.SubmitContainerTokenRequest -import io.seqera.wave.service.ContainerRequestData +import io.seqera.wave.service.request.ContainerRequest import io.seqera.wave.tower.User import static io.seqera.wave.util.DataTimeUtils.parseOffsetDateTime /** @@ -163,8 +163,18 @@ class WaveContainerRecord { */ final String fusionVersion - WaveContainerRecord(SubmitContainerTokenRequest request, ContainerRequestData data, String token, String waveImage, String addr, Instant expiration) { - this.id = token + /** + * Whenever it's a "mirror" build request + */ + final Boolean mirror + + /** + * The scan id associated with this request + */ + final String scanId + + WaveContainerRecord(SubmitContainerTokenRequest request, ContainerRequest data, String waveImage, String addr, Instant expiration) { + this.id = data.requestId this.user = data.identity.user this.workspaceId = request.towerWorkspaceId this.containerImage = request.containerImage @@ -187,6 +197,8 @@ class WaveContainerRecord { this.buildNew = data.buildId ? data.buildNew : null this.freeze = data.buildId ? data.freeze : null this.fusionVersion = request?.containerConfig?.fusionVersion()?.number + this.mirror = data.mirror + this.scanId = data.scanId } WaveContainerRecord(WaveContainerRecord that, String sourceDigest, String waveDigest) { @@ -211,6 +223,8 @@ class WaveContainerRecord { this.buildNew = that.buildNew this.freeze = that.freeze this.fusionVersion = that.fusionVersion + this.mirror == that.mirror + this.scanId = that.scanId // -- digest part this.sourceDigest = sourceDigest this.waveDigest = waveDigest diff --git a/src/main/groovy/io/seqera/wave/service/persistence/WaveScanRecord.groovy b/src/main/groovy/io/seqera/wave/service/persistence/WaveScanRecord.groovy index 65b5fa2c4..ea9d81f97 100644 --- a/src/main/groovy/io/seqera/wave/service/persistence/WaveScanRecord.groovy +++ b/src/main/groovy/io/seqera/wave/service/persistence/WaveScanRecord.groovy @@ -37,29 +37,41 @@ import io.seqera.wave.util.StringUtils @ToString(includeNames = true, includePackage = false) @EqualsAndHashCode @CompileStatic -class WaveScanRecord { +class WaveScanRecord implements Cloneable { String id String buildId + String mirrorId + String requestId String containerImage Instant startTime Duration duration String status List vulnerabilities + Integer exitCode + String logs /* required by jackson deserialization - do not remove */ WaveScanRecord() {} - WaveScanRecord(String id, String buildId, String containerImage, Instant startTime) { - this.id = StringUtils.surrealId(id) - this.buildId = buildId - this.containerImage = containerImage - this.startTime = startTime - } - - WaveScanRecord(String id, String buildId, String containerImage, Instant startTime, Duration duration, String status, List vulnerabilities) { + WaveScanRecord( + String id, + String buildId, + String mirrorId, + String requestId, + String containerImage, + Instant startTime, + Duration duration, + String status, + List vulnerabilities, + Integer exitCode, + String logs + ) + { this.id = StringUtils.surrealId(id) this.buildId = buildId + this.mirrorId = mirrorId + this.requestId = requestId this.containerImage = containerImage this.startTime = startTime this.duration = duration @@ -67,21 +79,52 @@ class WaveScanRecord { this.vulnerabilities = vulnerabilities ? new ArrayList(vulnerabilities) : List.of() + this.exitCode = exitCode + this.logs = sanitize0(logs) } - WaveScanRecord(String id, ScanEntry scanResult) { - this.id = StringUtils.surrealId(id) - this.buildId = scanResult.buildId - this.containerImage = scanResult.containerImage - this.startTime = scanResult.startTime - this.duration = scanResult.duration - this.status = scanResult.status - this.vulnerabilities = scanResult.vulnerabilities - ? new ArrayList(scanResult.vulnerabilities) + WaveScanRecord(ScanEntry scan) { + this.id = StringUtils.surrealId(scan.scanId) + this.buildId = scan.buildId + this.mirrorId = scan.mirrorId + this.requestId = scan.requestId + this.containerImage = scan.containerImage + this.startTime = scan.startTime + this.duration = scan.duration + this.status = scan.status + this.vulnerabilities = scan.vulnerabilities + ? new ArrayList(scan.vulnerabilities) : List.of() + this.exitCode = scan.exitCode + this.logs = sanitize0(scan.logs) + } + + private static String sanitize0(String str) { + if( !str ) + return null + // remove quotes that break sql statement + str = str.replaceAll(/'/,'') + if( str.size()>10_000 ) + str = str.substring(0,10_000) + ' [truncated]' + return str } void setId(String id) { this.id = StringUtils.surrealId(id) } + + Boolean succeeded() { + return duration != null + ? status == ScanEntry.SUCCEEDED + : null + } + + Boolean done() { + return duration != null + } + + @Override + WaveScanRecord clone() { + return (WaveScanRecord) super.clone() + } } diff --git a/src/main/groovy/io/seqera/wave/service/persistence/impl/LocalPersistenceService.groovy b/src/main/groovy/io/seqera/wave/service/persistence/impl/LocalPersistenceService.groovy index 109018f6c..d1c038d18 100644 --- a/src/main/groovy/io/seqera/wave/service/persistence/impl/LocalPersistenceService.groovy +++ b/src/main/groovy/io/seqera/wave/service/persistence/impl/LocalPersistenceService.groovy @@ -20,7 +20,7 @@ package io.seqera.wave.service.persistence.impl import groovy.transform.CompileStatic import io.seqera.wave.core.ContainerDigestPair -import io.seqera.wave.service.mirror.MirrorEntry +import io.seqera.wave.service.mirror.MirrorResult import io.seqera.wave.service.persistence.PersistenceService import io.seqera.wave.service.persistence.WaveBuildRecord import io.seqera.wave.service.persistence.WaveContainerRecord @@ -39,7 +39,7 @@ class LocalPersistenceService implements PersistenceService { private Map requestStore = new HashMap<>() private Map scanStore = new HashMap<>() - private Map mirrorStore = new HashMap<>() + private Map mirrorStore = new HashMap<>() @Override void saveBuild(WaveBuildRecord record) { @@ -83,13 +83,9 @@ class LocalPersistenceService implements PersistenceService { requestStore.get(token) } - @Override - void createScanRecord(WaveScanRecord scanRecord) { - scanStore.put(scanRecord.id, scanRecord) - } @Override - void updateScanRecord(WaveScanRecord scanRecord) { + void saveScanRecord(WaveScanRecord scanRecord) { scanStore.put(scanRecord.id, scanRecord) } @@ -98,15 +94,15 @@ class LocalPersistenceService implements PersistenceService { scanStore.get(scanId) } - MirrorEntry loadMirrorEntry(String mirrorId) { + MirrorResult loadMirrorResult(String mirrorId) { mirrorStore.get(mirrorId) } - MirrorEntry loadMirrorEntry(String targetImage, String digest) { - mirrorStore.values().find( (MirrorEntry mirror) -> mirror.targetImage==targetImage && mirror.digest==digest ) + MirrorResult loadMirrorResult(String targetImage, String digest) { + mirrorStore.values().find( (MirrorResult mirror) -> mirror.targetImage==targetImage && mirror.digest==digest ) } - void saveMirrorEntry(MirrorEntry mirror) { + void saveMirrorResult(MirrorResult mirror) { mirrorStore.put(mirror.mirrorId, mirror) } diff --git a/src/main/groovy/io/seqera/wave/service/persistence/impl/SurrealClient.groovy b/src/main/groovy/io/seqera/wave/service/persistence/impl/SurrealClient.groovy index 5e6ea24c4..badd7c4aa 100644 --- a/src/main/groovy/io/seqera/wave/service/persistence/impl/SurrealClient.groovy +++ b/src/main/groovy/io/seqera/wave/service/persistence/impl/SurrealClient.groovy @@ -26,7 +26,7 @@ import io.micronaut.http.annotation.Header import io.micronaut.http.annotation.Post import io.micronaut.http.client.annotation.Client import io.micronaut.retry.annotation.Retryable -import io.seqera.wave.service.mirror.MirrorEntry +import io.seqera.wave.service.mirror.MirrorResult import io.seqera.wave.service.persistence.WaveBuildRecord import io.seqera.wave.service.persistence.WaveScanRecord import io.seqera.wave.service.scan.ScanVulnerability @@ -79,5 +79,5 @@ interface SurrealClient { Map insertScanVulnerability(@Header String authorization, @Body ScanVulnerability scanVulnerability) @Post('/key/wave_mirror') - Flux> insertMirrorAsync(@Header String authorization, @Body MirrorEntry body) + Flux> insertMirrorAsync(@Header String authorization, @Body MirrorResult body) } diff --git a/src/main/groovy/io/seqera/wave/service/persistence/impl/SurrealPersistenceService.groovy b/src/main/groovy/io/seqera/wave/service/persistence/impl/SurrealPersistenceService.groovy index 7c7167387..3ecfa5b51 100644 --- a/src/main/groovy/io/seqera/wave/service/persistence/impl/SurrealPersistenceService.groovy +++ b/src/main/groovy/io/seqera/wave/service/persistence/impl/SurrealPersistenceService.groovy @@ -31,6 +31,7 @@ import io.micronaut.runtime.event.annotation.EventListener import io.seqera.wave.core.ContainerDigestPair import io.seqera.wave.service.builder.BuildRequest import io.seqera.wave.service.mirror.MirrorEntry +import io.seqera.wave.service.mirror.MirrorResult import io.seqera.wave.service.persistence.PersistenceService import io.seqera.wave.service.persistence.WaveBuildRecord import io.seqera.wave.service.persistence.WaveContainerRecord @@ -162,7 +163,7 @@ class SurrealPersistenceService implements PersistenceService { final query = """ select * from wave_build - where buildId ~ '${containerId}${BuildRequest.SEP}' + where buildId ~ '${containerId}${BuildRequest.SEP}' order by startTime desc limit 1 """.stripIndent() final json = surrealDb.sqlAsString(getAuthorization(), query) @@ -224,16 +225,11 @@ class SurrealPersistenceService implements PersistenceService { } static protected String patchSurrealId(String json, String table) { - json.replaceFirst(/"id":\s*"${table}:(\w*)"/) { List it-> /"id":"${it[1]}"/ } - } - - void createScanRecord(WaveScanRecord scanRecord) { - final result = surrealDb.insertScanRecord(authorization, scanRecord) - log.trace "Scan create result=$result" + return json.replaceFirst(/"id":\s*"${table}:(\w*)"/) { List it-> /"id":"${it[1]}"/ } } @Override - void updateScanRecord(WaveScanRecord scanRecord) { + void saveScanRecord(WaveScanRecord scanRecord) { final vulnerabilities = scanRecord.vulnerabilities ?: List.of() // save all vulnerabilities @@ -243,21 +239,25 @@ class SurrealPersistenceService implements PersistenceService { // compose the list of ids final ids = vulnerabilities - .collect(it-> "wave_scan_vuln:⟨$it.id⟩") - .join(', ') + .collect(it-> "wave_scan_vuln:⟨$it.id⟩".toString()) + + + // scan object + final copy = scanRecord.clone() + copy.vulnerabilities = List.of() + final json = JacksonHelper.toJson(copy) // create the scan record - final statement = """\ - UPDATE wave_scan:${scanRecord.id} - SET - status = '${scanRecord.status}', - duration = '${scanRecord.duration}', - vulnerabilities = ${ids ? "[$ids]" : "[]" } - """.stripIndent() + final statement = "INSERT INTO wave_scan ${patchScanVulnerabilities(json, ids)}".toString() final result = surrealDb.sqlAsMap(authorization, statement) log.trace "Scan update result=$result" } + protected String patchScanVulnerabilities(String json, List ids) { + final value = "\"vulnerabilities\":${ids.collect(it-> "\"$it\"").toString()}" + json.replaceFirst(/"vulnerabilities":\s*\[]/, value) + } + @Override WaveScanRecord loadScanRecord(String scanId) { if( !scanId ) @@ -278,10 +278,10 @@ class SurrealPersistenceService implements PersistenceService { * @param mirrorId The ID of the mirror record * @return The corresponding {@link MirrorEntry} object or null if it cannot be found */ - MirrorEntry loadMirrorEntry(String mirrorId) { + MirrorResult loadMirrorResult(String mirrorId) { final query = "select * from wave_mirror where mirrorId = '$mirrorId'" final json = surrealDb.sqlAsString(getAuthorization(), query) - final type = new TypeReference>>() {} + final type = new TypeReference>>() {} final data= json ? JacksonHelper.fromJson(json, type) : null final result = data && data[0].result ? data[0].result[0] : null return result @@ -294,10 +294,10 @@ class SurrealPersistenceService implements PersistenceService { * @param digest The image content SHA256 digest * @return The corresponding {@link MirrorEntry} object or null if it cannot be found */ - MirrorEntry loadMirrorEntry(String targetImage, String digest) { + MirrorResult loadMirrorResult(String targetImage, String digest) { final query = "select * from wave_mirror where targetImage = '$targetImage' and digest = '$digest'" final json = surrealDb.sqlAsString(getAuthorization(), query) - final type = new TypeReference>>() {} + final type = new TypeReference>>() {} final data= json ? JacksonHelper.fromJson(json, type) : null final result = data && data[0].result ? data[0].result[0] : null return result @@ -309,7 +309,7 @@ class SurrealPersistenceService implements PersistenceService { * @param mirror {@link MirrorEntry} object */ @Override - void saveMirrorEntry(MirrorEntry mirror) { + void saveMirrorResult(MirrorResult mirror) { surrealDb.insertMirrorAsync(getAuthorization(), mirror).subscribe({ result-> log.trace "Mirror request with id '$mirror.mirrorId' saved record: ${result}" }, {error-> diff --git a/src/main/groovy/io/seqera/wave/service/request/ContainerRequest.groovy b/src/main/groovy/io/seqera/wave/service/request/ContainerRequest.groovy new file mode 100644 index 000000000..3e967469a --- /dev/null +++ b/src/main/groovy/io/seqera/wave/service/request/ContainerRequest.groovy @@ -0,0 +1,198 @@ +/* + * Wave, containers provisioning service + * Copyright (c) 2023-2024, Seqera Labs + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package io.seqera.wave.service.request + +import java.time.Instant + +import groovy.transform.Canonical +import groovy.transform.CompileStatic +import groovy.transform.ToString +import io.seqera.wave.api.ContainerConfig +import io.seqera.wave.api.ScanLevel +import io.seqera.wave.api.ScanMode +import io.seqera.wave.core.ContainerPlatform +import io.seqera.wave.model.ContainerCoordinates +import io.seqera.wave.tower.PlatformId +import io.seqera.wave.util.LongRndKey +import static io.seqera.wave.util.StringUtils.trunc +/** + * Model a container request + * + * @author Paolo Di Tommaso + */ +@Canonical +@ToString(includePackage = false, includeNames = true) +@CompileStatic +class ContainerRequest { + + String requestId + PlatformId identity + String containerImage + String containerFile + ContainerConfig containerConfig + String condaFile + ContainerPlatform platform + String buildId + Boolean buildNew + Boolean freeze + Boolean mirror + String scanId + ScanMode scanMode + List scanLevels + Boolean scanOnRequest + Instant creationTime + + boolean durable() { + return freeze || mirror + } + + PlatformId getIdentity() { + return identity + } + + ContainerCoordinates coordinates() { ContainerCoordinates.parse(containerImage) } + + @Override + String toString() { + return "ContainerRequestData[requestId=${requestId}; identity=${getIdentity()}; containerImage=$containerImage; containerFile=${trunc(containerFile)}; condaFile=${trunc(condaFile)}; containerConfig=${containerConfig}; buildId=${buildId}; scanId=${scanId}; scanMode=${scanMode}]" + } + + String getRequestId() { + return requestId + } + + String getContainerImage() { + return containerImage + } + + String getContainerFile() { + return containerFile + } + + ContainerConfig getContainerConfig() { + return containerConfig + } + + String getCondaFile() { + return condaFile + } + + ContainerPlatform getPlatform() { + return platform + } + + String getBuildId() { + return buildId + } + + Boolean getBuildNew() { + return buildNew + } + + Boolean getFreeze() { + return freeze + } + + Boolean getMirror() { + return mirror + } + + String getScanId() { + return scanId + } + + ScanMode getScanMode() { + return scanMode + } + + List getScanLevels() { + return scanLevels + } + + Instant getCreationTime() { + return creationTime + } + + Boolean getScanOnRequest() { + scanOnRequest + } + + static ContainerRequest create( + PlatformId identity, + String containerImage, + String containerFile, + ContainerConfig containerConfig, + String condaFile, + ContainerPlatform platform, + String buildId, + Boolean buildNew, + Boolean freeze, + Boolean mirror, + String scanId, + ScanMode scanMode, + List scanLevels, + Boolean scanOnRequest, + Instant creationTime + ) + { + return new ContainerRequest( + LongRndKey.rndHex(), + identity, + containerImage, + containerFile, + containerConfig, + condaFile, + platform, + buildId, + buildNew, + freeze, + mirror, + scanId, + scanMode, + scanLevels, + scanOnRequest, + creationTime + ) + } + + static ContainerRequest of(PlatformId identity) { + new ContainerRequest((String)null, identity) + } + + static ContainerRequest of(Map data) { + new ContainerRequest( + data.requestId as String, + data.identity as PlatformId, + data.containerImage as String, + data.containerFile as String, + data.containerConfig as ContainerConfig, + data.condaFile as String, + data.platform as ContainerPlatform, + data.buildId as String, + (Boolean) data.buildNew, + (Boolean) data.freeze, + (Boolean) data.mirror, + data.scanId as String, + data.scanMode as ScanMode, + data.scanLevels as List, + data.scanOnRequest as Boolean, + data.creationTime as Instant + ) + } +} diff --git a/src/main/groovy/io/seqera/wave/service/request/ContainerRequestService.groovy b/src/main/groovy/io/seqera/wave/service/request/ContainerRequestService.groovy new file mode 100644 index 000000000..4bb854249 --- /dev/null +++ b/src/main/groovy/io/seqera/wave/service/request/ContainerRequestService.groovy @@ -0,0 +1,71 @@ +/* + * Wave, containers provisioning service + * Copyright (c) 2023-2024, Seqera Labs + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package io.seqera.wave.service.request + +import io.seqera.wave.service.persistence.WaveContainerRecord + +/** + * service to fulfill request for an augmented container + * + * @author Paolo Di Tommaso + */ +interface ContainerRequestService { + + /** + * Get (generate) a new container token for the specified container request data + * + * @param request + * An instance of {@link ContainerRequest} + * @return + * The {@link TokenData} representing the unique token and the expiration time + */ + TokenData computeToken(ContainerRequest request) + + /** + * Get the container image for the given container requestId + * + * @param requestId The + * container request unique id + * @return + * The {@link ContainerRequest} object for the specified id, + * or {@code null} if the requestId is unknown + */ + ContainerRequest getRequest(String requestId) + + /** + * Evict the container request entry from the cache for the given container request id + * + * @param requestId + * The id of the request to be evicted + * @return + * The corresponding token string, or null if the token is unknown + */ + ContainerRequest evictRequest(String requestId) + + /** + * Load the record persisted in the requests db + * + * @param requestId + * The unique id of the request to be loaded + * @return + * The {@link WaveContainerRecord} object corresponding the specified id or {@code null} otherwise + */ + WaveContainerRecord loadContainerRecord(String requestId) + +} diff --git a/src/main/groovy/io/seqera/wave/service/token/ContainerTokenServiceImpl.groovy b/src/main/groovy/io/seqera/wave/service/request/ContainerRequestServiceImpl.groovy similarity index 59% rename from src/main/groovy/io/seqera/wave/service/token/ContainerTokenServiceImpl.groovy rename to src/main/groovy/io/seqera/wave/service/request/ContainerRequestServiceImpl.groovy index 109a9a948..315b0b1fc 100644 --- a/src/main/groovy/io/seqera/wave/service/token/ContainerTokenServiceImpl.groovy +++ b/src/main/groovy/io/seqera/wave/service/request/ContainerRequestServiceImpl.groovy @@ -16,15 +16,15 @@ * along with this program. If not, see . */ -package io.seqera.wave.service.token +package io.seqera.wave.service.request import java.time.Instant import groovy.transform.CompileStatic import groovy.util.logging.Slf4j import io.seqera.wave.configuration.TokenConfig -import io.seqera.wave.service.ContainerRequestData -import io.seqera.wave.util.LongRndKey +import io.seqera.wave.service.persistence.PersistenceService +import io.seqera.wave.service.persistence.WaveContainerRecord import jakarta.inject.Inject import jakarta.inject.Singleton /** @@ -35,39 +35,45 @@ import jakarta.inject.Singleton @Slf4j @CompileStatic @Singleton -class ContainerTokenServiceImpl implements ContainerTokenService { +class ContainerRequestServiceImpl implements ContainerRequestService { @Inject - private ContainerTokenStore containerTokenStorage + private ContainerRequestStore containerTokenStorage @Inject private TokenConfig config @Inject - private ContainerTokenStoreImpl tokenCache + private ContainerRequestStoreImpl tokenCache + + @Inject + private PersistenceService persistenceService @Override - TokenData computeToken(ContainerRequestData request) { - final token = LongRndKey.rndHex() + TokenData computeToken(ContainerRequest request) { final expiration = Instant.now().plus(config.cache.duration); - containerTokenStorage.put(token, request) - return new TokenData(token, expiration) + containerTokenStorage.put(request.requestId, request) + return new TokenData(request.requestId, expiration) } @Override - ContainerRequestData getRequest(String token) { - return containerTokenStorage.get(token) + ContainerRequest getRequest(String requestId) { + return containerTokenStorage.get(requestId) } @Override - ContainerRequestData evictRequest(String token) { - if(!token) + ContainerRequest evictRequest(String requestId) { + if(!requestId) return null - final request = tokenCache.get(token) + final request = tokenCache.get(requestId) if( request ) { - tokenCache.remove(token) + tokenCache.remove(requestId) } return request } + + WaveContainerRecord loadContainerRecord(String requestId) { + persistenceService.loadContainerRequest(requestId) + } } diff --git a/src/main/groovy/io/seqera/wave/service/token/ContainerTokenStore.groovy b/src/main/groovy/io/seqera/wave/service/request/ContainerRequestStore.groovy similarity index 81% rename from src/main/groovy/io/seqera/wave/service/token/ContainerTokenStore.groovy rename to src/main/groovy/io/seqera/wave/service/request/ContainerRequestStore.groovy index b6da43faf..9fa670e31 100644 --- a/src/main/groovy/io/seqera/wave/service/token/ContainerTokenStore.groovy +++ b/src/main/groovy/io/seqera/wave/service/request/ContainerRequestStore.groovy @@ -16,21 +16,19 @@ * along with this program. If not, see . */ -package io.seqera.wave.service.token - -import io.seqera.wave.service.ContainerRequestData - +package io.seqera.wave.service.request /** * Define the container request token persistence operations * * @author : jorge * */ -interface ContainerTokenStore { +interface ContainerRequestStore { - void put(String key, ContainerRequestData request) + void put(String key, ContainerRequest request) - ContainerRequestData get(String key) + ContainerRequest get(String key) void remove(String key) + } diff --git a/src/main/groovy/io/seqera/wave/service/token/ContainerTokenStoreImpl.groovy b/src/main/groovy/io/seqera/wave/service/request/ContainerRequestStoreImpl.groovy similarity index 76% rename from src/main/groovy/io/seqera/wave/service/token/ContainerTokenStoreImpl.groovy rename to src/main/groovy/io/seqera/wave/service/request/ContainerRequestStoreImpl.groovy index 0ae47ddaf..a1d4cd97b 100644 --- a/src/main/groovy/io/seqera/wave/service/token/ContainerTokenStoreImpl.groovy +++ b/src/main/groovy/io/seqera/wave/service/request/ContainerRequestStoreImpl.groovy @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package io.seqera.wave.service.token +package io.seqera.wave.service.request import java.time.Duration @@ -24,31 +24,30 @@ import groovy.transform.CompileStatic import groovy.util.logging.Slf4j import io.seqera.wave.configuration.TokenConfig import io.seqera.wave.encoder.MoshiEncodeStrategy -import io.seqera.wave.service.ContainerRequestData import io.seqera.wave.store.state.AbstractStateStore import io.seqera.wave.store.state.impl.StateProvider import jakarta.inject.Singleton /** - * Implements a cache store for {@link ContainerRequestData} + * Implements a cache store for {@link ContainerRequest} * * @author Paolo Di Tommaso */ @Slf4j @Singleton @CompileStatic -class ContainerTokenStoreImpl extends AbstractStateStore implements ContainerTokenStore { +class ContainerRequestStoreImpl extends AbstractStateStore implements ContainerRequestStore { private TokenConfig tokenConfig - ContainerTokenStoreImpl(StateProvider delegate, TokenConfig tokenConfig) { - super(delegate, new MoshiEncodeStrategy(){}) + ContainerRequestStoreImpl(StateProvider delegate, TokenConfig tokenConfig) { + super(delegate, new MoshiEncodeStrategy(){}) this.tokenConfig = tokenConfig log.info "Creating Tokens cache store ― duration=${tokenConfig.cache.duration}" } @Override protected String getPrefix() { - return 'wave-tokens/v1:' + return 'wave-tokens/v1' } @Override @@ -57,12 +56,12 @@ class ContainerTokenStoreImpl extends AbstractStateStore i } @Override - ContainerRequestData get(String key) { - return super.get(key) + ContainerRequest get(String key) { + return (ContainerRequest) super.get(key) } @Override - void put(String key, ContainerRequestData value) { + void put(String key, ContainerRequest value) { super.put(key, value) } @@ -70,4 +69,5 @@ class ContainerTokenStoreImpl extends AbstractStateStore i void remove(String key) { super.remove(key) } + } diff --git a/src/main/groovy/io/seqera/wave/service/request/ContainerState.groovy b/src/main/groovy/io/seqera/wave/service/request/ContainerState.groovy new file mode 100644 index 000000000..dca46c989 --- /dev/null +++ b/src/main/groovy/io/seqera/wave/service/request/ContainerState.groovy @@ -0,0 +1,83 @@ +/* + * Wave, containers provisioning service + * Copyright (c) 2023-2024, Seqera Labs + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package io.seqera.wave.service.request + +import java.time.Duration +import java.time.Instant + +import groovy.transform.Canonical +import groovy.transform.CompileStatic +import groovy.transform.ToString +import io.seqera.wave.service.builder.BuildEntry +import io.seqera.wave.service.mirror.MirrorEntry +import io.seqera.wave.service.mirror.MirrorResult +import io.seqera.wave.service.persistence.WaveBuildRecord +/** + * Model the state of container request + * + * @author Paolo Di Tommaso + */ +@ToString(includeNames = true, includePackage = false) +@Canonical +@CompileStatic +class ContainerState { + + final Instant startTime + final Duration duration + final Boolean succeeded + + boolean isRunning() { + return duration==null + } + + static ContainerState from(BuildEntry build) { + assert build + return new ContainerState( + build.request.startTime, + build.result?.duration, + build.result?.succeeded() + ) + } + + static ContainerState from(WaveBuildRecord build) { + assert build + return new ContainerState( + build.startTime, + build.duration, + build.succeeded() + ) + } + + static ContainerState from(MirrorEntry mirror) { + assert mirror + return new ContainerState( + mirror.request.creationTime, + mirror.result?.duration, + mirror.result?.succeeded() + ) + } + + static ContainerState from(MirrorResult mirror) { + return new ContainerState( + mirror.creationTime, + mirror.duration, + mirror.succeeded() + ) + } +} diff --git a/src/main/groovy/io/seqera/wave/service/request/ContainerStatusService.groovy b/src/main/groovy/io/seqera/wave/service/request/ContainerStatusService.groovy new file mode 100644 index 000000000..a45f9a71b --- /dev/null +++ b/src/main/groovy/io/seqera/wave/service/request/ContainerStatusService.groovy @@ -0,0 +1,32 @@ +/* + * Wave, containers provisioning service + * Copyright (c) 2023-2024, Seqera Labs + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package io.seqera.wave.service.request + + +import io.seqera.wave.api.ContainerStatusResponse +/** + * Define the contract for container request status service + * + * @author Paolo Di Tommaso + */ +interface ContainerStatusService { + + ContainerStatusResponse getContainerStatus(String requestId) + +} diff --git a/src/main/groovy/io/seqera/wave/service/request/ContainerStatusServiceImpl.groovy b/src/main/groovy/io/seqera/wave/service/request/ContainerStatusServiceImpl.groovy new file mode 100644 index 000000000..5f5afcc00 --- /dev/null +++ b/src/main/groovy/io/seqera/wave/service/request/ContainerStatusServiceImpl.groovy @@ -0,0 +1,220 @@ +/* + * Wave, containers provisioning service + * Copyright (c) 2023-2024, Seqera Labs + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package io.seqera.wave.service.request + +import static io.seqera.wave.api.ContainerStatus.* + +import java.time.Duration +import java.time.Instant + +import groovy.transform.Canonical +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j +import io.micronaut.context.annotation.Value +import io.micronaut.core.annotation.Nullable +import io.seqera.wave.api.ContainerStatus +import io.seqera.wave.api.ContainerStatusResponse +import io.seqera.wave.api.ScanMode +import io.seqera.wave.exception.NotFoundException +import io.seqera.wave.service.builder.ContainerBuildService +import io.seqera.wave.service.mirror.ContainerMirrorService +import io.seqera.wave.service.scan.ContainerScanService +import io.seqera.wave.service.scan.ScanEntry +import jakarta.inject.Inject +import jakarta.inject.Singleton +/** + * Implements the {@link ContainerStatusService} service + * + * @author Paolo Di Tommaso + */ +@Slf4j +@Singleton +@CompileStatic +class ContainerStatusServiceImpl implements ContainerStatusService { + + @Canonical + static class StageResult { + boolean succeeded + String reason + String detailsUri + } + + @Inject + private ContainerBuildService buildService + + @Inject + private ContainerMirrorService mirrorService + + @Inject + @Nullable + private ContainerScanService scanService + + @Inject + private ContainerRequestStore requestStore + + @Inject + @Value('${wave.server.url}') + private String serverUrl + + @Override + ContainerStatusResponse getContainerStatus(String requestId) { + + final request = requestStore.get(requestId) + if( !request ) + return null + + final ContainerState state = getContainerState(request) + + if( state.running ) { + return createResponse0(BUILDING, request, state) + } + else if( !request.scanId ) { + return createResponse0(DONE, request, state, buildResult(request,state)) + } + + if( request.scanId && request.scanMode == ScanMode.required && scanService ) { + final scan = scanService.getScanState(request.scanId) + if ( !scan ) + throw new NotFoundException("Missing container scan record with id: ${request.scanId}") + if ( !scan.duration ) { + return createResponse0(SCANNING, request, new ContainerState(state.startTime)) + } + else { + final newState = state + ? new ContainerState(state.startTime, state.duration+scan.duration, scan.succeeded()) + : new ContainerState(scan.startTime, scan.duration, scan.succeeded()) + return createScanResponse(request, newState, scan) + } + } + + return createResponse0(DONE, request, state) + } + + protected ContainerState getContainerState(ContainerRequest request) { + if( request.mirror && request.buildId ) { + final mirror = mirrorService.getMirrorResult(request.buildId) + if (!mirror) + throw new NotFoundException("Missing container mirror record with id: ${request.buildId}") + return ContainerState.from(mirror) + } + if( request.buildId ) { + final build = buildService.getBuildRecord(request.buildId) + if (!build) + throw new NotFoundException("Missing container build record with id: ${request.buildId}") + return ContainerState.from(build) + } + else { + final delta = Duration.between(request.creationTime, Instant.now()) + return new ContainerState( request.creationTime, delta,true ) + } + } + + protected ContainerStatusResponse createResponse0(ContainerStatus status, ContainerRequest request, ContainerState state, StageResult result=null) { + new ContainerStatusResponse( + request.requestId, + status, + !request.mirror ? request.buildId : null, + request.mirror ? request.buildId : null, + request.scanId, + null, + state.succeeded, + result?.reason, + result?.detailsUri, + state.startTime, + state.duration, + ) + } + + protected ContainerStatusResponse createScanResponse(ContainerRequest request, ContainerState state, ScanEntry scan) { + + final result = scanResult(request, scan) + return new ContainerStatusResponse( + request.requestId, + DONE, + !request.mirror ? request.buildId : null, + request.mirror ? request.buildId : null, + request.scanId, + scan.summary(), + result.succeeded, + result.reason, + result.detailsUri, + state.startTime, + state.duration, + ) + } + + protected StageResult buildResult(ContainerRequest request, ContainerState state) { + if( state.succeeded ) + return new StageResult(true) + + if( request.mirror ) { + // when 'mirror' flag is true, then it should be interpreted as a mirror operation + return new StageResult(false, + "Container mirror did not complete successfully", + "${serverUrl}/view/mirrors/${request.buildId}" ) + } + else { + // plain build + return new StageResult(false, + "Container build did not complete successfully", + "${serverUrl}/view/builds/${request.buildId}" ) + } + } + + protected StageResult scanResult(ContainerRequest request, ScanEntry scan) { + // scan was not successful + if (!scan.succeeded()) { + return new StageResult( + false, + "Container security scan did not complete successfully", + "${serverUrl}/view/scans/${request.scanId}" + ) + } + + // scan job was successful, check the required levels are matched + final allowedLevels = request + .scanLevels + ?.collect(it -> it.toString().toUpperCase()) + ?: List.of() + final foundLevels = new HashSet(scan + .summary() + ?.keySet() + ?: Set.of()) + foundLevels.removeAll(allowedLevels) + if (foundLevels) { + return new StageResult( + false, + "Container security scan operation found one or more vulnerabilities with severity: ${foundLevels.join(',')}", + "${serverUrl}/view/scans/${request.scanId}" + ) + } + + if( scan.summary() ) { + return new StageResult( + true, + "Container security scan operation found one or more vulnerabilities that are compatible with requested security levels: ${allowedLevels.join(',')}", + "${serverUrl}/view/scans/${request.scanId}" + ) + } + + // all fine + return new StageResult(true) + } + +} diff --git a/src/main/groovy/io/seqera/wave/service/token/TokenData.groovy b/src/main/groovy/io/seqera/wave/service/request/TokenData.groovy similarity index 96% rename from src/main/groovy/io/seqera/wave/service/token/TokenData.groovy rename to src/main/groovy/io/seqera/wave/service/request/TokenData.groovy index 81d74c086..8c621b908 100644 --- a/src/main/groovy/io/seqera/wave/service/token/TokenData.groovy +++ b/src/main/groovy/io/seqera/wave/service/request/TokenData.groovy @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package io.seqera.wave.service.token +package io.seqera.wave.service.request import java.time.Instant diff --git a/src/main/groovy/io/seqera/wave/service/scan/ContainerScanService.groovy b/src/main/groovy/io/seqera/wave/service/scan/ContainerScanService.groovy index 7abd784f0..b992055e2 100644 --- a/src/main/groovy/io/seqera/wave/service/scan/ContainerScanService.groovy +++ b/src/main/groovy/io/seqera/wave/service/scan/ContainerScanService.groovy @@ -18,17 +18,32 @@ package io.seqera.wave.service.scan - +import io.seqera.wave.api.ScanMode +import io.seqera.wave.service.builder.BuildEvent +import io.seqera.wave.service.mirror.MirrorEntry import io.seqera.wave.service.persistence.WaveScanRecord +import io.seqera.wave.service.request.ContainerRequest + /** * Declare operations to scan containers * * @author Munish Chouhan + * @author Paolo Di Tommaso */ interface ContainerScanService { + String getScanId(String targetImage, String digest, ScanMode mode, String format) + void scan(ScanRequest request) - WaveScanRecord getScanResult(String scanId) + void scanOnBuild(BuildEvent build) + + void scanOnMirror(MirrorEntry entry) + + void scanOnRequest(ContainerRequest request) + + WaveScanRecord getScanRecord(String scanId) + + ScanEntry getScanState(String scanId) } diff --git a/src/main/groovy/io/seqera/wave/service/scan/ContainerScanServiceImpl.groovy b/src/main/groovy/io/seqera/wave/service/scan/ContainerScanServiceImpl.groovy index 331bd53a9..b71e340ec 100644 --- a/src/main/groovy/io/seqera/wave/service/scan/ContainerScanServiceImpl.groovy +++ b/src/main/groovy/io/seqera/wave/service/scan/ContainerScanServiceImpl.groovy @@ -19,22 +19,28 @@ package io.seqera.wave.service.scan import java.nio.file.NoSuchFileException +import java.time.Instant import java.util.concurrent.CompletableFuture import java.util.concurrent.ExecutorService import groovy.transform.CompileStatic import groovy.util.logging.Slf4j import io.micronaut.context.annotation.Requires -import io.micronaut.runtime.event.annotation.EventListener import io.micronaut.scheduling.TaskExecutors +import io.seqera.wave.api.ScanMode import io.seqera.wave.configuration.ScanConfig import io.seqera.wave.service.builder.BuildEvent +import io.seqera.wave.service.builder.BuildRequest +import io.seqera.wave.service.inspect.ContainerInspectService import io.seqera.wave.service.job.JobHandler import io.seqera.wave.service.job.JobService import io.seqera.wave.service.job.JobSpec import io.seqera.wave.service.job.JobState +import io.seqera.wave.service.mirror.MirrorEntry +import io.seqera.wave.service.mirror.MirrorRequest import io.seqera.wave.service.persistence.PersistenceService import io.seqera.wave.service.persistence.WaveScanRecord +import io.seqera.wave.service.request.ContainerRequest import jakarta.inject.Inject import jakarta.inject.Named import jakarta.inject.Singleton @@ -43,16 +49,17 @@ import static io.seqera.wave.service.builder.BuildFormat.DOCKER * Implements ContainerScanService * * @author Munish Chouhan + * @author Paolo Di Tommaso */ @Slf4j @Named("Scan") -@Requires(property = 'wave.scan.enabled', value = 'true') +@Requires(bean = ScanConfig) @Singleton @CompileStatic class ContainerScanServiceImpl implements ContainerScanService, JobHandler { @Inject - private ScanConfig scanConfig + private ScanConfig config @Inject @Named(TaskExecutors.IO) @@ -67,12 +74,34 @@ class ContainerScanServiceImpl implements ContainerScanService, JobHandler + * @author Paolo Di Tommaso */ @Slf4j @Singleton @@ -52,7 +53,7 @@ class DockerScanStrategy extends ScanStrategy { @Override void scanContainer(String jobName, ScanRequest req) { - log.info("Launching container scan for buildId: ${req.buildId} with scanId ${req.scanId}") + log.info("Launching container scan for request: ${req.requestId} with scanId ${req.scanId}") // create the scan dir try { diff --git a/src/main/groovy/io/seqera/wave/service/scan/KubeScanStrategy.groovy b/src/main/groovy/io/seqera/wave/service/scan/KubeScanStrategy.groovy index b97c8d681..8ed5ef25a 100644 --- a/src/main/groovy/io/seqera/wave/service/scan/KubeScanStrategy.groovy +++ b/src/main/groovy/io/seqera/wave/service/scan/KubeScanStrategy.groovy @@ -42,6 +42,7 @@ import static java.nio.file.StandardOpenOption.WRITE * Implements ScanStrategy for Kubernetes * * @author Munish Chouhan + * @author Paolo Di Tommaso */ @Slf4j @Primary @@ -65,7 +66,7 @@ class KubeScanStrategy extends ScanStrategy { @Override void scanContainer(String jobName, ScanRequest req) { - log.info("Launching container scan for buildId: ${req.buildId} with scanId ${req.scanId}") + log.info("Launching container scan for request: ${req.requestId} with scanId ${req.scanId}") try{ // create the scan dir try { diff --git a/src/main/groovy/io/seqera/wave/service/scan/ScanEntry.groovy b/src/main/groovy/io/seqera/wave/service/scan/ScanEntry.groovy index 0f1952efd..b793a11c5 100644 --- a/src/main/groovy/io/seqera/wave/service/scan/ScanEntry.groovy +++ b/src/main/groovy/io/seqera/wave/service/scan/ScanEntry.groovy @@ -31,6 +31,7 @@ import io.seqera.wave.store.state.StateEntry * Model for scan result * * @author Munish Chouhan + * @author Paolo Di Tommaso */ @ToString(includePackage = false, includeNames = true) @Canonical @@ -41,15 +42,60 @@ class ScanEntry implements StateEntry, JobEntry { static final public String SUCCEEDED = 'SUCCEEDED' static final public String FAILED = 'FAILED' - final String scanId - final String buildId - final String containerImage - final Instant startTime - final Duration duration - final String status - final List vulnerabilities - final Integer exitCode - final String logs + /** + * The scan unique Id + */ + String scanId + + /** + * The build request that original this scan entry + */ + String buildId + + /** + * The container mirror request that original this scan entry + */ + String mirrorId + + /** + * The container request that original this scan entry + */ + String requestId + + /** + * The target container image to be scanner + */ + String containerImage + + /** + * The request creation time + */ + Instant startTime + + /** + * How long the scan operation required + */ + Duration duration + + /** + * The status of the scan operation + */ + String status + + /** + * The list of security vulnerabilities reported + */ + List vulnerabilities + + /** + * The scan job exit status + */ + Integer exitCode + + /** + * The scan job logs + */ + String logs @Override String getKey() { @@ -61,15 +107,30 @@ class ScanEntry implements StateEntry, JobEntry { return duration != null } - boolean isSucceeded() { status==SUCCEEDED } + boolean succeeded() { status==SUCCEEDED } @Deprecated - boolean isCompleted() { done() } + boolean completed() { done() } + + static ScanEntry create(ScanRequest request) { + return new ScanEntry( + request.scanId, + request.buildId, + request.mirrorId, + request.requestId, + request.targetImage, + request.creationTime, + null, + PENDING, + List.of()) + } ScanEntry success(List vulnerabilities){ return new ScanEntry( this.scanId, this.buildId, + this.mirrorId, + this.requestId, this.containerImage, this.startTime, Duration.between(this.startTime, Instant.now()), @@ -79,18 +140,59 @@ class ScanEntry implements StateEntry, JobEntry { } ScanEntry failure(Integer exitCode, String logs){ - return new ScanEntry(this.scanId, this.buildId, this.containerImage, this.startTime, Duration.between(this.startTime, Instant.now()), FAILED, List.of(), exitCode, logs) + return new ScanEntry( + this.scanId, + this.buildId, + this.mirrorId, + this.requestId, + this.containerImage, + this.startTime, + Duration.between(this.startTime, Instant.now()), + FAILED, + List.of(), + exitCode, + logs) } static ScanEntry failure(ScanRequest request){ - return new ScanEntry(request.scanId, request.buildId, request.targetImage, request.creationTime, Duration.between(request.creationTime, Instant.now()), FAILED, List.of()) + return new ScanEntry( + request.scanId, + request.buildId, + request.mirrorId, + request.requestId, + request.targetImage, + request.creationTime, + Duration.between(request.creationTime, Instant.now()), + FAILED, + List.of()) } - static ScanEntry pending(String scanId, String buildId, String containerImage) { - return new ScanEntry(scanId, buildId, containerImage, Instant.now(), null, PENDING, List.of()) + + Map summary() { + final result = new HashMap() + if( !vulnerabilities ) + return result + for( ScanVulnerability it : vulnerabilities ) { + def v = result.getOrDefault(it.severity, 0) + result.put(it.severity, v+1) + } + return result } - static ScanEntry create(String scanId, String buildId, String containerImage, Instant startTime, Duration duration1, String status, List vulnerabilities){ - return new ScanEntry(scanId, buildId, containerImage, startTime, duration1, status, vulnerabilities) + static ScanEntry of(Map opts){ + return new ScanEntry( + opts.scanId as String, + opts.buildId as String, + opts.mirrorId as String, + opts.requestId as String, + opts.containerImage as String, + opts.startTime as Instant, + opts.duration as Duration, + opts.status as String, + opts.vulnerabilities as List, + opts.exitCode as Integer, + opts.logs as String + ) } + } diff --git a/src/main/groovy/io/seqera/wave/service/scan/ScanId.groovy b/src/main/groovy/io/seqera/wave/service/scan/ScanId.groovy new file mode 100644 index 000000000..4af315d6b --- /dev/null +++ b/src/main/groovy/io/seqera/wave/service/scan/ScanId.groovy @@ -0,0 +1,54 @@ +/* + * Wave, containers provisioning service + * Copyright (c) 2023-2024, Seqera Labs + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package io.seqera.wave.service.scan + +import groovy.transform.Canonical +import groovy.transform.CompileStatic +import io.seqera.wave.util.RegHelper + +/** + * Model a scan operation id + * + * @author Paolo Di Tommaso + */ +@Canonical +@CompileStatic +class ScanId { + + final String base + final int count + + ScanId withCount(int count) { + new ScanId(this.base, count) + } + + String toString() { + "sc-${base}_${count}" + } + + static ScanId of(String containerImage, String digest=null){ + final data = new LinkedHashMap(2) + data.image = containerImage + if( digest ) + data.digest = digest + final x = RegHelper.sipHash(data) + return new ScanId(x) + } + +} diff --git a/src/main/groovy/io/seqera/wave/service/scan/ScanIdStore.groovy b/src/main/groovy/io/seqera/wave/service/scan/ScanIdStore.groovy new file mode 100644 index 000000000..4c924e824 --- /dev/null +++ b/src/main/groovy/io/seqera/wave/service/scan/ScanIdStore.groovy @@ -0,0 +1,56 @@ +/* + * Wave, containers provisioning service + * Copyright (c) 2023-2024, Seqera Labs + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package io.seqera.wave.service.scan + +import java.time.Duration + +import groovy.transform.CompileStatic +import io.seqera.wave.configuration.ScanConfig +import io.seqera.wave.encoder.MoshiEncodeStrategy +import io.seqera.wave.store.state.AbstractStateStore +import io.seqera.wave.store.state.impl.StateProvider +import jakarta.inject.Inject +import jakarta.inject.Singleton + +/** + * Implements a store strategy for scan ids + * + * @author Paolo Di Tommaso + */ +@Singleton +@CompileStatic +class ScanIdStore extends AbstractStateStore { + + @Inject + private ScanConfig config + + ScanIdStore(StateProvider provider) { + super(provider, new MoshiEncodeStrategy() {}) + } + + @Override + protected String getPrefix() { + return 'wave-scanid/v1' + } + + @Override + protected Duration getDuration() { + return config.scanIdDuration + } +} diff --git a/src/main/groovy/io/seqera/wave/service/scan/ScanRequest.groovy b/src/main/groovy/io/seqera/wave/service/scan/ScanRequest.groovy index 41564a4fd..c30454f1a 100644 --- a/src/main/groovy/io/seqera/wave/service/scan/ScanRequest.groovy +++ b/src/main/groovy/io/seqera/wave/service/scan/ScanRequest.groovy @@ -24,7 +24,6 @@ import java.time.Instant import groovy.transform.Canonical import groovy.transform.CompileStatic import io.seqera.wave.core.ContainerPlatform -import io.seqera.wave.service.builder.BuildRequest /** * Model a container scan request * @@ -33,17 +32,65 @@ import io.seqera.wave.service.builder.BuildRequest @Canonical @CompileStatic class ScanRequest { + + /** + * The scan unique id + */ final String scanId + + /** + * The container build that generated this scan operation, either a container, build or mirror request + */ final String buildId + + /** + * The container mirror that generated this scan operation, either a container, build or mirror request + */ + final String mirrorId + + /** + * The container request that generated this scan operation, either a container, build or mirror request + */ + final String requestId + + /** + * The docker config json required to authenticate this request + */ final String configJson + + /** + * The container image that needs to be scanned + */ final String targetImage + + /** + * The container platform to be used + */ final ContainerPlatform platform + + /** + * The scan job work directory + */ final Path workDir + + /** + * Scan request creation time + */ final Instant creationTime - static ScanRequest fromBuild(BuildRequest request) { - final id = request.scanId - final workDir = request.workDir.resolveSibling("scan-${id}") - return new ScanRequest(id, request.buildId, request.configJson, request.targetImage, request.platform, workDir, Instant.now()) + + static ScanRequest of(Map opts) { + new ScanRequest( + opts.scanId as String, + opts.buildId as String, + opts.mirrorId as String, + opts.requestId as String, + opts.configJson as String, + opts.targetImage as String, + opts.platform as ContainerPlatform, + opts.workDir as Path, + opts.creationTime as Instant + ) } + } diff --git a/src/main/groovy/io/seqera/wave/service/scan/ScanStateStore.groovy b/src/main/groovy/io/seqera/wave/service/scan/ScanStateStore.groovy index d1d267d55..bdc24ad99 100644 --- a/src/main/groovy/io/seqera/wave/service/scan/ScanStateStore.groovy +++ b/src/main/groovy/io/seqera/wave/service/scan/ScanStateStore.groovy @@ -44,7 +44,7 @@ class ScanStateStore extends AbstractStateStore { @Override protected String getPrefix() { - return 'wave-mirror/v1:' + return 'wave-scan/v1' } @Override diff --git a/src/main/groovy/io/seqera/wave/service/scan/ScanStrategy.groovy b/src/main/groovy/io/seqera/wave/service/scan/ScanStrategy.groovy index e333141bc..432b9c49c 100644 --- a/src/main/groovy/io/seqera/wave/service/scan/ScanStrategy.groovy +++ b/src/main/groovy/io/seqera/wave/service/scan/ScanStrategy.groovy @@ -28,6 +28,7 @@ import io.seqera.wave.configuration.ScanConfig * Implements ScanStrategy for Docker * * @author Munish Chouhan + * @author Paolo Di Tommaso */ @Slf4j @CompileStatic diff --git a/src/main/groovy/io/seqera/wave/service/scan/ScanVulnerability.groovy b/src/main/groovy/io/seqera/wave/service/scan/ScanVulnerability.groovy index 16d50f96b..3dff74a96 100644 --- a/src/main/groovy/io/seqera/wave/service/scan/ScanVulnerability.groovy +++ b/src/main/groovy/io/seqera/wave/service/scan/ScanVulnerability.groovy @@ -18,17 +18,18 @@ package io.seqera.wave.service.scan + import groovy.transform.Canonical import groovy.transform.CompileStatic import groovy.transform.ToString import groovy.util.logging.Slf4j import io.seqera.wave.util.StringUtils import org.jetbrains.annotations.NotNull - /** * Model for Scan Vulnerability * * @author Munish Chouhan + * @author Paolo Di Tommaso */ @Slf4j @ToString(includeNames = true, includePackage = false) @@ -71,4 +72,5 @@ class ScanVulnerability implements Comparable { if( that.severity==null ) return 1 return ORDER[this.severity] <=> ORDER[that.severity] } + } diff --git a/src/main/groovy/io/seqera/wave/service/scan/TrivyResultProcessor.groovy b/src/main/groovy/io/seqera/wave/service/scan/TrivyResultProcessor.groovy index 71ea0d720..a1055037b 100644 --- a/src/main/groovy/io/seqera/wave/service/scan/TrivyResultProcessor.groovy +++ b/src/main/groovy/io/seqera/wave/service/scan/TrivyResultProcessor.groovy @@ -27,6 +27,7 @@ import io.seqera.wave.exception.ScanRuntimeException * Implements ScanStrategy for Docker * * @author Munish Chouhan + * @author Paolo Di Tommaso */ @Slf4j class TrivyResultProcessor { diff --git a/src/main/groovy/io/seqera/wave/service/token/ContainerTokenService.groovy b/src/main/groovy/io/seqera/wave/service/token/ContainerTokenService.groovy deleted file mode 100644 index f545c3908..000000000 --- a/src/main/groovy/io/seqera/wave/service/token/ContainerTokenService.groovy +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Wave, containers provisioning service - * Copyright (c) 2023-2024, Seqera Labs - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package io.seqera.wave.service.token - -import io.seqera.wave.service.ContainerRequestData - -/** - * service to fulfill request for an augmented container - * - * @author Paolo Di Tommaso - */ -interface ContainerTokenService { - - /** - * Get (generate) a new container token for the specified container request data - * - * @param request An instance of {@link io.seqera.wave.service.ContainerRequestData} - * @return A new token string that's used to track this request - */ - TokenData computeToken(ContainerRequestData request) - - /** - * Get the container image for the given container token - * - * @param token A container token string - * @return the corresponding token string, or null if the token is unknown - */ - ContainerRequestData getRequest(String token) - - /** - * Evict the container request entry from the cache for the given container token - * - * @param token A container token string - * @return the corresponding token string, or null if the token is unknown - */ - ContainerRequestData evictRequest(String token) - -} diff --git a/src/main/groovy/io/seqera/wave/storage/ManifestCacheStore.groovy b/src/main/groovy/io/seqera/wave/storage/ManifestCacheStore.groovy index a5542594f..3a65eba98 100644 --- a/src/main/groovy/io/seqera/wave/storage/ManifestCacheStore.groovy +++ b/src/main/groovy/io/seqera/wave/storage/ManifestCacheStore.groovy @@ -51,7 +51,7 @@ class ManifestCacheStore extends AbstractStateStore implements Stor @Override protected String getPrefix() { - return "wave-blobs/v1:" + return "wave-blobs/v1" } @Override diff --git a/src/main/groovy/io/seqera/wave/store/state/AbstractStateStore.groovy b/src/main/groovy/io/seqera/wave/store/state/AbstractStateStore.groovy index 06b1acd4a..22bc90509 100644 --- a/src/main/groovy/io/seqera/wave/store/state/AbstractStateStore.groovy +++ b/src/main/groovy/io/seqera/wave/store/state/AbstractStateStore.groovy @@ -24,7 +24,7 @@ import groovy.transform.CompileStatic import io.seqera.wave.encoder.EncodingStrategy import io.seqera.wave.store.state.impl.StateProvider /** - * Implements a generic cache store + * Implements a generic store for ephemeral state data * * @author Paolo Di Tommaso */ @@ -44,10 +44,44 @@ abstract class AbstractStateStore implements StateStore { protected abstract Duration getDuration() - protected String key0(String k) { return getPrefix() + k } + protected String key0(String k) { return getPrefix() + ':' + k } protected String requestId0(String requestId) { - return getPrefix() + 'request-id/' + requestId + if( !requestId ) + throw new IllegalStateException("Argument 'requestId' cannot be null") + return getPrefix() + '/request-id:' + requestId + } + + /** + * Defines the counter for auto-increment operations. By default + * uses the entry "key". Subclasses can provide a custom logic to use a + * different counter key. + * + * @param key + * The entry for which the increment should be performed + * @param value + * The entry value for which the increment should be performed + * @return + * The counter key that by default is the entry key. + */ + protected CountParams counterKey(String key, V value) { + assert key, "Argument 'key' cannot be empty" + return new CountParams(getPrefix() + '/counter', key) + } + + /** + * Defines the Lua script that's applied to increment the entry counter. + * + * It assumes the entry is serialised as JSON object and it contains a {@code count} attribute + * that will be update with the store counter value. + * + * @return The Lua script used to increment the entry count. + */ + protected String counterScript() { + // NOTE: + // "value" is expected to be a Lua variable holding the JSON object + // "counter_value" is expected to be a Lua variable holding the new count value + /string.gsub(value, '"count"%s*:%s*(%d+)', '"count":' .. counter_value)/ } protected V deserialize(String encoded) { @@ -58,10 +92,6 @@ abstract class AbstractStateStore implements StateStore { return encodingStrategy.encode(value) } - protected String getRaw(String key) { - delegate.get(key) - } - @Override V get(String key) { final result = delegate.get(key0(key)) @@ -73,6 +103,7 @@ abstract class AbstractStateStore implements StateStore { return get(key) } + @Override void put(String key, V value) { put(key, value, getDuration()) } @@ -94,10 +125,30 @@ abstract class AbstractStateStore implements StateStore { return result } + @Override boolean putIfAbsent(String key, V value) { return putIfAbsent(key, value, getDuration()) } + CountResult putIfAbsentAndCount(String key, V value) { + putIfAbsentAndCount(key, value, getDuration()) + } + + CountResult putIfAbsentAndCount(String key, V value, Duration ttl) { + final result = delegate.putJsonIfAbsentAndIncreaseCount( + key0(key), + serialize(value), + ttl, + counterKey(key,value), + counterScript()) + // update the `value` with the result one + final updated = deserialize(result.value) + if( result && updated instanceof RequestIdAware ) { + delegate.put(requestId0(updated.getRequestId()), key, ttl) + } + return new CountResult( result.succeed, updated, result.count) + } + @Override void remove(String key) { delegate.remove(key0(key)) diff --git a/src/main/groovy/io/seqera/wave/store/state/CountParams.groovy b/src/main/groovy/io/seqera/wave/store/state/CountParams.groovy new file mode 100644 index 000000000..a3a8b2fb7 --- /dev/null +++ b/src/main/groovy/io/seqera/wave/store/state/CountParams.groovy @@ -0,0 +1,40 @@ +/* + * Wave, containers provisioning service + * Copyright (c) 2023-2024, Seqera Labs + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package io.seqera.wave.store.state + +import groovy.transform.Canonical + +/** + * Model state store auto increment params + * + * @author Paolo Di Tommaso + */ +@Canonical +class CountParams { + final String key + final String field + + static CountParams of(String key) { + final p=key.lastIndexOf('/') + return p==-1 + ? new CountParams("counters/v1", key) + : new CountParams(key.substring(0,p), key.substring(p+1)) + } + +} diff --git a/src/main/groovy/io/seqera/wave/store/state/CountResult.groovy b/src/main/groovy/io/seqera/wave/store/state/CountResult.groovy new file mode 100644 index 000000000..3855ef6d7 --- /dev/null +++ b/src/main/groovy/io/seqera/wave/store/state/CountResult.groovy @@ -0,0 +1,33 @@ +/* + * Wave, containers provisioning service + * Copyright (c) 2023-2024, Seqera Labs + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package io.seqera.wave.store.state + +import groovy.transform.Canonical + +/** + * Model the result object of state auto-increment operation + * + * @author Paolo Di Tommaso + */ +@Canonical +class CountResult { + final Boolean succeed + final V value + final Integer count +} diff --git a/src/main/groovy/io/seqera/wave/store/state/StateStore.groovy b/src/main/groovy/io/seqera/wave/store/state/StateStore.groovy index b80dd389b..642e37323 100644 --- a/src/main/groovy/io/seqera/wave/store/state/StateStore.groovy +++ b/src/main/groovy/io/seqera/wave/store/state/StateStore.groovy @@ -63,7 +63,8 @@ interface StateStore { boolean putIfAbsent(K key, V value) /** - * Store a value in the cache only if does not exist yet + * Store a value in the cache only if does not exist + * * @param key The unique associated with this object * @param value The object to store * @param ttl The max time-to-live of the stored entry diff --git a/src/main/groovy/io/seqera/wave/store/state/impl/LocalStateProvider.groovy b/src/main/groovy/io/seqera/wave/store/state/impl/LocalStateProvider.groovy index 540b7b3fe..2aaf450c2 100644 --- a/src/main/groovy/io/seqera/wave/store/state/impl/LocalStateProvider.groovy +++ b/src/main/groovy/io/seqera/wave/store/state/impl/LocalStateProvider.groovy @@ -21,10 +21,16 @@ package io.seqera.wave.store.state.impl import java.time.Duration import java.time.Instant import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicInteger import groovy.transform.CompileStatic import io.micronaut.context.annotation.Requires +import io.seqera.wave.store.state.CountParams +import io.seqera.wave.store.state.CountResult import jakarta.inject.Singleton +import org.luaj.vm2.Globals +import org.luaj.vm2.LuaValue +import org.luaj.vm2.lib.jse.JsePlatform /** * Simple cache store implementation for development purpose @@ -54,6 +60,8 @@ class LocalStateProvider implements StateProvider { private Map> store = new ConcurrentHashMap<>() + private Map counters = new ConcurrentHashMap<>() + @Override String get(String key) { final entry = store.get(key) @@ -87,6 +95,28 @@ class LocalStateProvider implements StateProvider { return putIfAbsent0(key, value, ttl) == null } + @Override + synchronized CountResult putJsonIfAbsentAndIncreaseCount(String key, String json, Duration ttl, CountParams counterKey, String luaScript) { + final counter = counterKey.key + '/' + counterKey.field + final done = putIfAbsent0(key, json, ttl) == null + final addr = counters + .computeIfAbsent(counter, (it)-> new AtomicInteger()) + if( done ) { + final count = addr.incrementAndGet() + // apply the conversion + Globals globals = JsePlatform.standardGlobals() + globals.set('value', LuaValue.valueOf(json)) + globals.set('counter_value', LuaValue.valueOf(count)) + LuaValue chunk = globals.load("return $luaScript;"); + LuaValue result = chunk.call(); + // store the result + put(key, result.toString(), ttl) + return new CountResult(true, result.toString(), count) + } + else + return new CountResult(false, get(key), addr.get()) + } + private String putIfAbsent0(String key, String value, Duration ttl) { final entry = store.get(key) if( entry?.isExpired() ) diff --git a/src/main/groovy/io/seqera/wave/store/state/impl/RedisStateProvider.groovy b/src/main/groovy/io/seqera/wave/store/state/impl/RedisStateProvider.groovy index 8777ffa40..40d444fa2 100644 --- a/src/main/groovy/io/seqera/wave/store/state/impl/RedisStateProvider.groovy +++ b/src/main/groovy/io/seqera/wave/store/state/impl/RedisStateProvider.groovy @@ -22,6 +22,8 @@ import java.time.Duration import groovy.transform.CompileStatic import io.micronaut.context.annotation.Requires +import io.seqera.wave.store.state.CountParams +import io.seqera.wave.store.state.CountResult import jakarta.inject.Inject import jakarta.inject.Singleton import redis.clients.jedis.Jedis @@ -78,6 +80,46 @@ class RedisStateProvider implements StateProvider { } } + /* + * Set a value only the specified key does not exists, if the value can be set + * the counter identified by the key provided via 'KEYS[2]' is incremented by 1, + * + * If the key already exists return the current key value. + */ + static private String putAndIncrement(String luaScript) { + """ + local value = ARGV[1] + local ttl = ARGV[2] + local pattern = ARGV[3] + if redis.call('EXISTS', KEYS[1]) == 0 then + -- increment the counter + local counter_value = redis.call('HINCRBY', KEYS[2], KEYS[3], 1) + value = ${luaScript} + redis.call('SET', KEYS[1], value, 'PX', ttl) + -- return the updated value + return {1, value, counter_value} + else + return {0, redis.call('GET', KEYS[1]), redis.call('HGET', KEYS[2], KEYS[3])} + end + """ + } + + + CountResult putJsonIfAbsentAndIncreaseCount(String key, String json, Duration ttl, String counter, String luaScript) { + return putJsonIfAbsentAndIncreaseCount(key, json, ttl, CountParams.of(counter), luaScript) + } + + @Override + CountResult putJsonIfAbsentAndIncreaseCount(String key, String json, Duration ttl, CountParams counter, String mapping) { + try( Jedis jedis=pool.getResource() ) { + final result = jedis.eval(putAndIncrement(mapping), 3, key, counter.key, counter.field, json, ttl.toMillis().toString()) + return new CountResult<>( + (result as List)[0] == 1, + (result as List)[1] as String, + (result as List)[2] as Integer) + } + } + @Override void remove(String key) { try( Jedis conn=pool.getResource() ) { diff --git a/src/main/groovy/io/seqera/wave/store/state/impl/StateProvider.groovy b/src/main/groovy/io/seqera/wave/store/state/impl/StateProvider.groovy index cdb3e24bc..42186966c 100644 --- a/src/main/groovy/io/seqera/wave/store/state/impl/StateProvider.groovy +++ b/src/main/groovy/io/seqera/wave/store/state/impl/StateProvider.groovy @@ -18,12 +18,37 @@ package io.seqera.wave.store.state.impl -import io.seqera.wave.store.state.StateStore +import java.time.Duration +import io.seqera.wave.store.state.CountParams +import io.seqera.wave.store.state.CountResult +import io.seqera.wave.store.state.StateStore /** * Define an cache interface alias to be used by cache implementation providers * * @author Paolo Di Tommaso */ interface StateProvider extends StateStore { + + /** + * Store a value in the cache only if does not exist. If the operation is successful + * the counter identified by the key specified is incremented by 1 and the counter (new) + * value is returned as result the operation. + * + * @param key + * The unique associated with this object + * @param value + * A JSON payload to be stored. It attribute "count" is updated with the counter incremented value + * @param counterKey + * The counter unique key to be incremented + * @param ttl + * The max time-to-live of the stored entry + * @return + * A tuple with 3 elements with the following semantic: , where "result" is {@code true} + * when the value was actually updated or {@code false} otherwise. "value" represent the specified value when + * "return" is true or the value currently existing if the key already exist. Finally "count" is the value + * of the count after the increment operation. + */ + CountResult putJsonIfAbsentAndIncreaseCount(K key, V value, Duration ttl, CountParams counterKey, String luaScript) + } diff --git a/src/main/groovy/io/seqera/wave/tower/auth/JwtAuthStore.groovy b/src/main/groovy/io/seqera/wave/tower/auth/JwtAuthStore.groovy index 22fba58d1..bc952b81c 100644 --- a/src/main/groovy/io/seqera/wave/tower/auth/JwtAuthStore.groovy +++ b/src/main/groovy/io/seqera/wave/tower/auth/JwtAuthStore.groovy @@ -31,6 +31,7 @@ import jakarta.inject.Singleton /** * Implements storage for {@link JwtAuth} record * + * @author Paolo Di Tommaso */ @Slf4j @Singleton @@ -46,7 +47,7 @@ class JwtAuthStore extends AbstractStateStore { @Override protected String getPrefix() { - return "tower-jwt-store/v1:" + return "tower-jwt-store/v1" } /** @@ -92,7 +93,7 @@ class JwtAuthStore extends AbstractStateStore { final now = Instant.now() final entry = auth.withUpdatedAt(now) this.put(auth.key, entry) - log.debug "JWT updating refreshed record - $entry" + log.trace "JWT updating refreshed record - $entry" } boolean storeIfAbsent(JwtAuth auth) { diff --git a/src/main/groovy/io/seqera/wave/tower/auth/JwtMonitor.groovy b/src/main/groovy/io/seqera/wave/tower/auth/JwtMonitor.groovy index 0fee156f7..5ed82a503 100644 --- a/src/main/groovy/io/seqera/wave/tower/auth/JwtMonitor.groovy +++ b/src/main/groovy/io/seqera/wave/tower/auth/JwtMonitor.groovy @@ -98,7 +98,7 @@ class JwtMonitor implements Runnable { } // ignore record without an empty refresh field if( !entry.refresh ) { - log.info "JWT record refresh ignored - entry=$entry" + log.debug "JWT record refresh ignored - entry=$entry" return } // check that's a `createdAt` field (it may be missing in legacy records) @@ -113,11 +113,11 @@ class JwtMonitor implements Runnable { // check if the JWT record is expired final deadline = entry.createdAt + tokenConfig.cache.duration if( now > deadline ) { - log.info "JWT record expired - entry=$entry; deadline=$deadline; " + log.debug "JWT record expired - entry=$entry; deadline=$deadline; " return } - log.debug "JWT refresh request - entry=$entry; deadline=$deadline" + log.trace "JWT refresh request - entry=$entry; deadline=$deadline" towerClient.userInfo(entry.endpoint, entry) jwtTimeStore.setRefreshTimer(key) } diff --git a/src/main/groovy/io/seqera/wave/util/ContainerHelper.groovy b/src/main/groovy/io/seqera/wave/util/ContainerHelper.groovy index 7348d27ce..896427b3e 100644 --- a/src/main/groovy/io/seqera/wave/util/ContainerHelper.groovy +++ b/src/main/groovy/io/seqera/wave/util/ContainerHelper.groovy @@ -22,6 +22,7 @@ import groovy.transform.CompileStatic import groovy.util.logging.Slf4j import io.micronaut.core.annotation.Nullable import io.seqera.wave.api.BuildContext +import io.seqera.wave.api.ContainerStatus import io.seqera.wave.api.ImageNameStrategy import io.seqera.wave.api.PackagesSpec import io.seqera.wave.api.SubmitContainerTokenRequest @@ -29,9 +30,9 @@ import io.seqera.wave.api.SubmitContainerTokenResponse import io.seqera.wave.config.CondaOpts import io.seqera.wave.core.ContainerPlatform import io.seqera.wave.exception.BadRequestException -import io.seqera.wave.service.ContainerRequestData +import io.seqera.wave.service.request.ContainerRequest import io.seqera.wave.service.builder.BuildFormat -import io.seqera.wave.service.token.TokenData +import io.seqera.wave.service.request.TokenData import org.yaml.snakeyaml.Yaml import static io.seqera.wave.service.builder.BuildFormat.SINGULARITY import static io.seqera.wave.util.DockerHelper.condaEnvironmentToCondaYaml @@ -130,19 +131,22 @@ class ContainerHelper { } } - static SubmitContainerTokenResponse makeResponseV1(ContainerRequestData data, TokenData token, String waveImage) { + static SubmitContainerTokenResponse makeResponseV1(ContainerRequest data, TokenData token, String waveImage) { final target = waveImage final build = data.buildNew ? data.buildId : null - return new SubmitContainerTokenResponse(token.value, target, token.expiration, data.containerImage, build, null, null, null) + return new SubmitContainerTokenResponse(data.requestId, token.value, target, token.expiration, data.containerImage, build, null, null, null, null, null) } - static SubmitContainerTokenResponse makeResponseV2(ContainerRequestData data, TokenData token, String waveImage) { + static SubmitContainerTokenResponse makeResponseV2(ContainerRequest data, TokenData token, String waveImage) { final target = data.durable() ? data.containerImage : waveImage final build = data.buildId final Boolean cached = !data.buildNew final expiration = !data.durable() ? token.expiration : null final tokenId = !data.durable() ? token.value : null - return new SubmitContainerTokenResponse(tokenId, target, expiration, data.containerImage, build, cached, data.freeze, data.mirror) + final status = data.buildNew || data.scanId + ? ContainerStatus.PENDING + : ContainerStatus.DONE + return new SubmitContainerTokenResponse(data.requestId, tokenId, target, expiration, data.containerImage, build, cached, data.freeze, data.mirror, data.scanId, status) } static String patchPlatformEndpoint(String endpoint) { diff --git a/src/main/resources/application-buildlogs-aws-test.yml b/src/main/resources/application-buildlogs-aws-test.yml index b147c2ea1..88d3c405a 100644 --- a/src/main/resources/application-buildlogs-aws-test.yml +++ b/src/main/resources/application-buildlogs-aws-test.yml @@ -3,4 +3,6 @@ wave: build: logs: bucket: "nextflow-ci" + prefix: 'wave-build/logs' + conda-lock-prefix: 'wave-build/conda-locks' ... diff --git a/src/main/resources/application-buildlogs-local.yml b/src/main/resources/application-buildlogs-local.yml index 53ac81a8c..757bdcda0 100644 --- a/src/main/resources/application-buildlogs-local.yml +++ b/src/main/resources/application-buildlogs-local.yml @@ -4,4 +4,5 @@ wave: logs: bucket: "$PWD/build-workspace" prefix: 'wave-build/logs' + conda-lock-prefix: 'wave-build/conda-locks' ... diff --git a/src/main/resources/io/seqera/wave/build-notification.html b/src/main/resources/io/seqera/wave/build-notification.html index 0c3a96c3a..1261cdfbd 100644 --- a/src/main/resources/io/seqera/wave/build-notification.html +++ b/src/main/resources/io/seqera/wave/build-notification.html @@ -97,8 +97,8 @@

Summary

<% if (scan_url) { %> - Security - Image scan report + Security scan + ${scan_id} <% } %> @@ -111,9 +111,6 @@

Conda file

${build_condafile}
<% } %> -

Build logs

-
${build_log_data}
-