diff --git a/src/main/groovy/io/seqera/wave/controller/BuildController.groovy b/src/main/groovy/io/seqera/wave/controller/BuildController.groovy index a4c5378ca..c7b984116 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 @@ -32,6 +33,7 @@ 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.conda.CondaLockService import io.seqera.wave.service.logs.BuildLogService import io.seqera.wave.service.mirror.ContainerMirrorService import io.seqera.wave.service.mirror.MirrorRequest @@ -58,6 +60,9 @@ class BuildController { @Nullable BuildLogService logService + @Inject + CondaLockService condaLockService + @Get("/v1alpha1/builds/{buildId}") HttpResponse getBuildRecord(String buildId) { final record = buildService.getBuildRecord(buildId) @@ -85,6 +90,18 @@ class BuildController { : HttpResponse.notFound() } + @Produces(MediaType.TEXT_PLAIN) + @Get(value="/v1alpha1/builds/{buildId}/condalock") + HttpResponse getCondaLock(String buildId){ + if( condaLockService==null ) + throw new IllegalStateException("Build Logs service not configured") + final condaLock = condaLockService.fetchCondaLock(buildId) + return condaLock + ? HttpResponse.ok(condaLock) + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + buildId + ".lock\"") + : HttpResponse.notFound() + } + protected BuildStatusResponse buildResponse0(String buildId) { if( !buildId ) throw new BadRequestException("Missing 'buildId' parameter") @@ -100,4 +117,5 @@ class BuildController { ?.toStatusResponse() } } + } diff --git a/src/main/groovy/io/seqera/wave/controller/ViewController.groovy b/src/main/groovy/io/seqera/wave/controller/ViewController.groovy index 5b8c16aff..990d0dc06 100644 --- a/src/main/groovy/io/seqera/wave/controller/ViewController.groovy +++ b/src/main/groovy/io/seqera/wave/controller/ViewController.groovy @@ -33,6 +33,7 @@ import io.micronaut.scheduling.annotation.ExecuteOn import io.micronaut.views.View import io.seqera.wave.exception.NotFoundException import io.seqera.wave.service.builder.ContainerBuildService +import io.seqera.wave.service.conda.CondaLockService import io.seqera.wave.service.inspect.ContainerInspectService import io.seqera.wave.service.logs.BuildLogService import io.seqera.wave.service.persistence.PersistenceService @@ -68,6 +69,9 @@ class ViewController { @Nullable private BuildLogService buildLogService + @Inject + private CondaLockService condaLockService + @Inject private ContainerInspectService inspectService @@ -152,6 +156,10 @@ class ViewController { binding.build_log_truncated = buildLog?.truncated binding.build_log_url = "$serverUrl/v1alpha1/builds/${result.buildId}/logs" } + //configure conda lock file when available + if( condaLockService && result.condaFile ) { + binding.build_conda_lock_url = "$serverUrl/v1alpha1/builds/${result.buildId}/condalock" + } // result the main object return binding } diff --git a/src/main/groovy/io/seqera/wave/service/conda/Conda.groovy b/src/main/groovy/io/seqera/wave/service/conda/Conda.groovy new file mode 100644 index 000000000..09d412f42 --- /dev/null +++ b/src/main/groovy/io/seqera/wave/service/conda/Conda.groovy @@ -0,0 +1,13 @@ +package io.seqera.wave.service.conda + +/** + * Interface for Conda constants + * + * @author Munish Chouhan + */ +interface Conda { + + String CONDA_LOCK_START = "conda_lock_start" + String CONDA_LOCK_END = "conda_lock_end" + +} diff --git a/src/main/groovy/io/seqera/wave/service/conda/CondaLockService.groovy b/src/main/groovy/io/seqera/wave/service/conda/CondaLockService.groovy new file mode 100644 index 000000000..546eb2374 --- /dev/null +++ b/src/main/groovy/io/seqera/wave/service/conda/CondaLockService.groovy @@ -0,0 +1,34 @@ +/* + * Wave, containers provisioning service + * Copyright (c) 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.conda + +import io.micronaut.http.server.types.files.StreamedFile + +/** + * Service to manage conda lock files + * + * @author Munish Chouhan + */ +interface CondaLockService { + + void storeCondaLock(String buildId, String condaLock) + + StreamedFile fetchCondaLock(String buildId) + +} diff --git a/src/main/groovy/io/seqera/wave/service/conda/impl/CondaLockServiceImpl.groovy b/src/main/groovy/io/seqera/wave/service/conda/impl/CondaLockServiceImpl.groovy new file mode 100644 index 000000000..faf0060ec --- /dev/null +++ b/src/main/groovy/io/seqera/wave/service/conda/impl/CondaLockServiceImpl.groovy @@ -0,0 +1,103 @@ +/* + * Wave, containers provisioning service + * Copyright (c) 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.conda.impl + +import io.micronaut.http.MediaType +import io.micronaut.http.server.types.files.StreamedFile +import io.seqera.wave.service.conda.CondaLockService + +import java.nio.charset.StandardCharsets +import java.util.concurrent.CompletableFuture +import java.util.concurrent.ExecutorService + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j +import io.micronaut.runtime.event.annotation.EventListener +import io.micronaut.scheduling.TaskExecutors +import io.seqera.wave.service.builder.BuildEvent +import io.seqera.wave.service.persistence.PersistenceService +import io.seqera.wave.service.persistence.WaveCondaLockRecord +import jakarta.inject.Inject +import jakarta.inject.Named +import jakarta.inject.Singleton +import static io.seqera.wave.service.conda.Conda.CONDA_LOCK_END +import static io.seqera.wave.service.conda.Conda.CONDA_LOCK_START +/** + * Implements Service to manage conda lock files from an Object store + * + * @author Munish Chouhan + */ +@Slf4j +@Singleton +@CompileStatic +class CondaLockServiceImpl implements CondaLockService { + + @Inject + @Named(TaskExecutors.IO) + private volatile ExecutorService ioExecutor + + @Inject + PersistenceService persistenceService + + @EventListener + void onBuildEvent(BuildEvent event) { + if ( event.request.condaFile ) { + CompletableFuture.supplyAsync(() -> storeCondaLock(event.result.id, event.result.logs), ioExecutor) + } + } + + @Override + void storeCondaLock(String buildId, String logs) { + if( !logs ) return + try { + String condaLock = extractCondaLockFile(logs) + if (condaLock){ + log.debug "Storing condalock for buildId: $buildId" + def record = new WaveCondaLockRecord(buildId, condaLock.getBytes(StandardCharsets.UTF_8)) + persistenceService.saveCondaLock(record) + } + } + catch (Exception e) { + log.warn "Unable to store condalock for buildId: $buildId - reason: ${e.message}", e + } + } + + @Override + StreamedFile fetchCondaLock(String buildId) { + if( !buildId ) + return null + def condaLock = persistenceService.loadCondaLock(buildId)?.condaLockFile + if( !condaLock ) + return null + def inputStream = new ByteArrayInputStream(condaLock) + return new StreamedFile(inputStream, MediaType.APPLICATION_OCTET_STREAM_TYPE) + + } + + protected static extractCondaLockFile(String logs) { + try { + return logs.substring(logs.lastIndexOf(CONDA_LOCK_START) + CONDA_LOCK_START.length(), logs.lastIndexOf(CONDA_LOCK_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/logs/BuildLogServiceImpl.groovy b/src/main/groovy/io/seqera/wave/service/logs/BuildLogServiceImpl.groovy index 896330267..cd689aa38 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,9 @@ 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 +import static io.seqera.wave.service.conda.Conda.CONDA_LOCK_END +import static io.seqera.wave.service.conda.Conda.CONDA_LOCK_START /** * Implements Service to manage logs from an Object store * @@ -83,7 +86,7 @@ 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" } @@ -97,6 +100,7 @@ class BuildLogServiceImpl implements BuildLogService { @Override void storeLog(String buildId, String content){ try { + content = removeCondaLockFile(content) log.debug "Storing logs for buildId: $buildId" final uploadRequest = UploadRequest.fromBytes(content.getBytes(), logKey(buildId)) objectStorageOperations.upload(uploadRequest) @@ -125,4 +129,11 @@ 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') + } } 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 5cd103f93..1de6edbb9 100644 --- a/src/main/groovy/io/seqera/wave/service/persistence/PersistenceService.groovy +++ b/src/main/groovy/io/seqera/wave/service/persistence/PersistenceService.groovy @@ -111,6 +111,22 @@ interface PersistenceService { */ WaveScanRecord loadScanRecord(String scanId) + /** + * Store a condaLock for buildId in the underlying persistence layer. + * + * + * @param build A {@link WaveBuildRecord} object + */ + void saveCondaLock(WaveCondaLockRecord condaLock) + + /** + * Retrieve a condaLock for the given build id + * + * @param buildId + * @return The corresponding condaLock file as a string + */ + WaveCondaLockRecord loadCondaLock(String buildId) + /** * Load a mirror state record * diff --git a/src/main/groovy/io/seqera/wave/service/persistence/WaveCondaLockRecord.groovy b/src/main/groovy/io/seqera/wave/service/persistence/WaveCondaLockRecord.groovy new file mode 100644 index 000000000..0ed2a8811 --- /dev/null +++ b/src/main/groovy/io/seqera/wave/service/persistence/WaveCondaLockRecord.groovy @@ -0,0 +1,43 @@ +/* + * Wave, containers provisioning service + * Copyright (c) 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.persistence + +import groovy.transform.CompileStatic +import groovy.transform.EqualsAndHashCode +import groovy.transform.ToString + +/** + * Model a Wave conda lock record + * + * @author Munish Chouhan + */ +@ToString +@CompileStatic +@EqualsAndHashCode +class WaveCondaLockRecord { + String buildId + byte[] condaLockFile + + WaveCondaLockRecord() {} + + WaveCondaLockRecord(String buildId, byte[] condaLockFile) { + this.buildId = buildId + this.condaLockFile = condaLockFile + } +} 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 0c24dbaf6..376701cf5 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 @@ -23,6 +23,7 @@ import io.seqera.wave.core.ContainerDigestPair import io.seqera.wave.service.mirror.MirrorEntry import io.seqera.wave.service.persistence.PersistenceService import io.seqera.wave.service.persistence.WaveBuildRecord +import io.seqera.wave.service.persistence.WaveCondaLockRecord import io.seqera.wave.service.persistence.WaveContainerRecord import io.seqera.wave.service.persistence.WaveScanRecord import jakarta.inject.Singleton @@ -38,9 +39,12 @@ class LocalPersistenceService implements PersistenceService { private Map buildStore = new HashMap<>() private Map requestStore = new HashMap<>() + private Map scanStore = new HashMap<>() private Map mirrorStore = new HashMap<>() + private Map condaLockStore = new HashMap<>() + @Override void saveBuild(WaveBuildRecord record) { buildStore[record.buildId] = record @@ -98,6 +102,16 @@ class LocalPersistenceService implements PersistenceService { scanStore.get(scanId) } + @Override + void saveCondaLock(WaveCondaLockRecord condaLock) { + condaLockStore.put(condaLock.buildId, condaLock) + } + + @Override + WaveCondaLockRecord loadCondaLock(String buildId) { + return condaLockStore.get(buildId) + } + MirrorEntry loadMirrorEntry(String mirrorId) { mirrorStore.get(mirrorId) } 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 d475b8646..724e1062a 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 @@ -33,6 +33,7 @@ import io.seqera.wave.service.builder.BuildRequest import io.seqera.wave.service.mirror.MirrorEntry import io.seqera.wave.service.persistence.PersistenceService import io.seqera.wave.service.persistence.WaveBuildRecord +import io.seqera.wave.service.persistence.WaveCondaLockRecord import io.seqera.wave.service.persistence.WaveContainerRecord import io.seqera.wave.service.persistence.WaveScanRecord import io.seqera.wave.service.scan.ScanVulnerability @@ -92,6 +93,19 @@ class SurrealPersistenceService implements PersistenceService { final ret5 = surrealDb.sqlAsMap(authorization, "define table wave_mirror SCHEMALESS") if( ret5.status != "OK") throw new IllegalStateException("Unable to define SurrealDB table wave_mirror - cause: $ret5") + // create wave_conda_lock table + final ret6 = surrealDb.sqlAsMap(authorization, "DEFINE TABLE wave_conda_lock SCHEMAFULL;") + if( ret6.status != "OK") + throw new IllegalStateException("Unable to define SurrealDB table wave_conda_lock - cause: $ret6") + final ret7 = surrealDb.sqlAsMap(authorization, "DEFINE FIELD buildId ON TABLE wave_conda_lock TYPE number;") + if( ret7.status != "OK") + throw new IllegalStateException("Unable to define SurrealDB field buildId on table wave_conda_lock - cause: $ret7") + final ret8 = surrealDb.sqlAsMap(authorization, "DEFINE FIELD condaLockFile ON TABLE wave_conda_lock TYPE bytes;") + if( ret8.status != "OK") + throw new IllegalStateException("Unable to define SurrealDB field condaLockFile on table wave_conda_lock - cause: $ret8") + final ret9 = surrealDb.sqlAsMap(authorization, "DEFINE INDEX idx_buildId ON TABLE wave_conda_lock COLUMNS buildId UNIQUE;") + if( ret9.status != "OK") + throw new IllegalStateException("Unable to define SurrealDB index idx_buildId on table wave_conda_lock - cause: $ret9") } protected String getAuthorization() { @@ -254,6 +268,37 @@ class SurrealPersistenceService implements PersistenceService { return result } + @Override + void saveCondaLock(WaveCondaLockRecord condaLock) { + final query = """\ + INSERT into wave_conda_lock { + buildId: '$condaLock.buildId', + condaLockFile: $condaLock.condaLockFile + }""".stripIndent() + surrealDb + .sqlAsync(getAuthorization(), query) + .subscribe({result -> + log.trace "Conda lock for buildId '$condaLock.buildId' saved record: ${result}" + }, + {error-> + def msg = error.message + if( error instanceof HttpClientResponseException ){ + msg += ":\n $error.response.body" + } + log.error("Error saving conda lock for buildId: $condaLock.buildId", error) + }) + } + + @Override + WaveCondaLockRecord loadCondaLock(String buildId) { + final query = "select * from wave_conda_lock where buildId = '$buildId'" + final json = surrealDb.sqlAsString(getAuthorization(), query) + 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 + } + // === mirror operations /** diff --git a/src/main/resources/io/seqera/wave/build-view.hbs b/src/main/resources/io/seqera/wave/build-view.hbs index 821050224..450e58dd1 100644 --- a/src/main/resources/io/seqera/wave/build-view.hbs +++ b/src/main/resources/io/seqera/wave/build-view.hbs @@ -117,6 +117,13 @@ Image scan report {{/if}} + + {{#if build_success}} + {{#if build_conda_lock_url }} + Conda lockfile + Conda lockfile + {{/if}} + {{/if}}

Container file

diff --git a/src/test/groovy/io/seqera/wave/controller/BuildControllerTest.groovy b/src/test/groovy/io/seqera/wave/controller/BuildControllerTest.groovy index 5809d680e..a081c962b 100644 --- a/src/test/groovy/io/seqera/wave/controller/BuildControllerTest.groovy +++ b/src/test/groovy/io/seqera/wave/controller/BuildControllerTest.groovy @@ -44,6 +44,7 @@ import io.seqera.wave.service.logs.BuildLogService import io.seqera.wave.service.logs.BuildLogServiceImpl import io.seqera.wave.service.persistence.PersistenceService import io.seqera.wave.service.persistence.WaveBuildRecord +import io.seqera.wave.service.persistence.WaveCondaLockRecord import io.seqera.wave.tower.PlatformId import io.seqera.wave.util.ContainerHelper import jakarta.inject.Inject @@ -152,4 +153,55 @@ class BuildControllerTest extends Specification { e.status == HttpStatus.NOT_FOUND } + def 'should get container build log' () { + given: + def buildId = 'testbuildid1234' + def LOGS = "test build log" + def response = new StreamedFile(new ByteArrayInputStream(LOGS.bytes), MediaType.APPLICATION_OCTET_STREAM_TYPE) + + when: + def req = HttpRequest.GET("/v1alpha1/builds/${buildId}/logs") + def res = client.toBlocking().exchange(req, StreamedFile) + + then: + 1 * buildLogService.fetchLogStream(buildId) >> response + and: + res.code() == 200 + new String(res.bodyBytes) == LOGS + } + + def 'should get conda lock file' () { + given: + def build1 = new WaveBuildRecord( + buildId: 'test1', + dockerFile: 'test1', + condaFile: 'test1', + targetImage: 'testImage1', + userName: 'testUser1', + userEmail: 'test1@xyz.com', + userId: 1, + requestIp: '127.0.0.1', + startTime: Instant.now().minus(1, ChronoUnit.DAYS) ) + def condaLock = "conda lock content" + def waveCondaLock = new WaveCondaLockRecord(build1.buildId, condaLock.bytes) + and: + persistenceService.saveBuild(build1) + persistenceService.saveCondaLock(waveCondaLock) + sleep(500) + + when: + def req = HttpRequest.GET("/v1alpha1/builds/${build1.buildId}/condalock") + def res = client.toBlocking().exchange(req, byte[]) + then: + res.status() == HttpStatus.OK + new String(res.body()) == condaLock + res.header("Content-Disposition") == "attachment; filename=\"${build1.buildId}.lock\"" + + when: + client.toBlocking().exchange(HttpRequest.GET("/v1alpha1/builds/0000/condalock"), String) + then: + HttpClientResponseException e = thrown(HttpClientResponseException) + e.status == HttpStatus.NOT_FOUND + } + } diff --git a/src/test/groovy/io/seqera/wave/controller/ViewControllerTest.groovy b/src/test/groovy/io/seqera/wave/controller/ViewControllerTest.groovy index 499733dac..9cc262131 100644 --- a/src/test/groovy/io/seqera/wave/controller/ViewControllerTest.groovy +++ b/src/test/groovy/io/seqera/wave/controller/ViewControllerTest.groovy @@ -34,6 +34,7 @@ import io.seqera.wave.api.ContainerConfig import io.seqera.wave.api.SubmitContainerTokenRequest import io.seqera.wave.core.ContainerPlatform import io.seqera.wave.service.ContainerRequestData +import io.seqera.wave.service.conda.CondaLockService import io.seqera.wave.service.builder.ContainerBuildService import io.seqera.wave.service.inspect.ContainerInspectService import io.seqera.wave.service.logs.BuildLogService @@ -73,9 +74,12 @@ class ViewControllerTest extends Specification { @Inject private ContainerInspectService inspectService + @Inject + private CondaLockService condaLockService + def 'should return build page mapping' () { given: - def controller = new ViewController(serverUrl: 'http://foo.com', buildLogService: buildLogService) + def controller = new ViewController(serverUrl: 'http://foo.com', buildLogService: buildLogService, condaLockService: condaLockService) and: def record = new WaveBuildRecord( buildId: '12345', @@ -110,6 +114,7 @@ class ViewControllerTest extends Specification { binding.build_log_data == 'log content' binding.build_log_truncated == false binding.build_log_url == 'http://foo.com/v1alpha1/builds/12345/logs' + binding.build_conda_lock_url == 'http://foo.com/v1alpha1/builds/12345/condalock' binding.build_success == true binding.build_in_progress == false binding.build_failed == false diff --git a/src/test/groovy/io/seqera/wave/service/conda/impl/CondaLockServiceImplTest.groovy b/src/test/groovy/io/seqera/wave/service/conda/impl/CondaLockServiceImplTest.groovy new file mode 100644 index 000000000..37b975d27 --- /dev/null +++ b/src/test/groovy/io/seqera/wave/service/conda/impl/CondaLockServiceImplTest.groovy @@ -0,0 +1,119 @@ +/* + * Wave, containers provisioning service + * Copyright (c) 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.conda.impl + +import spock.lang.Specification + +import java.nio.charset.StandardCharsets + +import io.seqera.wave.service.persistence.PersistenceService +import io.seqera.wave.service.persistence.WaveCondaLockRecord + +/** + * Tests for {@link CondaLockServiceImpl} + * + * @author Munish Chouhan + */ +class CondaLockServiceImplTest extends Specification { + + def "should store conda lockfile" (){ + given: + def persistenceService = Mock(PersistenceService) + def service = new CondaLockServiceImpl(persistenceService: persistenceService) + + and: + def logs = """ + logs.... + #10 12.24 conda_lock_start + #10 12.24 # This file may be used to create an environment using: + #10 12.24 # \$ conda create --name --file + #10 12.24 # platform: linux-aarch64 + #10 12.24 @EXPLICIT + #10 12.25 conda_lock_end + logs....""".stripIndent() + + when: + service.storeCondaLock("someId", logs) + + then: + 1 * persistenceService.saveCondaLock(_ as WaveCondaLockRecord) + } + + def "should not store conda lockfile when logs does not exist" (){ + given: + def persistenceService = Mock(PersistenceService) + def service = new CondaLockServiceImpl(persistenceService: persistenceService) + + when: + service.storeCondaLock("someId", null) + + then: + 0 * persistenceService.saveCondaLock(_) + } + + def "should fetch conda lockfile" () { + given: + def persistenceService = Mock(PersistenceService) + def service = new CondaLockServiceImpl(persistenceService: persistenceService) + def record = new WaveCondaLockRecord("someId", "conda lock content".getBytes(StandardCharsets.UTF_8)) + persistenceService.loadCondaLock("someId") >> record + + when: + def condaLock = service.fetchCondaLock("someId") + + then: + condaLock.inputStream.text == "conda lock content" + } + + def "should return null when buildId is null" () { + given: + def persistenceService = Mock(PersistenceService) + def service = new CondaLockServiceImpl(persistenceService: persistenceService) + + when: + def condaLock = service.fetchCondaLock(null) + + then: + condaLock == null + } + + def 'should extract conda lockfile' () { + def logs = """ + #9 12.23 logs.... + #10 12.24 conda_lock_start + #10 12.24 # This file may be used to create an environment using: + #10 12.24 # \$ conda create --name --file + #10 12.24 # platform: linux-aarch64 + #10 12.24 @EXPLICIT + #10 12.25 conda_lock_end + #11 12.26 logs....""".stripIndent() + def service = new CondaLockServiceImpl() + + when: + def result = service.extractCondaLockFile(logs) + + then: + result == """ + # This file may be used to create an environment using: + # \$ conda create --name --file + # platform: linux-aarch64 + @EXPLICIT + """.stripIndent() + } +} diff --git a/src/test/groovy/io/seqera/wave/service/logs/BuildLogsServiceTest.groovy b/src/test/groovy/io/seqera/wave/service/logs/BuildLogsServiceTest.groovy index 6cbf7fb2c..b239753aa 100644 --- a/src/test/groovy/io/seqera/wave/service/logs/BuildLogsServiceTest.groovy +++ b/src/test/groovy/io/seqera/wave/service/logs/BuildLogsServiceTest.groovy @@ -21,6 +21,8 @@ package io.seqera.wave.service.logs import spock.lang.Specification import spock.lang.Unroll +import io.seqera.wave.service.conda.impl.CondaLockServiceImpl + /** * * @author Paolo Di Tommaso @@ -40,4 +42,25 @@ class BuildLogsServiceTest extends Specification { '/foo/bar/' | '123' | 'foo/bar/123.log' } + def 'should remove conda lockfile from logs' () { + def logs = """ + #9 12.23 logs.... + #10 12.24 conda_lock_start + #10 12.24 # This file may be used to create an environment using: + #10 12.24 # \$ conda create --name --file + #10 12.24 # platform: linux-aarch64 + #10 12.24 @EXPLICIT + #10 12.25 conda_lock_end + #11 12.26 logs....""".stripIndent() + def service = new BuildLogServiceImpl() + + when: + def result = service.removeCondaLockFile(logs) + + then: + result == """ + #9 12.23 logs.... + #11 12.26 logs....""".stripIndent() + } + } diff --git a/src/test/groovy/io/seqera/wave/service/persistence/impl/SurrealPersistenceServiceTest.groovy b/src/test/groovy/io/seqera/wave/service/persistence/impl/SurrealPersistenceServiceTest.groovy index af1f812b8..a54ba16aa 100644 --- a/src/test/groovy/io/seqera/wave/service/persistence/impl/SurrealPersistenceServiceTest.groovy +++ b/src/test/groovy/io/seqera/wave/service/persistence/impl/SurrealPersistenceServiceTest.groovy @@ -31,6 +31,9 @@ import io.seqera.wave.api.ContainerConfig import io.seqera.wave.api.ContainerLayer import io.seqera.wave.api.SubmitContainerTokenRequest import io.seqera.wave.core.ContainerDigestPair +import io.seqera.wave.service.builder.BuildFormat +import io.seqera.wave.service.persistence.WaveCondaLockRecord +import io.seqera.wave.service.scan.ScanVulnerability import io.seqera.wave.core.ContainerPlatform import io.seqera.wave.service.ContainerRequestData import io.seqera.wave.service.builder.BuildEvent @@ -316,6 +319,21 @@ class SurrealPersistenceServiceTest extends Specification implements SurrealDBTe result2 == scanRecord2 } + def 'should save and load conda lockfile' (){ + given: + def persistence = applicationContext.getBean(SurrealPersistenceService) + def lockfile = "some lockfile content" + def buildId = 'build1234' + def record = new WaveCondaLockRecord(buildId, lockfile.bytes) + + when: + persistence.saveCondaLock(record) + sleep 200 + def loaded = persistence.loadCondaLock(buildId) + then: + loaded == record + } + //== mirror records tests void "should save and load a mirror record by id"() { @@ -366,5 +384,4 @@ class SurrealPersistenceServiceTest extends Specification implements SurrealDBTe stored == result } - }