diff --git a/README.md b/README.md index da025233e..27558e7ee 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ images. * Augment container images i.e. dynamically add one or more container layers to existing images; * Build container images on-demand for a given container file (aka Dockerfile); * Build container images on-demand based on one or more Conda packages; -* Build container images on-demand based on one or more Spack packages; +* Build container images on-demand based on one or more Spack packages, Spack support will be removed in future releases; * Build container images for a specified target platform (currently linux/amd64 and linux/arm64); * Push and cache built containers to a user-provided container repository; * Build Singularity native containers both using a Singularity spec file, Conda package(s) and Spack package(s); diff --git a/VERSION b/VERSION index a7ee35a3e..f8e233b27 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.8.3 +1.9.0 diff --git a/build.gradle b/build.gradle index 4d3be2206..622c954b8 100644 --- a/build.gradle +++ b/build.gradle @@ -35,7 +35,7 @@ dependencies { implementation("jakarta.persistence:jakarta.persistence-api:3.0.0") api 'io.seqera:lib-mail:1.0.0' api 'io.seqera:wave-api:0.10.0' - api 'io.seqera:wave-utils:0.12.0' + api 'io.seqera:wave-utils:0.13.1' implementation("io.micronaut:micronaut-http-client") implementation("io.micronaut:micronaut-jackson-databind") diff --git a/changelog.txt b/changelog.txt index 6b0429062..65c3d6b10 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,4 +1,26 @@ # Wave changelog +1.9.1 - 11 Jul 2024 +- Prevent hard error when launch credentials cannot be accessed [a318a855] + +1.9.0 - 11 Jul 2024 +- Add Typespec API definitions (#537) [32f7dd16] +- Add cache record-stats (#534) [229926e2] +- Add http 429 error to auth service retry condition [8282a492] +- Check and delete corrupted blobs cache uploads (#533) [b0c775a3] +- Deprecate the support for Spack and remove the support for it in the codebase (#550) [85a05196] +- Enable ECR authentication via AWS compute env credentials (#303) [ec895222] +- Fix Blob cache config for local dev [0d5413bf] +- Fix client cache deadlock (#547) [cc6012ff] +- Fix multiple s3clients in wave (#554) [45689500] +- Minor change in mail notification [0aba6997] +- Refactored metrics service (#549) [5e0d32ac] +- Remove buildlogs aws config to not initialize AwsS3Operations (#558) [d50e5b7a] +- Update metrics response (#536) [b6b36a97] +- Bump buildkit 0.14.0 (#528) [c54172d1] +- Bump buildkit 0.14.1 (#548) [37afb782] +- Bump trivy 0.53.0 [f27ff527] +- Bump version 1.8.3 [55b473e1] + 1.8.3 - 29 Jun 2024 - Fix client cache deadlock (#547) [cc6012ff] - Fix failing test [263b44d3] diff --git a/configuration.md b/configuration.md index 359d93ae2..9c8daf16b 100644 --- a/configuration.md +++ b/configuration.md @@ -109,6 +109,8 @@ Below are the standard format for known registries, but you can change registry ### Spack configuration for wave build process +**Note**: Spack support will be removed in future releases. + Spack configuration consists of the path of its secret file, the mount path for the secret file in the spack container, and the optional S3 bucket name for the spack binary cache. **Note**: these configuration are mandatory to support Spack in a wave installation. diff --git a/docs/api.mdx b/docs/api.mdx index dfa5b453b..019a95b39 100644 --- a/docs/api.mdx +++ b/docs/api.mdx @@ -67,7 +67,7 @@ This API endpoint is deprecated in current versions of Wave. | `containerConfig.layers.gzipSize` | The size in bytes of the the provided layer tar gzip file. | | `containerFile` | Dockerfile used for building a new container encoded in base64 (optional). When provided, the attribute `containerImage` must be omitted. | | `condaFile` | Conda environment file encoded as base64 string. | -| `spackFile` | Spack recipe file encoded as base64 string. | +| `spackFile` | `Deprecated` Spack recipe file encoded as base64 string. Spack support will be removed in future releases. | | `containerPlatform` | Target container architecture of the built container, e.g., `linux/amd64` (optional). Currently only supporting amd64 and arm64. | | `buildRepository` | Container repository where container builds should be pushed, e.g., `docker.io/user/my-image` (optional). | | `cacheRepository` | Container repository used to cache build layers `docker.io/user/my-cache` (optional). | @@ -168,7 +168,7 @@ The endpoint returns the name of the container request made available by Wave. | `containerConfig.layers.gzipSize` | The size in bytes of the the provided layer tar gzip file. | | `containerFile` | Dockerfile used for building a new container encoded in base64 (optional). When provided, the attribute `containerImage` must be omitted. | | `condaFile` | Conda environment file encoded as base64 string. | -| `spackFile` | Spack recipe file encoded as base64 string. | +| `spackFile` | `Deprecated` Spack recipe file encoded as base64 string. Spack support will be removed in future releases. | | `containerPlatform` | Target container architecture of the built container, e.g., `linux/amd64` (optional). Currently only supporting amd64 and arm64. | | `buildRepository` | Container repository where container builds should be pushed, e.g., `docker.io/user/my-image` (optional). | | `cacheRepository` | Container repository used to cache build layers `docker.io/user/my-cache` (optional). | @@ -178,9 +178,9 @@ The endpoint returns the name of the container request made available by Wave. | `towerEndpoint` | Seqera Platform service endpoint from where container registry credentials are retrieved (optional). Default `https://api.cloud.seqera.io`. | | `towerAccessToken` | Access token of the user account granting access to the Seqera Platform service specified via `towerEndpoint` (optional). | | `towerWorkspaceId` | ID of the Seqera Platform workspace from where the container registry credentials are retrieved (optional). When omitted the personal workspace is used. | -| `packages` | This object specifies Conda or Spack packages environment information. | +| `packages` | This object specifies Conda packages environment information. | | `environment` | The package environment file encoded as a base64 string. | -| `type` | This represents the type of package builder. Use `SPACK` or `CONDA`. | +| `type` | This represents the type of package builder. Use `CONDA`. | | `entries` | List of the packages names. | | `channels` | List of Conda channels, which will be used to download packages. | | `mambaImage` | Name of the docker image used to build Conda containers. | diff --git a/docs/cli/build-spack.mdx b/docs/cli/build-spack.mdx index 11b434a8a..a11acbd9a 100644 --- a/docs/cli/build-spack.mdx +++ b/docs/cli/build-spack.mdx @@ -2,6 +2,8 @@ title: Build a container from Spack packages --- +**Note**: Spack support will be removed in future releases. + The Wave CLI supports building a container from a list of [Spack] packages. :::caution diff --git a/docs/index.mdx b/docs/index.mdx index 0b9fa81cb..25d7045ac 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -10,6 +10,10 @@ It allows for the on-demand assembly, augmentation, and deployment of containeri The Wave container service itself is not a container registry. All containers builds are stored in a Seqera-hosted image registry for a limited time or frozen to a user-specified container registry. +:::note +Wave is available for free as part of Seqera Cloud. As it is open source software, no support is provided by Seqera. For a supported, self-hosted, solution please [contact us](https://seqera.io/contact-us/). +::: + ## Features ### Private container registries @@ -26,7 +30,7 @@ Wave offers a flexible approach to container image management. It allows you to #### An example of Wave augmentation -Imagine you have a base Ubuntu image in a container registry. Wave acts as a proxy between your docker client and the registry. When you request an augmented image, Wave intercepts the process. +Imagine you have a base Ubuntu image in a container registry. Wave acts as a proxy between your Docker client and the registry. When you request an augmented image, Wave intercepts the process. 1. Base image layers download: The Docker client downloads the standard Ubuntu layers from the registry. 2. Custom layer injection: Wave injects your custom layer, denoted by "ω", which could represent application code, libraries, configurations etc. @@ -39,7 +43,7 @@ Imagine you have a base Ubuntu image in a container registry. Wave acts as a pro 1. Streamlined workflows: Wave simplifies your workflow by eliminating the need to manually build and manage custom images. 2. Flexibility: You can easily modify the custom layer for different use cases, allowing for greater adaptability. -### Conda based containers +### Conda-based containers Package management systems such as Conda and Bioconda simplify the installation of scientific software. However, there’s considerable friction when it comes to using those tools to deploy pipelines in cloud environments. Wave enables dynamic provisioning of container images from any Conda or Bioconda recipe. Just declare the Conda packages in your Nextflow pipeline and Wave will assemble the required container. diff --git a/src/main/groovy/io/seqera/wave/auth/RegistryAuthServiceImpl.groovy b/src/main/groovy/io/seqera/wave/auth/RegistryAuthServiceImpl.groovy index 76df7e83c..ec6c4cb28 100644 --- a/src/main/groovy/io/seqera/wave/auth/RegistryAuthServiceImpl.groovy +++ b/src/main/groovy/io/seqera/wave/auth/RegistryAuthServiceImpl.groovy @@ -39,7 +39,7 @@ import io.seqera.wave.util.StringUtils import jakarta.inject.Inject import jakarta.inject.Singleton import static io.seqera.wave.WaveDefault.DOCKER_IO -import static io.seqera.wave.WaveDefault.HTTP_SERVER_ERRORS +import static io.seqera.wave.WaveDefault.HTTP_RETRYABLE_ERRORS /** * Implement Docker authentication & login service * @@ -116,7 +116,7 @@ class RegistryAuthServiceImpl implements RegistryAuthService { // retry strategy final retryable = Retryable .>of(httpConfig) - .retryIf( (response) -> response.statusCode() in HTTP_SERVER_ERRORS) + .retryIf( (response) -> response.statusCode() in HTTP_RETRYABLE_ERRORS) .onRetry((event) -> log.warn("Unable to connect '$endpoint' - event: $event}")) // make the request final response = retryable.apply(()-> httpClient.send(request, HttpResponse.BodyHandlers.ofString())) @@ -203,7 +203,7 @@ class RegistryAuthServiceImpl implements RegistryAuthService { // retry strategy final retryable = Retryable .>of(httpConfig) - .retryIf( (response) -> ((HttpResponse)response).statusCode() in HTTP_SERVER_ERRORS ) + .retryIf( (response) -> ((HttpResponse)response).statusCode() in HTTP_RETRYABLE_ERRORS ) .onRetry((event) -> log.warn("Unable to connect '$login' - event: $event")) // submit http request final response = retryable.apply(()-> httpClient.send(req, HttpResponse.BodyHandlers.ofString())) diff --git a/src/main/groovy/io/seqera/wave/auth/RegistryCredentialsProvider.groovy b/src/main/groovy/io/seqera/wave/auth/RegistryCredentialsProvider.groovy index 325c9aa04..1bc6134e5 100644 --- a/src/main/groovy/io/seqera/wave/auth/RegistryCredentialsProvider.groovy +++ b/src/main/groovy/io/seqera/wave/auth/RegistryCredentialsProvider.groovy @@ -40,6 +40,15 @@ interface RegistryCredentialsProvider { */ RegistryCredentials getDefaultCredentials(String registry) + /** + * Provides the default credentials for the specified container + * + * @param container + * A container name e.g. docker.io/library/ubuntu. + * @return + * A {@link RegistryCredentials} object holding the credentials for the specified container or {@code null} + * if not credentials can be found + */ RegistryCredentials getDefaultCredentials(ContainerPath container) /** @@ -56,4 +65,21 @@ interface RegistryCredentialsProvider { */ RegistryCredentials getUserCredentials(ContainerPath container, PlatformId identity) + /** + * Provides the credentials for the specified container. When the platform identity is provider + * this is equivalent to #getUserCredentials. + * + * @param container + * A container name e.g. docker.io/library/ubuntu. + * @param identity + * The platform identity of the user submitting the request + * @return + * A {@link RegistryCredentials} object holding the credentials for the specified container or {@code null} + * if not credentials can be found + */ + default RegistryCredentials getCredentials(ContainerPath container, PlatformId identity) { + return !identity + ? getDefaultCredentials(container) + : getUserCredentials(container, identity) + } } diff --git a/src/main/groovy/io/seqera/wave/auth/RegistryLookupServiceImpl.groovy b/src/main/groovy/io/seqera/wave/auth/RegistryLookupServiceImpl.groovy index fde961428..db65227a1 100644 --- a/src/main/groovy/io/seqera/wave/auth/RegistryLookupServiceImpl.groovy +++ b/src/main/groovy/io/seqera/wave/auth/RegistryLookupServiceImpl.groovy @@ -34,7 +34,7 @@ import jakarta.inject.Inject import jakarta.inject.Singleton import static io.seqera.wave.WaveDefault.DOCKER_IO import static io.seqera.wave.WaveDefault.DOCKER_REGISTRY_1 -import static io.seqera.wave.WaveDefault.HTTP_SERVER_ERRORS +import static io.seqera.wave.WaveDefault.HTTP_RETRYABLE_ERRORS /** * Lookup service for container registry. The role of this component * is to registry the retrieve the registry authentication realm @@ -73,7 +73,7 @@ class RegistryLookupServiceImpl implements RegistryLookupService { // retry strategy final retryable = Retryable .>of(httpConfig) - .retryIf((response) -> response.statusCode() in HTTP_SERVER_ERRORS) + .retryIf((response) -> response.statusCode() in HTTP_RETRYABLE_ERRORS ) .onRetry((event) -> log.warn("Unable to connect '$endpoint' - event: $event")) // submit the request final response = retryable.apply(()-> httpClient.send(request, HttpResponse.BodyHandlers.ofString())) diff --git a/src/main/groovy/io/seqera/wave/configuration/BlobCacheConfig.groovy b/src/main/groovy/io/seqera/wave/configuration/BlobCacheConfig.groovy index 6730c489e..b61975eb0 100644 --- a/src/main/groovy/io/seqera/wave/configuration/BlobCacheConfig.groovy +++ b/src/main/groovy/io/seqera/wave/configuration/BlobCacheConfig.groovy @@ -90,6 +90,9 @@ class BlobCacheConfig { @Value('${wave.blobCache.backoffLimit:3}') Integer backoffLimit + @Value('${wave.blobCache.k8s.pod.delete.timeout:20s}') + Duration podDeleteTimeout + Map getEnvironment() { final result = new HashMap(10) if( storageRegion ) { diff --git a/src/main/groovy/io/seqera/wave/configuration/SpackConfig.groovy b/src/main/groovy/io/seqera/wave/configuration/SpackConfig.groovy index 092f22476..0b81d6299 100644 --- a/src/main/groovy/io/seqera/wave/configuration/SpackConfig.groovy +++ b/src/main/groovy/io/seqera/wave/configuration/SpackConfig.groovy @@ -35,6 +35,7 @@ import jakarta.inject.Singleton @EqualsAndHashCode @Singleton @CompileStatic +@Deprecated class SpackConfig { /** diff --git a/src/main/groovy/io/seqera/wave/core/RegistryProxyService.groovy b/src/main/groovy/io/seqera/wave/core/RegistryProxyService.groovy index da1f75db3..3ecd517c1 100644 --- a/src/main/groovy/io/seqera/wave/core/RegistryProxyService.groovy +++ b/src/main/groovy/io/seqera/wave/core/RegistryProxyService.groovy @@ -107,9 +107,7 @@ class RegistryProxyService { } protected RegistryCredentials getCredentials(RoutePath route) { - final result = !route.identity - ? credentialsProvider.getDefaultCredentials(route) - : credentialsProvider.getUserCredentials(route, route.identity) + final result = credentialsProvider.getCredentials(route, route.identity) log.debug "Credentials for route path=${route.targetContainer}; identity=${route.identity} => ${result}" return result } diff --git a/src/main/groovy/io/seqera/wave/service/ContainerRegistryKeys.groovy b/src/main/groovy/io/seqera/wave/service/ContainerRegistryKeys.groovy index 7e4429bab..b87da03df 100644 --- a/src/main/groovy/io/seqera/wave/service/ContainerRegistryKeys.groovy +++ b/src/main/groovy/io/seqera/wave/service/ContainerRegistryKeys.groovy @@ -20,6 +20,7 @@ package io.seqera.wave.service import groovy.json.JsonSlurper import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j import io.seqera.wave.util.StringUtils /** @@ -27,19 +28,45 @@ import io.seqera.wave.util.StringUtils * * @author Paolo Di Tommaso */ +@Slf4j @CompileStatic class ContainerRegistryKeys { + /** + * The registry user name + */ String userName + + /** + * The registry secret + */ String password + + /** + * The registry target host - NOTE: this can be null when the keys where obtained by AWS credentials record + */ String registry static ContainerRegistryKeys fromJson(String json) { final root = (Map) new JsonSlurper().parseText(json) - return new ContainerRegistryKeys(userName: root.userName, password: root.password, registry: root.registry) + // parse container registry credentials + if( root.discriminator == 'container-reg' ) { + return new ContainerRegistryKeys(userName: root.userName, password: root.password, registry: root.registry) + } + // Map AWS keys to registry username and password + if( root.discriminator == 'aws' ) { + // AWS keys can have also the `assumeRoleArn`, not clear yet how to handle it + // https://github.com/seqeralabs/platform/blob/64d12c6f3f399f26422a746c0d97cea6d8ddebbb/tower-enterprise/src/main/groovy/io/seqera/tower/domain/aws/AwsSecurityKeys.groovy#L39-L39 + if( root.assumeRoleArn ) { + log.warn "The use of AWS assumeRoleArn for container credentials is not supported - accessKey=${root.accessKey}; assumeRoleArn=${root.assumeRoleArn}" + return null + } + return new ContainerRegistryKeys(userName: root.accessKey, password: root.secretKey) + } + throw new IllegalArgumentException("Unsupported credentials key discriminator type: ${root.discriminator}") } @Override String toString() { - return "ContainerRegistryKeys[registry=$registry; userName=$userName; password=${StringUtils.redact(password)})]" + return "ContainerRegistryKeys[registry=${registry}; userName=${userName}; password=${StringUtils.redact(password)})]" } } diff --git a/src/main/groovy/io/seqera/wave/service/CredentialServiceImpl.groovy b/src/main/groovy/io/seqera/wave/service/CredentialServiceImpl.groovy index d0e0b9e7b..8af268b0a 100644 --- a/src/main/groovy/io/seqera/wave/service/CredentialServiceImpl.groovy +++ b/src/main/groovy/io/seqera/wave/service/CredentialServiceImpl.groovy @@ -22,9 +22,11 @@ import groovy.transform.CompileStatic import groovy.util.logging.Slf4j import io.seqera.tower.crypto.AsymmetricCipher import io.seqera.tower.crypto.EncryptedPacket +import io.seqera.wave.service.aws.AwsEcrService import io.seqera.wave.service.pairing.PairingService import io.seqera.wave.tower.PlatformId import io.seqera.wave.tower.auth.JwtAuth +import io.seqera.wave.tower.client.CredentialsDescription import io.seqera.wave.tower.client.TowerClient import jakarta.inject.Inject import jakarta.inject.Singleton @@ -76,11 +78,14 @@ class CredentialServiceImpl implements CredentialsService { // This cannot be implemented at the moment since, in tower, container registry // credentials are associated to the whole registry final matchingRegistryName = registryName ?: DOCKER_IO - final creds = all.find { + def creds = all.find { it.provider == 'container-reg' && (it.registry ?: DOCKER_IO) == matchingRegistryName } + if (!creds && identity.workflowId && AwsEcrService.isEcrHost(registryName) ) { + creds = findComputeCreds(identity) + } if (!creds) { - log.debug "No credentials matching criteria registryName=$registryName; userId=$identity.userId; workspaceId=$identity.workspaceId; endpoint=$identity.towerEndpoint" + log.debug "No credentials matching criteria registryName=$registryName; userId=$identity.userId; workspaceId=$identity.workspaceId; workflowId=${identity.workflowId}; endpoint=$identity.towerEndpoint" return null } @@ -93,6 +98,28 @@ class CredentialServiceImpl implements CredentialsService { return parsePayload(credentials) } + CredentialsDescription findComputeCreds(PlatformId identity) { + try { + return findComputeCreds0(identity) + } + catch (Exception e) { + log.error("Unable to retrieve Platform launch credentials for $identity - cause ${e.message}") + return null + } + } + + protected CredentialsDescription findComputeCreds0(PlatformId identity) { + final response = towerClient.describeWorkflowLaunch(identity.towerEndpoint, JwtAuth.of(identity), identity.workflowId) + if( !response ) + return null + final computeEnv = response.get()?.launch?.computeEnv + if( !computeEnv ) + return null + if( computeEnv.platform != 'aws-batch' ) + return null + return new CredentialsDescription(id: computeEnv.credentialsId, provider: 'aws') + } + protected String decryptCredentials(byte[] encodedKey, String payload) { final packet = EncryptedPacket.decode(payload) final cipher = AsymmetricCipher.getInstance() diff --git a/src/main/groovy/io/seqera/wave/service/aws/ObjectStorageOperationsFactory.groovy b/src/main/groovy/io/seqera/wave/service/aws/ObjectStorageOperationsFactory.groovy new file mode 100644 index 000000000..b0d649d16 --- /dev/null +++ b/src/main/groovy/io/seqera/wave/service/aws/ObjectStorageOperationsFactory.groovy @@ -0,0 +1,54 @@ +/* + * 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.aws + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j +import io.micronaut.context.annotation.Factory +import io.micronaut.context.annotation.Requires +import io.micronaut.context.annotation.Value +import io.micronaut.objectstorage.InputStreamMapper +import io.micronaut.objectstorage.ObjectStorageOperations +import io.micronaut.objectstorage.aws.AwsS3Configuration +import io.micronaut.objectstorage.aws.AwsS3Operations +import jakarta.inject.Named +import jakarta.inject.Singleton +import software.amazon.awssdk.services.s3.S3Client +/** + * Factory implementation for ObjectStorageOperations + * + * @author Munish Chouhan + */ +@Factory +@CompileStatic +@Slf4j +@Requires(property = 'wave.build.logs.bucket') +class ObjectStorageOperationsFactory { + + @Value('${wave.build.logs.bucket}') + String storageBucket + + @Singleton + @Named("build-logs") + ObjectStorageOperations awsStorageOperations(@Named("DefaultS3Client") S3Client s3Client, InputStreamMapper inputStreamMapper) { + AwsS3Configuration configuration = new AwsS3Configuration('build-logs') + configuration.setBucket(storageBucket) + return new AwsS3Operations(configuration, s3Client, inputStreamMapper) + } +} diff --git a/src/main/groovy/io/seqera/wave/service/blob/impl/S3ClientFactory.groovy b/src/main/groovy/io/seqera/wave/service/aws/S3ClientFactory.groovy similarity index 76% rename from src/main/groovy/io/seqera/wave/service/blob/impl/S3ClientFactory.groovy rename to src/main/groovy/io/seqera/wave/service/aws/S3ClientFactory.groovy index af14deba6..5327cb7d1 100644 --- a/src/main/groovy/io/seqera/wave/service/blob/impl/S3ClientFactory.groovy +++ b/src/main/groovy/io/seqera/wave/service/aws/S3ClientFactory.groovy @@ -16,17 +16,18 @@ * along with this program. If not, see . */ -package io.seqera.wave.service.blob.impl +package io.seqera.wave.service.aws import groovy.transform.CompileStatic import groovy.util.logging.Slf4j import io.micronaut.context.annotation.Factory import io.micronaut.context.annotation.Requires +import io.micronaut.context.annotation.Value import io.seqera.wave.configuration.BlobCacheConfig -import jakarta.inject.Inject import jakarta.inject.Named import jakarta.inject.Singleton import software.amazon.awssdk.auth.credentials.AwsBasicCredentials +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider import software.amazon.awssdk.regions.Region import software.amazon.awssdk.services.s3.S3Client @@ -38,15 +39,15 @@ import software.amazon.awssdk.services.s3.S3Client @Factory @CompileStatic @Slf4j -@Requires(property = 'wave.blobCache.enabled', value = 'true') class S3ClientFactory { - @Inject - private BlobCacheConfig blobConfig + @Value('${aws.region}') + private String awsRegion; @Singleton + @Requires(property = 'wave.blobCache.enabled', value = 'true') @Named('BlobS3Client') - S3Client cloudflareS3Client() { + S3Client cloudflareS3Client(BlobCacheConfig blobConfig) { final creds = AwsBasicCredentials.create(blobConfig.storageAccessKey, blobConfig.storageSecretKey) final builder = S3Client.builder() .region(Region.of(blobConfig.storageRegion)) @@ -59,4 +60,13 @@ class S3ClientFactory { log.info("Creating S3 client with configuration: $builder") return builder.build() } + + @Singleton + @Named('DefaultS3Client') + S3Client defaultS3Client() { + return S3Client.builder() + .region(Region.of(awsRegion)) + .credentialsProvider(DefaultCredentialsProvider.create()) + .build() + } } diff --git a/src/main/groovy/io/seqera/wave/service/blob/impl/KubeTransferStrategy.groovy b/src/main/groovy/io/seqera/wave/service/blob/impl/KubeTransferStrategy.groovy index a88fab5f8..258605bbc 100644 --- a/src/main/groovy/io/seqera/wave/service/blob/impl/KubeTransferStrategy.groovy +++ b/src/main/groovy/io/seqera/wave/service/blob/impl/KubeTransferStrategy.groovy @@ -36,7 +36,6 @@ import io.seqera.wave.service.k8s.K8sService import io.seqera.wave.util.K8sHelper import jakarta.inject.Inject import jakarta.inject.Named - /** * Implements {@link TransferStrategy} that runs s5cmd using a * Kubernetes job @@ -88,7 +87,6 @@ class KubeTransferStrategy implements TransferStrategy { if( cleanup.shouldCleanup(terminated.exitCode) && job.metadata?.name ) { CompletableFuture.supplyAsync (() -> k8sService.deleteJob(job.metadata.name), executor) } - return result } @@ -100,4 +98,18 @@ class KubeTransferStrategy implements TransferStrategy { .putUnencodedChars(info.creationTime.toString()) .hash() } + + private void cleanupPod(String podName, int exitCode) { + if( !cleanup.shouldCleanup(exitCode) ) { + return + } + + CompletableFuture.supplyAsync (() -> + k8sService.deletePodWhenReachStatus( + podName, + 'Succeeded', + blobConfig.podDeleteTimeout.toMillis()), + executor) + } + } diff --git a/src/main/groovy/io/seqera/wave/service/inspect/ContainerInspectServiceImpl.groovy b/src/main/groovy/io/seqera/wave/service/inspect/ContainerInspectServiceImpl.groovy index 862b3bc30..ddf868bcb 100644 --- a/src/main/groovy/io/seqera/wave/service/inspect/ContainerInspectServiceImpl.groovy +++ b/src/main/groovy/io/seqera/wave/service/inspect/ContainerInspectServiceImpl.groovy @@ -105,9 +105,7 @@ class ContainerInspectServiceImpl implements ContainerInspectService { // skip this index host because it has already be added to the list continue } - final creds = !identity - ? credentialsProvider.getDefaultCredentials(path) - : credentialsProvider.getUserCredentials(path, identity) + final creds = credentialsProvider.getCredentials(path, identity) log.debug "Build credentials for repository: $repo => $creds" if( !creds ) { // skip this host because there are no credentials @@ -177,9 +175,7 @@ class ContainerInspectServiceImpl implements ContainerInspectService { else if( item instanceof InspectRepository ) { final path = ContainerCoordinates.parse(item.getImage()) - final creds = !identity - ? credentialsProvider.getDefaultCredentials(path) - : credentialsProvider.getUserCredentials(path, identity) + final creds = credentialsProvider.getCredentials(path, identity) log.debug "Config credentials for repository: ${item.getImage()} => $creds" final entry = fetchConfig0(path, creds).config?.entrypoint @@ -219,9 +215,7 @@ class ContainerInspectServiceImpl implements ContainerInspectService { ContainerSpec containerSpec(String containerImage, PlatformId identity) { final path = ContainerCoordinates.parse(containerImage) - final creds = !identity - ? credentialsProvider.getDefaultCredentials(path) - : credentialsProvider.getUserCredentials(path, identity) + final creds = credentialsProvider.getCredentials(path, identity) log.debug "Inspect credentials for repository: ${containerImage} => $creds" final client = client0(path, creds) diff --git a/src/main/groovy/io/seqera/wave/service/k8s/K8sService.groovy b/src/main/groovy/io/seqera/wave/service/k8s/K8sService.groovy index 6fcbf6dca..dbe8a6343 100644 --- a/src/main/groovy/io/seqera/wave/service/k8s/K8sService.groovy +++ b/src/main/groovy/io/seqera/wave/service/k8s/K8sService.groovy @@ -66,4 +66,6 @@ interface K8sService { V1PodList waitJob(V1Job job, Long timeout) + void deletePodWhenReachStatus(String podName, String statusName, long timeout) + } diff --git a/src/main/groovy/io/seqera/wave/service/k8s/K8sServiceImpl.groovy b/src/main/groovy/io/seqera/wave/service/k8s/K8sServiceImpl.groovy index dea880dbc..85b3c85eb 100644 --- a/src/main/groovy/io/seqera/wave/service/k8s/K8sServiceImpl.groovy +++ b/src/main/groovy/io/seqera/wave/service/k8s/K8sServiceImpl.groovy @@ -515,6 +515,26 @@ class K8sServiceImpl implements K8sService { .deleteNamespacedPod(name, namespace, (String)null, (String)null, (Integer)null, (Boolean)null, (String)null, (V1DeleteOptions)null) } + /** + * Delete a pod where the status is reached + * + * @param name The name of the pod to be deleted + * @param statusName The status to be reached + * @param timeout The max wait time in milliseconds + */ + @Override + void deletePodWhenReachStatus(String podName, String statusName, long timeout){ + final pod = getPod(podName) + final start = System.currentTimeMillis() + while( (System.currentTimeMillis() - start) < timeout ) { + if( pod?.status?.phase == statusName ) { + deletePod(podName) + return + } + sleep 5_000 + } + } + @Override V1Pod scanContainer(String name, String containerImage, List args, Path workDir, Path creds, ScanConfig scanConfig, Map nodeSelector) { final spec = scanSpec(name, containerImage, args, workDir, creds, scanConfig, nodeSelector) diff --git a/src/main/groovy/io/seqera/wave/tower/PlatformId.groovy b/src/main/groovy/io/seqera/wave/tower/PlatformId.groovy index e5812f500..79af8ea13 100644 --- a/src/main/groovy/io/seqera/wave/tower/PlatformId.groovy +++ b/src/main/groovy/io/seqera/wave/tower/PlatformId.groovy @@ -39,9 +39,10 @@ class PlatformId { final Long workspaceId final String accessToken final String towerEndpoint + final String workflowId boolean asBoolean() { - user!=null || workspaceId!=null || accessToken || towerEndpoint + user!=null || workspaceId!=null || accessToken || towerEndpoint || workflowId } Long getUserId() { @@ -53,7 +54,8 @@ class PlatformId { user, request.towerWorkspaceId, request.towerAccessToken, - request.towerEndpoint ) + request.towerEndpoint, + request.workflowId) } static PlatformId of(User user, ContainerInspectRequest request) { @@ -71,6 +73,7 @@ class PlatformId { ", workspaceId=" + workspaceId + ", accessToken=" + StringUtils.trunc(accessToken,25) + ", towerEndpoint=" + towerEndpoint + + ", workflowId=" + workflowId + ')'; } } diff --git a/src/main/groovy/io/seqera/wave/tower/client/TowerClient.groovy b/src/main/groovy/io/seqera/wave/tower/client/TowerClient.groovy index 354115424..f0aa79654 100644 --- a/src/main/groovy/io/seqera/wave/tower/client/TowerClient.groovy +++ b/src/main/groovy/io/seqera/wave/tower/client/TowerClient.groovy @@ -25,9 +25,11 @@ import io.micronaut.cache.annotation.Cacheable import io.micronaut.core.annotation.Nullable import io.seqera.wave.tower.auth.JwtAuth import io.seqera.wave.tower.client.connector.TowerConnector +import io.seqera.wave.tower.compute.DescribeWorkflowLaunchResponse import jakarta.inject.Inject import jakarta.inject.Singleton import org.apache.commons.lang3.StringUtils + /** * Implement a client to interact with Tower services * @@ -112,4 +114,14 @@ class TowerClient { StringUtils.removeEnd(endpoint, "/") } + @Cacheable(value = 'cache-tower-client', atomic = true) + CompletableFuture describeWorkflowLaunch(String towerEndpoint, JwtAuth authorization, String workflowId) { + final uri = workflowLaunchEndpoint(towerEndpoint,workflowId) + return getAsync(uri, towerEndpoint, authorization, DescribeWorkflowLaunchResponse.class) + } + + protected static URI workflowLaunchEndpoint(String towerEndpoint, String workflowId) { + return URI.create("${checkEndpoint(towerEndpoint)}/workflow/${workflowId}/launch") + } + } diff --git a/src/main/groovy/io/seqera/wave/tower/compute/ComputeEnv.groovy b/src/main/groovy/io/seqera/wave/tower/compute/ComputeEnv.groovy new file mode 100644 index 000000000..dc2c81d7a --- /dev/null +++ b/src/main/groovy/io/seqera/wave/tower/compute/ComputeEnv.groovy @@ -0,0 +1,34 @@ +/* + * 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.tower.compute + +import groovy.transform.CompileStatic + +/** + * Model the response of compute environment from seqera platform + * + * @author Munish Chouhan + */ +@CompileStatic +class ComputeEnv { + String id + String platform + String credentialsId +} + diff --git a/src/main/groovy/io/seqera/wave/tower/compute/DescribeWorkflowLaunchResponse.groovy b/src/main/groovy/io/seqera/wave/tower/compute/DescribeWorkflowLaunchResponse.groovy new file mode 100644 index 000000000..1859796ad --- /dev/null +++ b/src/main/groovy/io/seqera/wave/tower/compute/DescribeWorkflowLaunchResponse.groovy @@ -0,0 +1,37 @@ +/* + * Wave, containers provisioning service + * Copyright (c) 2023, 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.tower.compute + +import groovy.transform.CompileStatic +/** + * Model the response of workflow launch describe request + * + * @author Munish Chouhan + */ +@CompileStatic +class DescribeWorkflowLaunchResponse { + + WorkflowLaunchResponse launch + + DescribeWorkflowLaunchResponse() {} + + DescribeWorkflowLaunchResponse(WorkflowLaunchResponse launch) { + this.launch = launch + } +} diff --git a/src/main/groovy/io/seqera/wave/tower/compute/WorkflowLaunchResponse.groovy b/src/main/groovy/io/seqera/wave/tower/compute/WorkflowLaunchResponse.groovy new file mode 100644 index 000000000..a271b0b89 --- /dev/null +++ b/src/main/groovy/io/seqera/wave/tower/compute/WorkflowLaunchResponse.groovy @@ -0,0 +1,31 @@ +/* + * 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.tower.compute + +import groovy.transform.CompileStatic + +/** + * Model the response of workflow launch response from seqera platform + * + * @author Munish Chouhan + */ +@CompileStatic +class WorkflowLaunchResponse { + ComputeEnv computeEnv +} diff --git a/src/main/groovy/io/seqera/wave/util/SpackHelper.groovy b/src/main/groovy/io/seqera/wave/util/SpackHelper.groovy index bc1e23724..82a324a98 100644 --- a/src/main/groovy/io/seqera/wave/util/SpackHelper.groovy +++ b/src/main/groovy/io/seqera/wave/util/SpackHelper.groovy @@ -29,6 +29,7 @@ import io.seqera.wave.service.builder.BuildFormat * @author Paolo Di Tommaso */ @CompileStatic +@Deprecated class SpackHelper { static String builderDockerTemplate() { diff --git a/src/main/resources/application-buildlogs-aws-test.yml b/src/main/resources/application-buildlogs-aws-test.yml index e93358e3d..b147c2ea1 100644 --- a/src/main/resources/application-buildlogs-aws-test.yml +++ b/src/main/resources/application-buildlogs-aws-test.yml @@ -1,10 +1,4 @@ --- -micronaut: - object-storage: - aws: - build-logs: - bucket: "${wave.build.logs.bucket}" ---- wave: build: logs: diff --git a/src/main/resources/application-buildlogs-aws.yml b/src/main/resources/application-buildlogs-aws.yml deleted file mode 100644 index 767e1cddc..000000000 --- a/src/main/resources/application-buildlogs-aws.yml +++ /dev/null @@ -1,7 +0,0 @@ ---- -micronaut: - object-storage: - aws: - build-logs: - bucket: "${wave.build.logs.bucket}" -... diff --git a/src/main/resources/application-buildlogs-local.yml b/src/main/resources/application-buildlogs-local.yml index f6d7a6e2f..53ac81a8c 100644 --- a/src/main/resources/application-buildlogs-local.yml +++ b/src/main/resources/application-buildlogs-local.yml @@ -4,13 +4,4 @@ wave: logs: bucket: "$PWD/build-workspace" prefix: 'wave-build/logs' ---- -# unfortunately "local" object storage requires min Java 17 -# keeping this only for reference -micronaut: - object-storage: - local: - build-logs: - enabled: true - path: "${wave.build.logs.bucket}" ... diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ce1eb9933..7c795d5f5 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -83,7 +83,7 @@ wave: multiplier: '1.75' scan: image: - name: aquasec/trivy:0.50.1 + name: aquasec/trivy:0.53.0 blobCache: s5cmdImage: cr.seqera.io/public/wave/s5cmd:v2.2.2 --- diff --git a/src/test/groovy/io/seqera/wave/auth/RegistryCredentialsProviderTest.groovy b/src/test/groovy/io/seqera/wave/auth/RegistryCredentialsProviderTest.groovy index ca38eff6c..2c34a9ce8 100644 --- a/src/test/groovy/io/seqera/wave/auth/RegistryCredentialsProviderTest.groovy +++ b/src/test/groovy/io/seqera/wave/auth/RegistryCredentialsProviderTest.groovy @@ -106,6 +106,7 @@ class RegistryCredentialsProviderTest extends Specification { def WORKSPACE_ID = 200 def TOWER_TOKEN = "token" def TOWER_ENDPOINT = "localhost:8080" + def WORKFLOW_ID = "id123" and: def credentialService = Mock(CredentialsService) def credentialsFactory = new RegistryCredentialsFactoryImpl(awsEcrService: Mock(AwsEcrService)) diff --git a/src/test/groovy/io/seqera/wave/service/CredentialsServiceTest.groovy b/src/test/groovy/io/seqera/wave/service/CredentialsServiceTest.groovy index e55df4766..a2babb87d 100644 --- a/src/test/groovy/io/seqera/wave/service/CredentialsServiceTest.groovy +++ b/src/test/groovy/io/seqera/wave/service/CredentialsServiceTest.groovy @@ -18,7 +18,6 @@ package io.seqera.wave.service - import spock.lang.Specification import java.security.PublicKey @@ -38,6 +37,9 @@ import io.seqera.wave.tower.client.CredentialsDescription import io.seqera.wave.tower.client.GetCredentialsKeysResponse import io.seqera.wave.tower.client.ListCredentialsResponse import io.seqera.wave.tower.client.TowerClient +import io.seqera.wave.tower.compute.ComputeEnv +import io.seqera.wave.tower.compute.DescribeWorkflowLaunchResponse +import io.seqera.wave.tower.compute.WorkflowLaunchResponse import jakarta.inject.Inject /** @@ -79,7 +81,7 @@ class CredentialsServiceTest extends Specification { and: 'registry credentials to access a registry stored in tower' def credentialsId = 'credentialsId' - def registryCredentials = '{"userName":"me", "password": "you", "registry": "quay.io"}' + def registryCredentials = '{"userName":"me", "password": "you", "registry": "quay.io", "discriminator":"container-reg"}' def credentialsDescription = new CredentialsDescription( id: credentialsId, provider: 'container-reg', @@ -167,7 +169,7 @@ class CredentialsServiceTest extends Specification { registry: 'docker.io' ) and: - def identity = new PlatformId(new User(id:10), 10,"token",'tower.io') + def identity = new PlatformId(new User(id:10), 10,"token",'tower.io', '101') def auth = JwtAuth.of(identity) when: @@ -187,26 +189,109 @@ class CredentialsServiceTest extends Specification { credentials: [nonContainerRegistryCredentials,otherRegistryCredentials] )) + and:'no compute credentials' + 0 * towerClient.describeWorkflowLaunch('tower.io',auth,'101') >> null + then: credentials == null } - def 'should parse credentials payload' () { given: def svc = new CredentialServiceImpl() when: - def keys = svc.parsePayload('{"registry":"foo.io", "userName":"me", "password": "you"}') + def keys = svc.parsePayload('{"registry":"foo.io", "userName":"me", "password": "you", "discriminator":"container-reg"}') then: keys.registry == 'foo.io' keys.userName == 'me' keys.password == 'you' } + def 'should parse aws keys payload' () { + given: + def svc = new CredentialServiceImpl() + + when: + def keys = svc.parsePayload('{"accessKey":"12345", "secretKey": "67890","discriminator":"aws"}') + then: + keys.userName == '12345' + keys.password == '67890' + keys.registry == null + } + + def 'should get registry creds from compute creds when not found in tower credentials'() { + given: 'a tower user in a workspace on a specific instance with a valid token' + def userId = 10 + def workspaceId = 10 + def token = "valid-token" + def towerEndpoint = "http://tower.io:9090" + def workflowId = "id123" + def registryName = '1000000.dkr.ecr.eu-west-1.amazonaws.com' + + and: 'a previously registered key' + def keypair = TEST_CIPHER.generateKeyPair() + def keyId = 'generated-key-id' + def keyRecord = new PairingRecord( + service: PairingService.TOWER_SERVICE, + endpoint: towerEndpoint, + pairingId: keyId, + privateKey: keypair.getPrivate().getEncoded(), + expiration: (Instant.now() + Duration.ofSeconds(10)) ) + + + and: 'registry credentials to access a registry stored in tower' + def credentialsId = 'credentialsId' + and: 'other credentials registered by the user' + def nonContainerRegistryCredentials = new CredentialsDescription( + id: 'alt-creds', + provider: 'azure', + registry: null ) + and: 'workflow launch info' + def computeEnv = new ComputeEnv( + id: 'computeId', + credentialsId: credentialsId, + platform: 'aws-batch' + ) + def launch = new WorkflowLaunchResponse( + computeEnv: computeEnv + ) + def describeWorkflowLaunchResponse = new DescribeWorkflowLaunchResponse( + launch: launch + ) + and: 'compute credentials' + def computeCredentials = '{"accessKey":"me", "secretKey": "you", "discriminator":"aws"}' + and: + def identity = new PlatformId(new User(id:userId), workspaceId,token,towerEndpoint,workflowId) + def auth = JwtAuth.of(identity) + + when: 'look those registry credentials from tower' + def containerCredentials = credentialsService.findRegistryCreds(registryName,identity) + + then: 'the registered key is fetched correctly from the security service' + 1 * securityService.getPairingRecord(PairingService.TOWER_SERVICE, towerEndpoint) >> keyRecord + + and: 'credentials are listed once and return a potential match' + 1 * towerClient.listCredentials(towerEndpoint,auth,workspaceId) >> CompletableFuture.completedFuture(new ListCredentialsResponse( + credentials: [nonContainerRegistryCredentials])) + + and:'fetched compute credentials' + 1*towerClient.describeWorkflowLaunch(towerEndpoint, auth, workflowId) >> CompletableFuture.completedFuture(describeWorkflowLaunchResponse) + + and: 'they match and the encrypted credentials are fetched' + 1 * towerClient.fetchEncryptedCredentials(towerEndpoint, auth, credentialsId, keyId, workspaceId) >> CompletableFuture.completedFuture( + encryptedCredentialsFromTower(keypair.getPublic(), computeCredentials)) + + and: + containerCredentials.userName == 'me' + containerCredentials.password == "you" + noExceptionThrown() + } private static GetCredentialsKeysResponse encryptedCredentialsFromTower(PublicKey key, String credentials) { - return new GetCredentialsKeysResponse(keys: TEST_CIPHER.encrypt(key,credentials.getBytes()).encode()) + if( credentials ) + return new GetCredentialsKeysResponse(keys: TEST_CIPHER.encrypt(key,credentials.getBytes()).encode()) + return null } } diff --git a/src/test/groovy/io/seqera/wave/service/blob/impl/KubeTransferStrategyTest.groovy b/src/test/groovy/io/seqera/wave/service/blob/impl/KubeTransferStrategyTest.groovy index 1c132e53f..be77b3ac7 100644 --- a/src/test/groovy/io/seqera/wave/service/blob/impl/KubeTransferStrategyTest.groovy +++ b/src/test/groovy/io/seqera/wave/service/blob/impl/KubeTransferStrategyTest.groovy @@ -29,6 +29,12 @@ import io.kubernetes.client.openapi.models.V1Pod import io.kubernetes.client.openapi.models.V1PodList import io.kubernetes.client.openapi.models.V1PodStatus import io.seqera.wave.configuration.BlobCacheConfig +import java.util.concurrent.Executors +import io.kubernetes.client.openapi.models.V1ContainerStateTerminated +import io.kubernetes.client.openapi.models.V1Pod +import io.kubernetes.client.openapi.models.V1PodStatus +import io.seqera.wave.configuration.BlobCacheConfig +import io.seqera.wave.configuration.BuildConfig import io.seqera.wave.service.blob.BlobCacheInfo import io.seqera.wave.service.cleanup.CleanupStrategy import io.seqera.wave.service.k8s.K8sService @@ -38,7 +44,49 @@ import io.seqera.wave.service.k8s.K8sService */ class KubeTransferStrategyTest extends Specification { - def "transfer should return completed info when job is terminated"() { + K8sService k8sService = Mock(K8sService) + BlobCacheConfig blobConfig = new BlobCacheConfig(s5Image: 's5cmd', transferTimeout: Duration.ofSeconds(10)) + CleanupStrategy cleanup = new CleanupStrategy(buildConfig: new BuildConfig(cleanup: "OnSuccess")) + KubeTransferStrategy strategy = new KubeTransferStrategy(k8sService: k8sService, blobConfig: blobConfig, cleanup: cleanup, executor: Executors.newSingleThreadExecutor()) + + def "transfer should complete successfully with valid inputs"() { + given: + def uri = "s3://bucket/file.txt" + def info = BlobCacheInfo.create(uri, null, null) + def command = ["s5cmd", "cp", uri, "/local/path"] + k8sService.transferContainer(_, blobConfig.s5Image, command, blobConfig) >> new V1Pod(status: new V1PodStatus(phase: 'Succeeded')) + k8sService.getPod(_) >> new V1Pod(status: new V1PodStatus(phase: 'Succeeded')) + k8sService.waitPod(_, _) >> new V1ContainerStateTerminated(exitCode: 0) + k8sService.logsPod(_) >> "Transfer completed" + + when: + def result = strategy.transfer(info, command) + + then: + result.succeeded() + result.exitStatus == 0 + result.logs == "Transfer completed" + result.done() + } + + def "transfer should fail when pod execution exceeds timeout"() { + given: + def uri = "s3://bucket/file.txt" + def info = BlobCacheInfo.create(uri, null, null) + def command = ["s5cmd", "cp", uri, "/local/path"] + k8sService.transferContainer(_, blobConfig.s5Image, command, blobConfig) >> new V1Pod(status: new V1PodStatus(phase: 'Running')) + k8sService.waitPod(_, blobConfig.transferTimeout.toMillis()) >> new V1ContainerStateTerminated(exitCode: 1) + k8sService.logsPod(_) >> "Transfer timeout" + + when: + def result = strategy.transfer(info, command) + + then: + result.failed("Transfer timeout") + result.logs == "Transfer timeout" + } + + def "transfer should return completed info when job is terminated"() { given: def config = Mock(BlobCacheConfig) { getTransferTimeout() >> Duration.ofSeconds(20) diff --git a/src/test/groovy/io/seqera/wave/service/k8s/K8sServiceImplTest.groovy b/src/test/groovy/io/seqera/wave/service/k8s/K8sServiceImplTest.groovy index 51040b6c4..63e167ae9 100644 --- a/src/test/groovy/io/seqera/wave/service/k8s/K8sServiceImplTest.groovy +++ b/src/test/groovy/io/seqera/wave/service/k8s/K8sServiceImplTest.groovy @@ -24,7 +24,11 @@ import java.nio.file.Path import java.time.Duration import io.kubernetes.client.custom.Quantity +import io.kubernetes.client.openapi.ApiClient +import io.kubernetes.client.openapi.apis.CoreV1Api import io.kubernetes.client.openapi.models.V1EnvVar +import io.kubernetes.client.openapi.models.V1Pod +import io.kubernetes.client.openapi.models.V1PodStatus import io.micronaut.context.ApplicationContext import io.micronaut.test.extensions.spock.annotation.MicronautTest import io.seqera.wave.configuration.BlobCacheConfig @@ -650,4 +654,57 @@ class K8sServiceImplTest extends Specification { cleanup: ctx.close() } + + def "deletePodWhenReachStatus should delete pod when status is reached within timeout"() { + given: + def podName = "test-pod" + def statusName = "Succeeded" + def timeout = 5000 + def api = Mock(CoreV1Api) + api.readNamespacedPod(_,_,_) >> new V1Pod(status: new V1PodStatus(phase: statusName)) + def k8sClient = new K8sClient() { + @Override + ApiClient apiClient() { + return null + } + CoreV1Api coreV1Api() { + return api + } + } + + def k8sService = new K8sServiceImpl(k8sClient: k8sClient) + + when: + k8sService.deletePodWhenReachStatus(podName, statusName, timeout) + + then: + 1 * api.deleteNamespacedPod('test-pod', null, null, null, null, null, null, null) + } + + def "deletePodWhenReachStatus should not delete pod if status is not reached within timeout"() { + given: + def podName = "test-pod" + def statusName = "Succeeded" + def timeout = 5000 + def api = Mock(CoreV1Api) + api.readNamespacedPod(_,_,_) >> new V1Pod(status: new V1PodStatus(phase: "Running")) + def k8sClient = new K8sClient() { + @Override + ApiClient apiClient() { + return null + } + CoreV1Api coreV1Api() { + return api + } + } + + def k8sService = new K8sServiceImpl(k8sClient: k8sClient) + + when: + k8sService.deletePodWhenReachStatus(podName, statusName, timeout) + + then: + 0 * api.deleteNamespacedPod('test-pod', null, null, null, null, null, null, null) + } + } diff --git a/typespec/models/SpackOpts.tsp b/typespec/models/SpackOpts.tsp index cc7a9ea2e..813caa6a2 100644 --- a/typespec/models/SpackOpts.tsp +++ b/typespec/models/SpackOpts.tsp @@ -1,5 +1,5 @@ -@doc("Options for Spack environments.") +@doc("Options for Spack environments. Spack support will be removed in future releases") model SpackOpts { basePackages: string; commands: string[]; -} \ No newline at end of file +}