diff --git a/.codespellignore b/.codespellignore index a90703856..ab316676a 100644 --- a/.codespellignore +++ b/.codespellignore @@ -1 +1,2 @@ carrer +ser diff --git a/.github/workflows/typespec.yml b/.github/workflows/typespec.yml new file mode 100644 index 000000000..8d9adaa33 --- /dev/null +++ b/.github/workflows/typespec.yml @@ -0,0 +1,40 @@ +--- +name: Typespec_Validation + +on: + push: + branches: + - '**' + paths : + - 'typespec/**' + pull_request: + types: [opened, reopened, synchronize] + paths: + - 'typespec/**' + +permissions: + contents: read + +jobs: + typespec_validation: + name: validate typespec files + runs-on: ubuntu-latest + + steps: + - name : Checkout + uses : actions/checkout@v4 + + - name : Setup Node.js environment + uses : actions/setup-node@v4 + with : + node-version : '20.9.0' + + - name : Install tsp + run : npm install -g @typespec/compiler + + - name : Validate tsp files + run : | + cd typespec + tsp install + tsp compile . + diff --git a/.gitignore b/.gitignore index e44a57f7f..41b2ff645 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,8 @@ scan-workspace/ .cache site/ deployment-url.txt + +#typespec +tsp-output/ +node_modules/ +package-lock.json diff --git a/README.md b/README.md index 070ea0d4a..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); @@ -108,6 +108,17 @@ container registry where the image is stored, while the instrumented layers are '-Djdk.httpclient.HttpClient.log=requests,headers' ``` +## TypeSpec API Specifications + +- You can find the API specifications using (typespec)[https://github.com/microsoft/typespec] in typespec directory. Use following command to generate the API specifications. + + ```bash + 'cd typespec' + 'tsp install' + 'tsp compile .' + ``` + +- Check `typespec/tsp-output` directory for the generated API specifications. ## Related links * [Wave command line tool](https://github.com/seqeralabs/wave-cli) diff --git a/VERSION b/VERSION index 27f9cd322..f8e233b27 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.8.0 +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 298e484a5..65c3d6b10 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,4 +1,38 @@ # 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] +- Add cache record-stats (#534) [229926e2] + +1.8.2 - 14 Jun 2024 +- Fix get image auth digest (#530) [df8ec041] + +1.8.1 - 13 Jun 2024 +- Fix digest warning (#526) [6e34ff0a] +- Fix image name strategy (#513) [3bd8ffb5] + 1.8.0 - 10 Jun 2024 - Add breakdown per org on stats metrics when a date is specified (#517) [b24894a4] - Replace Kaniko with Buildkit (#503) [a26f5dfe] 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/_images/wave_container_build_failure_details.png b/docs/_images/wave_container_build_failure_details.png new file mode 100644 index 000000000..be367f62b Binary files /dev/null and b/docs/_images/wave_container_build_failure_details.png differ diff --git a/docs/api.mdx b/docs/api.mdx index cc8f5a475..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. | @@ -285,13 +285,11 @@ Provides status of build against buildId passed as path variable ```json { - serviceInfo: { - id: string, - status: string, - startTime: string, - duration: string, - succeeded: boolean - } + id: string, + status: string, + startTime: string, + duration: string, + succeeded: boolean } ``` @@ -508,7 +506,7 @@ curl --location 'http://localhost:9090/v1alpha1/inspect' \ These APIs provide usage (builds and pulls) metrics of Wave for a specific date and/or a specific organization. These APIs require basic authentication, so you must provide a username and password while calling these APIs. -All Metrics API endpoints use these query parameters (at least one is required): +All Metrics API endpoints use these query parameters: | Name | Description | sample Value | |------|-----------------------------------------------------------------------|--------------| @@ -521,18 +519,36 @@ These APIs are used to retrieve metrics about container builds performed by Wave ### GET `/v1alpha2/metrics/builds` -This endpoint is used to retrieve the total number of builds performed by Wave. +This endpoint is used to retrieve the builds performed by Wave. ### Response ```json { - count: integer + metric: "builds", + count: integer, + orgs: { + String: integer, + String: integer, + ... + } } ``` #### Examples +```shell +curl -u foo:bar "http://localhost:9090/v1alpha2/metrics/builds" +{ + "metric": "builds", + "count": 18, + "orgs": { + "seqera.io": 13, + "gmail.com": 5 + } +} +``` + ```shell curl -u foo:bar "http://localhost:9090/v1alpha2/metrics/builds?date=2024-04-08&org=seqera.io" {"count":4} @@ -540,12 +556,25 @@ curl -u foo:bar "http://localhost:9090/v1alpha2/metrics/builds?date=2024-04-08&o ```shell curl -u foo:bar "http://localhost:9090/v1alpha2/metrics/builds?date=2024-04-08" -{"count":6} +{ + "metric": "builds", + "count": 8, + "orgs": { + "gmail.com": 4, + "seqera.io": 4 + } +} ``` ```shell curl -u foo:bar "http://localhost:9090/v1alpha2/metrics/builds?org=seqera.io" -{"count":4} +{ + "metric": "builds", + "count": 13, + "orgs": { + "seqera.io": 13 + } +} ``` ### Pull Metrics API @@ -553,18 +582,36 @@ These APIs are used to get the metrics about the container pulls through Wave. ### GET `/v1alpha2/metrics/pulls` -This endpoint is used to get the total numbers of pulls performed through Wave. +This endpoint is used to get the pulls performed through Wave. ### Response ```json { - count: integer + metric: "pulls", + count: integer, + orgs: { + String: integer, + String: integer, + ... + } } ``` #### Examples +```shell +curl -u foo:bar "http://localhost:9090/v1alpha2/metrics/pulls" +{ + "metric": "pulls", + "count": 11, + "orgs": { + "seqera.io": 7, + "gmail.com": 4 + } +} +``` + ```shell curl -u foo:bar "http://localhost:9090/v1alpha2/metrics/pulls?date=2024-04-08&org=seqera.io" {"count":5} @@ -572,12 +619,25 @@ curl -u foo:bar "http://localhost:9090/v1alpha2/metrics/pulls?date=2024-04-08&or ```shell curl -u foo:bar "http://localhost:9090/v1alpha2/metrics/pulls?date=2024-04-08" -{"count":7} +{ + "metric": "pulls", + "count": 4, + "orgs": { + "seqera.io": 1, + "gmail.com": 3 + } +} ``` ```shell curl -u foo:bar "http://localhost:9090/v1alpha2/metrics/pulls?org=seqera.io" -{"count":5} +{ + "metric": "pulls", + "count": 7, + "orgs": { + "seqera.io": 7 + } +} ``` ### Fusion Pull Metrics API @@ -586,18 +646,36 @@ These APIs are used to get the metrics about the Fusion-based container pulls th ### GET `/v1alpha2/metrics/fusion/pulls` -This endpoint is used to get the total numbers of pulls of Fusion-based containers performed through Wave. +This endpoint is used to get the pulls of Fusion-based containers performed through Wave. ### Response ```json { - count: integer + metric: "fusion", + count: integer, + orgs: { + String: integer, + String: integer, + ... + } } ``` #### Examples +```shell +curl -u foo:bar "http://localhost:9090/v1alpha2/metrics/fusion/pulls" +{ + "metric": "fusion", + "count": 2, + "orgs": { + "seqera.io": 1, + "gmail.com": 1 + } +} +``` + ```shell curl -u foo:bar "http://localhost:9090/v1alpha2/metrics/fusion/pulls?date=2024-04-08&org=seqera.io" {"count":2} @@ -605,10 +683,22 @@ curl -u foo:bar "http://localhost:9090/v1alpha2/metrics/fusion/pulls?date=2024-0 ```shell curl -u foo:bar "http://localhost:9090/v1alpha2/metrics/fusion/pulls?date=2024-04-08" -{"count":2}% +{ + "metric": "fusion", + "count": 1, + "orgs": { + "gmail.com": 1 + } +} ``` ```shell curl -u foo:bar "http://localhost:9090/v1alpha2/metrics/fusion/pulls?org=seqera.io" -{"count":2} +{ + "metric": "fusion", + "count": 1, + "orgs": { + "seqera.io": 1 + } +} ``` 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/docs/sidebar.json b/docs/sidebar.json index e44053468..cb6df70fa 100644 --- a/docs/sidebar.json +++ b/docs/sidebar.json @@ -22,6 +22,7 @@ "guide", "api", "metrics", - "faq" + "faq", + "troubleshoot" ] } diff --git a/docs/troubleshoot.mdx b/docs/troubleshoot.mdx new file mode 100644 index 000000000..5fd64a655 --- /dev/null +++ b/docs/troubleshoot.mdx @@ -0,0 +1,26 @@ +## Troubleshoot guide + +1. How to troubleshoot container build failure? + +If your container build fails, you can check the build details by checking the logs in build details email as shown in below screenshot. + +#### email screenshot: +![](_images/wave_container_build_failure_details.png) + +If there is nothing conclusive in logs, you can check the exit status, e.g. if it is 137 that means out of memory error. +Wave run build process in kubernetes pod, you can check this [link](https://komodor.com/learn/exit-codes-in-containers-and-kubernetes-the-complete-guide/) for more details on exit codes. + +2. How to solve buildkit error, while running wave build on docker desktop in mac os? + +#### error: +``` +could not connect to unix:///run/user/1000/buildkit/buildkitd.sock after 10 trials +========== log ========== +[rootlesskit:parent] error: failed to start the child: fork/exec /proc/self/exe: invalid argument +sh: can't kill pid 14: No such process +``` + +#### Solution: +- In case of wave cli use `--platform linux/arm64` flag with wave build command. +- In case of API call use `containerPlatform: linux/arm64` in the request body. + diff --git a/src/main/groovy/io/seqera/wave/WaveDefault.groovy b/src/main/groovy/io/seqera/wave/WaveDefault.groovy index 7765efcca..7b1fd6254 100644 --- a/src/main/groovy/io/seqera/wave/WaveDefault.groovy +++ b/src/main/groovy/io/seqera/wave/WaveDefault.groovy @@ -38,10 +38,10 @@ interface WaveDefault { 'application/vnd.docker.distribution.manifest.list.v2+json' ) ) - final public static int[] HTTP_REDIRECT_CODES = [301, 302, 303, 307, 308] + final public static int[] HTTP_REDIRECT_CODES = List.of(301, 302, 303, 307, 308) - final public static List HTTP_SERVER_ERRORS = [500, 502, 503, 504] + final public static List HTTP_SERVER_ERRORS = List.of(500, 502, 503, 504) - final public static List HTTP_RETRYABLE_ERRORS = [429, 500, 502, 503, 504] + final public static List HTTP_RETRYABLE_ERRORS = List.of(429, 500, 502, 503, 504) } 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 13935e6c0..efca71c62 100644 --- a/src/main/groovy/io/seqera/wave/configuration/BlobCacheConfig.groovy +++ b/src/main/groovy/io/seqera/wave/configuration/BlobCacheConfig.groovy @@ -41,6 +41,9 @@ class BlobCacheConfig { @Value('${wave.blobCache.status.delay:5s}') Duration statusDelay + @Value('${wave.blobCache.failure.duration:4s}') + Duration failureDuration + @Value('${wave.blobCache.timeout:5m}') Duration transferTimeout @@ -84,6 +87,9 @@ class BlobCacheConfig { @Value('${wave.blobCache.url-signature-duration:30m}') Duration urlSignatureDuration + @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/controller/ContainerController.groovy b/src/main/groovy/io/seqera/wave/controller/ContainerController.groovy index 5116ab590..fcfa3d7d7 100644 --- a/src/main/groovy/io/seqera/wave/controller/ContainerController.groovy +++ b/src/main/groovy/io/seqera/wave/controller/ContainerController.groovy @@ -322,7 +322,7 @@ class ContainerController { final nameStrategy = req.nameStrategy==null && buildRepository && buildConfig.defaultPublicRepository - && buildRepository.startsWith(buildConfig.defaultPublicRepository) ? ImageNameStrategy.imageSuffix : null + && buildRepository.startsWith(buildConfig.defaultPublicRepository) ? ImageNameStrategy.imageSuffix : req.nameStrategy checkContainerSpec(containerSpec) @@ -351,7 +351,7 @@ class ContainerController { } protected BuildTrack checkBuild(BuildRequest build, boolean dryRun) { - final digest = registryProxyService.getImageDigest(build.targetImage) + final digest = registryProxyService.getImageDigest(build) // check for dry-run execution if( dryRun ) { log.debug "== Dry-run build request: $build" diff --git a/src/main/groovy/io/seqera/wave/controller/MetricsController.groovy b/src/main/groovy/io/seqera/wave/controller/MetricsController.groovy index 2ba867e78..f3125e49b 100644 --- a/src/main/groovy/io/seqera/wave/controller/MetricsController.groovy +++ b/src/main/groovy/io/seqera/wave/controller/MetricsController.groovy @@ -36,11 +36,9 @@ import io.micronaut.security.annotation.Secured import io.micronaut.security.authentication.AuthorizationException import io.micronaut.security.rules.SecurityRule import io.seqera.wave.exception.BadRequestException -import io.seqera.wave.service.metric.MetricConstants +import io.seqera.wave.service.metric.MetricsConstants import io.seqera.wave.service.metric.MetricsService -import io.seqera.wave.service.metric.model.GetBuildsCountResponse -import io.seqera.wave.service.metric.model.GetFusionPullsCountResponse -import io.seqera.wave.service.metric.model.GetPullsCountResponse + import jakarta.inject.Inject import static io.micronaut.http.HttpHeaders.WWW_AUTHENTICATE @@ -65,25 +63,25 @@ class MetricsController { @Get(uri = "/v1alpha2/metrics/builds", produces = MediaType.APPLICATION_JSON) HttpResponse getBuildsMetrics(@Nullable @QueryValue String date, @Nullable @QueryValue String org) { if(!date && !org) - return HttpResponse.ok(metricsService.getAllOrgCount(MetricConstants.PREFIX_BUILDS)) + return HttpResponse.ok(metricsService.getAllOrgCount(MetricsConstants.PREFIX_BUILDS)) validateQueryParams(date) - return HttpResponse.ok(metricsService.getOrgCount(MetricConstants.PREFIX_BUILDS, date, org)) + return HttpResponse.ok(metricsService.getOrgCount(MetricsConstants.PREFIX_BUILDS, date, org)) } @Get(uri = "/v1alpha2/metrics/pulls", produces = MediaType.APPLICATION_JSON) HttpResponse getPullsMetrics(@Nullable @QueryValue String date, @Nullable @QueryValue String org) { if(!date && !org) - return HttpResponse.ok(metricsService.getAllOrgCount(MetricConstants.PREFIX_PULLS)) + return HttpResponse.ok(metricsService.getAllOrgCount(MetricsConstants.PREFIX_PULLS)) validateQueryParams(date) - return HttpResponse.ok(metricsService.getOrgCount(MetricConstants.PREFIX_PULLS, date, org)) + return HttpResponse.ok(metricsService.getOrgCount(MetricsConstants.PREFIX_PULLS, date, org)) } @Get(uri = "/v1alpha2/metrics/fusion/pulls", produces = MediaType.APPLICATION_JSON) HttpResponse getFusionPullsMetrics(@Nullable @QueryValue String date, @Nullable @QueryValue String org) { if(!date && !org) - return HttpResponse.ok(metricsService.getAllOrgCount(MetricConstants.PREFIX_FUSION)) + return HttpResponse.ok(metricsService.getAllOrgCount(MetricsConstants.PREFIX_FUSION)) validateQueryParams(date) - return HttpResponse.ok(metricsService.getOrgCount(MetricConstants.PREFIX_FUSION, date, org)) + return HttpResponse.ok(metricsService.getOrgCount(MetricsConstants.PREFIX_FUSION, date, org)) } diff --git a/src/main/groovy/io/seqera/wave/controller/ServiceInfoController.groovy b/src/main/groovy/io/seqera/wave/controller/ServiceInfoController.groovy index ff1b52052..207c6e8d5 100644 --- a/src/main/groovy/io/seqera/wave/controller/ServiceInfoController.groovy +++ b/src/main/groovy/io/seqera/wave/controller/ServiceInfoController.groovy @@ -18,6 +18,7 @@ package io.seqera.wave.controller +import groovy.transform.CompileStatic import io.micronaut.core.annotation.Nullable import groovy.util.logging.Slf4j @@ -37,6 +38,7 @@ import io.seqera.wave.util.BuildInfo */ @Slf4j @Controller("/") +@CompileStatic @ExecuteOn(TaskExecutors.IO) class ServiceInfoController { @@ -50,11 +52,6 @@ class ServiceInfoController { HttpResponse.ok(new ServiceInfoResponse(info)) } - @Get('/ping') - HttpResponse ping() { - HttpResponse.ok() - } - @Get("/") HttpResponse landing() { return landingUrl diff --git a/src/main/groovy/io/seqera/wave/core/RegistryProxyService.groovy b/src/main/groovy/io/seqera/wave/core/RegistryProxyService.groovy index c86cf1d5a..3ecd517c1 100644 --- a/src/main/groovy/io/seqera/wave/core/RegistryProxyService.groovy +++ b/src/main/groovy/io/seqera/wave/core/RegistryProxyService.groovy @@ -25,9 +25,7 @@ import io.micronaut.cache.annotation.Cacheable import io.micronaut.context.annotation.Context import io.micronaut.core.io.buffer.ByteBuffer import io.micronaut.http.client.annotation.Client -import io.micronaut.http.exceptions.HttpException import io.micronaut.reactor.http.client.ReactorStreamingHttpClient -import io.micronaut.retry.annotation.Retryable import io.seqera.wave.WaveDefault import io.seqera.wave.auth.RegistryAuthService import io.seqera.wave.auth.RegistryCredentials @@ -38,6 +36,7 @@ import io.seqera.wave.http.HttpClientFactory import io.seqera.wave.model.ContainerCoordinates import io.seqera.wave.proxy.ProxyClient import io.seqera.wave.service.CredentialsService +import io.seqera.wave.service.builder.BuildRequest import io.seqera.wave.service.persistence.PersistenceService import io.seqera.wave.storage.DigestStore import io.seqera.wave.storage.Storage @@ -46,6 +45,7 @@ import jakarta.inject.Inject import jakarta.inject.Singleton import reactor.core.publisher.Flux import static io.seqera.wave.WaveDefault.HTTP_REDIRECT_CODES +import static io.seqera.wave.WaveDefault.HTTP_RETRYABLE_ERRORS /** * Proxy service that forwards incoming container request * to the target repository, resolving credentials and augmentation @@ -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 } @@ -188,38 +186,28 @@ class RegistryProxyService { } } - @Deprecated - boolean isManifestPresent(String image){ + String getImageDigest(BuildRequest request, boolean retryOnNotFound=false) { try { - return getImageDigest0(image) != null + return getImageDigest0(request, retryOnNotFound) } catch(Exception e) { - log.warn "Unable to check status for container image '$image' -- cause: ${e.message}" - return false - } - } - - String getImageDigest(String image) { - try { - return getImageDigest0(image) - } - catch(Exception e) { - log.warn "Unable to retrieve digest for image '$image' -- cause: ${e.message}" + log.warn "Unable to retrieve digest for image '${request.getTargetImage()}' -- cause: ${e.message}" return null } } - @Cacheable(value = 'cache-1min', atomic = true) - @Retryable(includes=[IOException, HttpException]) - protected String getImageDigest0(String image) { + static private List RETRY_ON_NOT_FOUND = HTTP_RETRYABLE_ERRORS + 404 + + @Cacheable(value = 'cache-registry-proxy', atomic = true) + protected String getImageDigest0(BuildRequest request, boolean retryOnNotFound) { + final image = request.targetImage final coords = ContainerCoordinates.parse(image) - final route = RoutePath.v2manifestPath(coords) + final route = RoutePath.v2manifestPath(coords, request.identity) final proxyClient = client(route) + .withRetryableHttpErrors(retryOnNotFound ? RETRY_ON_NOT_FOUND : HTTP_RETRYABLE_ERRORS) final resp = proxyClient.head(route.path, WaveDefault.ACCEPT_HEADERS) - final result = resp.statusCode() == 200 - ? resp.headers().firstValue('docker-content-digest').orElse(null) - : null - if( !result ) { + final result = resp.headers().firstValue('docker-content-digest').orElse(null) + if( !result && (resp.statusCode()!=404 || retryOnNotFound) ) { log.warn "Unable to retrieve digest for image '$image' -- response status=${resp.statusCode()}; headers:\n${RegHelper.dumpHeaders(resp.headers())}" } return result diff --git a/src/main/groovy/io/seqera/wave/core/RoutePath.groovy b/src/main/groovy/io/seqera/wave/core/RoutePath.groovy index a5acaf55a..bfe1bffe1 100644 --- a/src/main/groovy/io/seqera/wave/core/RoutePath.groovy +++ b/src/main/groovy/io/seqera/wave/core/RoutePath.groovy @@ -124,8 +124,9 @@ class RoutePath implements ContainerPath { new RoutePath(type, registry ?: DOCKER_IO, image, ref, "/v2/$image/$type/$ref", request, token) } - static RoutePath v2manifestPath(ContainerCoordinates container) { - new RoutePath('manifests', container.registry, container.image, container.reference, "/v2/${container.image}/manifests/${container.reference}") + static RoutePath v2manifestPath(ContainerCoordinates container, PlatformId identity=null) { + ContainerRequestData data = identity!=null ? new ContainerRequestData(identity) : null + return new RoutePath('manifests', container.registry, container.image, container.reference, "/v2/${container.image}/manifests/${container.reference}", data) } static RoutePath empty() { diff --git a/src/main/groovy/io/seqera/wave/proxy/ProxyClient.groovy b/src/main/groovy/io/seqera/wave/proxy/ProxyClient.groovy index 222e0a7cc..f58c3a688 100644 --- a/src/main/groovy/io/seqera/wave/proxy/ProxyClient.groovy +++ b/src/main/groovy/io/seqera/wave/proxy/ProxyClient.groovy @@ -59,6 +59,7 @@ class ProxyClient { private RegistryAuthService loginService private ContainerPath route private HttpClientConfig httpConfig + private List retryableHttpErrors = HTTP_RETRYABLE_ERRORS ProxyClient(HttpClient httpClient, HttpClientConfig httpConfig) { if( httpClient.followRedirects()!= HttpClient.Redirect.NEVER ) @@ -96,6 +97,12 @@ class ProxyClient { return this } + ProxyClient withRetryableHttpErrors(List errors) { + if( errors!=null ) + retryableHttpErrors = errors + return this + } + URI makeUri(String path) { assert path.startsWith('/'), "Request past should start with a slash character — offending path: $path" return URI.create(registry.host.toString() + path) @@ -157,7 +164,7 @@ class ProxyClient { def HttpResponse get(URI origin, Map> headers, BodyHandler handler, boolean followRedirect) { final retryable = Retryable .>of(httpConfig) - .retryIf((resp) -> resp.statusCode() in HTTP_RETRYABLE_ERRORS) + .retryIf((resp) -> resp.statusCode() in retryableHttpErrors) .onRetry((event) -> "Failure on GET request: $origin - event: $event") // carry out the request return retryable.apply(()-> get0(origin, headers, handler, followRedirect)) @@ -246,10 +253,10 @@ class ProxyClient { } - HttpResponse head(URI uri, Map> headers) { + HttpResponse head(URI uri, Map> headers) { final retryable = Retryable .>of(httpConfig) - .retryIf((resp) -> resp.statusCode() in HTTP_RETRYABLE_ERRORS) + .retryIf((resp) -> resp.statusCode() in retryableHttpErrors) .onRetry((event) -> "Failure on HEAD request: $uri - event: $event") // carry out the request return retryable.apply(()-> head0(uri,headers)) 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/aws/S3ClientFactory.groovy b/src/main/groovy/io/seqera/wave/service/aws/S3ClientFactory.groovy new file mode 100644 index 000000000..5327cb7d1 --- /dev/null +++ b/src/main/groovy/io/seqera/wave/service/aws/S3ClientFactory.groovy @@ -0,0 +1,72 @@ +/* + * 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.seqera.wave.configuration.BlobCacheConfig +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 +/** + * Factory implementation for S3Client + * + * @author Munish Chouhan + */ +@Factory +@CompileStatic +@Slf4j +class S3ClientFactory { + + @Value('${aws.region}') + private String awsRegion; + + @Singleton + @Requires(property = 'wave.blobCache.enabled', value = 'true') + @Named('BlobS3Client') + S3Client cloudflareS3Client(BlobCacheConfig blobConfig) { + final creds = AwsBasicCredentials.create(blobConfig.storageAccessKey, blobConfig.storageSecretKey) + final builder = S3Client.builder() + .region(Region.of(blobConfig.storageRegion)) + .credentialsProvider(StaticCredentialsProvider.create(creds)) + + if (blobConfig.storageEndpoint) { + builder.endpointOverride(URI.create(blobConfig.storageEndpoint)) + } + + 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/BlobCacheServiceImpl.groovy b/src/main/groovy/io/seqera/wave/service/blob/impl/BlobCacheServiceImpl.groovy index 51c8fb3cf..6f8f91b05 100644 --- a/src/main/groovy/io/seqera/wave/service/blob/impl/BlobCacheServiceImpl.groovy +++ b/src/main/groovy/io/seqera/wave/service/blob/impl/BlobCacheServiceImpl.groovy @@ -20,6 +20,7 @@ package io.seqera.wave.service.blob.impl import java.net.http.HttpClient import java.net.http.HttpRequest import java.net.http.HttpResponse +import java.util.concurrent.CompletableFuture import java.util.concurrent.ExecutorService import groovy.transform.CompileStatic @@ -38,6 +39,7 @@ import io.seqera.wave.service.blob.BlobSigningService import io.seqera.wave.service.blob.BlobStore import io.seqera.wave.service.blob.TransferStrategy import io.seqera.wave.service.blob.TransferTimeoutException +import io.seqera.wave.util.BucketTokenizer import io.seqera.wave.util.Escape import io.seqera.wave.util.Retryable import io.seqera.wave.util.StringUtils @@ -45,6 +47,9 @@ import jakarta.annotation.PostConstruct import jakarta.inject.Inject import jakarta.inject.Named import jakarta.inject.Singleton +import software.amazon.awssdk.services.s3.S3Client +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest +import software.amazon.awssdk.services.s3.model.HeadObjectRequest import static io.seqera.wave.WaveDefault.HTTP_SERVER_ERRORS /** * Implements cache for container image layer blobs @@ -82,6 +87,10 @@ class BlobCacheServiceImpl implements BlobCacheService { @Inject private HttpClientConfig httpConfig + @Inject + @Named('BlobS3Client') + private S3Client s3Client + private HttpClient httpClient @PostConstruct @@ -184,21 +193,38 @@ class BlobCacheServiceImpl implements BlobCacheService { else { log.debug "== Blob cache begin for object '${info.locationUri}'" result = store(route, info) + //check if the cached blob size is correct + result = checkUploadedBlobSize(result, route) } } finally { // use a short time-to-live for failed downloads - // this is needed to allow re-try downloads failed for - // temporary error conditions e.g. expired credentials + // this is needed to allow re-try caching of failure transfers final ttl = result.succeeded() ? blobConfig.statusDuration - : blobConfig.statusDelay.multipliedBy(10) + : blobConfig.failureDuration blobStore.storeBlob(route.targetPath, result, ttl) return result } } + /** + * Check the size of the blob stored in the cache + * + * @return {@link BlobCacheInfo} the blob cache info + */ + protected BlobCacheInfo checkUploadedBlobSize(BlobCacheInfo info, RoutePath route) { + if( !info.succeeded() ) + return info + final blobSize = getBlobSize(route) + if( blobSize == info.contentLength ) + return info + log.warn("== Blob cache mismatch size for uploaded object '${info.locationUri}'; upload blob size: ${blobSize}; expect size: ${info.contentLength}") + CompletableFuture.supplyAsync(() -> deleteBlob(route), executor) + return info.failed("Mismatch cache size for object ${info.locationUri}") + } + protected BlobCacheInfo store(RoutePath route, BlobCacheInfo info) { final target = route.targetPath try { @@ -226,7 +252,6 @@ class BlobCacheServiceImpl implements BlobCacheService { StringUtils.pathConcat(blobConfig.storageBucket, route.targetPath) } - /** * The HTTP URI from there the cached layer blob is going to be downloaded * @@ -234,7 +259,7 @@ class BlobCacheServiceImpl implements BlobCacheService { * @return The HTTP URI from the cached layer blob is going to be downloaded */ protected String blobDownloadUri(RoutePath route) { - final bucketPath = StringUtils.pathConcat(blobConfig.storageBucket, route.targetPath) + final bucketPath = blobStorePath(route) final presignedUrl = signingService.createSignedUri(bucketPath) if( blobConfig.baseUrl ) { @@ -247,7 +272,6 @@ class BlobCacheServiceImpl implements BlobCacheService { return presignedUrl } - /** * Await for the container layer blob download * @@ -293,4 +317,50 @@ class BlobCacheServiceImpl implements BlobCacheService { } } } + + /** + * get the size of the blob stored in the cache + * + * @return {@link Long} the size of the blob stored in the cache + */ + protected Long getBlobSize(RoutePath route) { + final objectUri = blobStorePath(route) + final object = BucketTokenizer.from(objectUri) + try { + final request = + HeadObjectRequest.builder() + .bucket(object.bucket) + .key(object.key) + .build() + final headObjectResponse = s3Client.headObject(request as HeadObjectRequest) + final contentLength = headObjectResponse.contentLength() + return contentLength!=null ? contentLength : -1L + } + catch (Exception e){ + log.error("== Blob cache Error getting content length of object $objectUri from bucket ${blobConfig.storageBucket}", e) + return -1L + } + } + + /** + * delete the blob stored in the cache + * + */ + protected void deleteBlob(RoutePath route) { + final objectUri = blobStorePath(route) + log.debug "== Blob cache Deleting object $objectUri" + final object = BucketTokenizer.from(objectUri) + try { + final request = + DeleteObjectRequest.builder() + .bucket(object.bucket) + .key(object.key) + .build() + s3Client.deleteObject(request as DeleteObjectRequest) + log.debug("== Blob cache Deleted object $objectUri from bucket ${blobConfig.storageBucket}") + } + catch (Exception e){ + log.error("== Blob cache Error deleting object $objectUri from bucket ${blobConfig.storageBucket}", e) + } + } } 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 6204595e9..ee09c9be0 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 @@ -18,16 +18,22 @@ package io.seqera.wave.service.blob.impl +import java.util.concurrent.CompletableFuture +import java.util.concurrent.ExecutorService + import com.google.common.hash.Hashing import groovy.transform.CompileStatic import groovy.util.logging.Slf4j import io.micronaut.context.annotation.Replaces import io.micronaut.context.annotation.Requires +import io.micronaut.scheduling.TaskExecutors import io.seqera.wave.configuration.BlobCacheConfig import io.seqera.wave.service.blob.BlobCacheInfo import io.seqera.wave.service.blob.TransferStrategy +import io.seqera.wave.service.cleanup.CleanupStrategy import io.seqera.wave.service.k8s.K8sService import jakarta.inject.Inject +import jakarta.inject.Named /** * Implements {@link TransferStrategy} that runs s5cmd using a * Kubernetes job @@ -46,15 +52,24 @@ class KubeTransferStrategy implements TransferStrategy { @Inject private K8sService k8sService + @Inject + private CleanupStrategy cleanup + + @Inject + @Named(TaskExecutors.IO) + private ExecutorService executor + @Override BlobCacheInfo transfer(BlobCacheInfo info, List command) { final podName = podName(info) final pod = k8sService.transferContainer(podName, blobConfig.s5Image, command, blobConfig) final terminated = k8sService.waitPod(pod, blobConfig.transferTimeout.toMillis()) final stdout = k8sService.logsPod(podName) - return terminated + final result = terminated ? info.completed(terminated.exitCode, stdout) : info.failed(stdout) + cleanupPod(podName, terminated.exitCode) + return result } protected String podName(BlobCacheInfo info) { @@ -65,4 +80,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/builder/DockerBuildStrategy.groovy b/src/main/groovy/io/seqera/wave/service/builder/DockerBuildStrategy.groovy index d87ef1c08..aaaf07fe3 100644 --- a/src/main/groovy/io/seqera/wave/service/builder/DockerBuildStrategy.groovy +++ b/src/main/groovy/io/seqera/wave/service/builder/DockerBuildStrategy.groovy @@ -98,7 +98,7 @@ class DockerBuildStrategy extends BuildStrategy { final completed = proc.waitFor(buildConfig.buildTimeout.toSeconds(), TimeUnit.SECONDS) final stdout = proc.inputStream.text if( completed ) { - final digest = proxyService.getImageDigest(req.targetImage) + final digest = proc.exitValue()==0 ? proxyService.getImageDigest(req, true) : null return BuildResult.completed(req.buildId, proc.exitValue(), stdout, req.startTime, digest) } else { diff --git a/src/main/groovy/io/seqera/wave/service/builder/KubeBuildStrategy.groovy b/src/main/groovy/io/seqera/wave/service/builder/KubeBuildStrategy.groovy index b5e452141..2d1bdd4ae 100644 --- a/src/main/groovy/io/seqera/wave/service/builder/KubeBuildStrategy.groovy +++ b/src/main/groovy/io/seqera/wave/service/builder/KubeBuildStrategy.groovy @@ -103,11 +103,11 @@ class KubeBuildStrategy extends BuildStrategy { final terminated = k8sService.waitPod(pod, buildConfig.buildTimeout.toMillis()) final stdout = k8sService.logsPod(name) if( terminated ) { - final digest = proxyService.getImageDigest(req.targetImage) + final digest = terminated.exitCode==0 ? proxyService.getImageDigest(req, true) : null return BuildResult.completed(req.buildId, terminated.exitCode, stdout, req.startTime, digest) } else { - return BuildResult.failed(req.buildId, stdout, req.startTime ) + return BuildResult.failed(req.buildId, stdout, req.startTime) } } catch (ApiException e) { 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..be6b738e3 100644 --- a/src/main/groovy/io/seqera/wave/service/inspect/ContainerInspectServiceImpl.groovy +++ b/src/main/groovy/io/seqera/wave/service/inspect/ContainerInspectServiceImpl.groovy @@ -33,6 +33,7 @@ import io.seqera.wave.core.ContainerPath import io.seqera.wave.core.RegistryProxyService import io.seqera.wave.core.spec.ConfigSpec import io.seqera.wave.core.spec.ContainerSpec +import io.seqera.wave.exception.BadRequestException import io.seqera.wave.http.HttpClientFactory import io.seqera.wave.model.ContainerCoordinates import io.seqera.wave.proxy.ProxyClient @@ -91,7 +92,16 @@ class ContainerInspectServiceImpl implements ContainerInspectService { repos.add(buildRepo) if( cacheRepo ) repos.add(cacheRepo) - return credsJson(repos, identity) + final result = credsJson(repos, identity) + if( buildRepo && !result.contains(host0(buildRepo)) ) + throw new BadRequestException("Missing credentials for target build repository: $buildRepo") + if( cacheRepo && !result.contains(host0(cacheRepo)) ) + throw new BadRequestException("Missing credentials for target cache repository: $buildRepo") + return result + } + + static protected String host0(String repo) { + repo.tokenize('/')[0] } protected String credsJson(Set repositories, PlatformId identity) { @@ -105,9 +115,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 +185,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 +225,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 30fd68935..7d28759f8 100644 --- a/src/main/groovy/io/seqera/wave/service/k8s/K8sService.groovy +++ b/src/main/groovy/io/seqera/wave/service/k8s/K8sService.groovy @@ -56,4 +56,7 @@ interface K8sService { V1ContainerStateTerminated waitPod(V1Pod pod, long timeout) InputStream getCurrentLogsPod(String name) + + 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 bfe3d10d3..6ae2a830e 100644 --- a/src/main/groovy/io/seqera/wave/service/k8s/K8sServiceImpl.groovy +++ b/src/main/groovy/io/seqera/wave/service/k8s/K8sServiceImpl.groovy @@ -506,6 +506,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/service/mail/impl/MailServiceImpl.groovy b/src/main/groovy/io/seqera/wave/service/mail/impl/MailServiceImpl.groovy index 3c4990637..6e73804aa 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 @@ -87,7 +87,7 @@ class MailServiceImpl implements MailService { final binding = new HashMap(20) final status = result.succeeded() ? 'DONE': 'FAILED' binding.build_id = result.id - binding.build_user = "${req.identity?.user ? req.identity.user.userName : 'n/a'} (${req.ip})" + binding.build_user = "${req.identity?.user ? req.identity.user.userName : '-'} (${req.ip})" binding.build_success = result.exitStatus==0 binding.build_exit_status = result.exitStatus binding.build_time = formatTimestamp(result.startTime, req.offsetId) ?: '-' diff --git a/src/main/groovy/io/seqera/wave/service/metric/MetricConstants.groovy b/src/main/groovy/io/seqera/wave/service/metric/MetricsConstants.groovy similarity index 97% rename from src/main/groovy/io/seqera/wave/service/metric/MetricConstants.groovy rename to src/main/groovy/io/seqera/wave/service/metric/MetricsConstants.groovy index 95d397616..84fc14c54 100644 --- a/src/main/groovy/io/seqera/wave/service/metric/MetricConstants.groovy +++ b/src/main/groovy/io/seqera/wave/service/metric/MetricsConstants.groovy @@ -23,7 +23,7 @@ package io.seqera.wave.service.metric * * @author Munish Chouhan */ -interface MetricConstants { +interface MetricsConstants { static final public String PREFIX_FUSION = 'fusion' diff --git a/src/main/groovy/io/seqera/wave/service/metric/impl/MetricsServiceImpl.groovy b/src/main/groovy/io/seqera/wave/service/metric/impl/MetricsServiceImpl.groovy index d08e404d5..01c2950c3 100644 --- a/src/main/groovy/io/seqera/wave/service/metric/impl/MetricsServiceImpl.groovy +++ b/src/main/groovy/io/seqera/wave/service/metric/impl/MetricsServiceImpl.groovy @@ -25,13 +25,14 @@ import java.util.regex.Pattern import groovy.transform.CompileStatic import groovy.util.logging.Slf4j -import io.seqera.wave.service.metric.MetricConstants import io.seqera.wave.service.metric.MetricsCounterStore import io.seqera.wave.service.metric.MetricsService import io.seqera.wave.service.metric.model.GetOrgCountResponse import io.seqera.wave.tower.PlatformId import jakarta.inject.Inject import jakarta.inject.Singleton + +import static io.seqera.wave.service.metric.MetricsConstants.* /** * Implements service to store and retrieve wave metrics from the counter store * @@ -52,13 +53,13 @@ class MetricsServiceImpl implements MetricsService { @Override GetOrgCountResponse getAllOrgCount(String metric){ final response = new GetOrgCountResponse(metric, 0, [:]) - final orgCounts = metricsCounterStore.getAllMatchingEntries("$metric/$MetricConstants.PREFIX_ORG/*") + final orgCounts = metricsCounterStore.getAllMatchingEntries("$metric/$PREFIX_ORG/*") for(def entry : orgCounts) { // orgCounts also contains the records with org and date, so here it filter out the records with date - if(!entry.key.contains("/$MetricConstants.PREFIX_DAY/")) { + if(!entry.key.contains("/$PREFIX_DAY/")) { response.count += entry.value //split is used to extract the org name from the key like "metrics/o/seqera.io" => seqera.io - response.orgs.put(entry.key.split("/$MetricConstants.PREFIX_ORG/").last(), entry.value) + response.orgs.put(entry.key.split("/$PREFIX_ORG/").last(), entry.value) } } return response @@ -76,7 +77,7 @@ class MetricsServiceImpl implements MetricsService { response.orgs.put(org, response.count) }else{ // when only date is provide, scan the store and return the count for all orgs on given date - final orgCounts = metricsCounterStore.getAllMatchingEntries("$metric/$MetricConstants.PREFIX_ORG/*/$MetricConstants.PREFIX_DAY/$date") + final orgCounts = metricsCounterStore.getAllMatchingEntries("$metric/$PREFIX_ORG/*/$PREFIX_DAY/$date") for(def entry : orgCounts) { response.orgs.put(extractOrgFromKey(entry.key), entry.value) } @@ -88,17 +89,17 @@ class MetricsServiceImpl implements MetricsService { @Override void incrementFusionPullsCounter(PlatformId platformId){ - incrementCounter(MetricConstants.PREFIX_FUSION, platformId?.user?.email) + incrementCounter(PREFIX_FUSION, platformId?.user?.email) } @Override void incrementBuildsCounter(PlatformId platformId){ - incrementCounter(MetricConstants.PREFIX_BUILDS, platformId?.user?.email) + incrementCounter(PREFIX_BUILDS, platformId?.user?.email) } @Override void incrementPullsCounter(PlatformId platformId) { - incrementCounter(MetricConstants.PREFIX_PULLS, platformId?.user?.email) + incrementCounter(PREFIX_PULLS, platformId?.user?.email) } protected void incrementCounter(String prefix, String email) { @@ -127,13 +128,13 @@ class MetricsServiceImpl implements MetricsService { protected static String getKey(String prefix, String day, String org){ if( day && org ) - return "$prefix/$MetricConstants.PREFIX_ORG/$org/$MetricConstants.PREFIX_DAY/$day" + return "$prefix/$PREFIX_ORG/$org/$PREFIX_DAY/$day" if( org ) - return "$prefix/$MetricConstants.PREFIX_ORG/$org" + return "$prefix/$PREFIX_ORG/$org" if( day ) - return "$prefix/$MetricConstants.PREFIX_DAY/$day" + return "$prefix/$PREFIX_DAY/$day" return null } 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 c0d5aae02..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 * @@ -47,25 +49,25 @@ class TowerClient { return connector.sendAsync(endpoint, uri, authorization, type) } - @Cacheable(value = 'cache-20sec', atomic = true) + @Cacheable(value = 'cache-tower-client', atomic = true) CompletableFuture serviceInfo(String towerEndpoint) { final uri = serviceInfoEndpoint(towerEndpoint) return getAsync(uri, towerEndpoint, null, ServiceInfoResponse) } - @Cacheable(value = 'cache-20sec', atomic = true) + @Cacheable(value = 'cache-tower-client', atomic = true) CompletableFuture userInfo(String towerEndpoint, JwtAuth authorization) { final uri = userInfoEndpoint(towerEndpoint) return getAsync(uri, towerEndpoint, authorization, UserInfoResponse) } - @Cacheable(value = 'cache-20sec', atomic = true) + @Cacheable(value = 'cache-tower-client', atomic = true) CompletableFuture listCredentials(String towerEndpoint, JwtAuth authorization, Long workspaceId) { final uri = listCredentialsEndpoint(towerEndpoint, workspaceId) return getAsync(uri, towerEndpoint, authorization, ListCredentialsResponse) } - @Cacheable(value = 'cache-20sec', atomic = true) + @Cacheable(value = 'cache-tower-client', atomic = true) CompletableFuture fetchEncryptedCredentials(String towerEndpoint, JwtAuth authorization, String credentialsId, String pairingId, Long workspaceId) { final uri = fetchCredentialsEndpoint(towerEndpoint, credentialsId, pairingId, workspaceId) return getAsync(uri, towerEndpoint, authorization, GetCredentialsKeysResponse) @@ -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/service/metric/model/GetPullsCountResponse.groovy b/src/main/groovy/io/seqera/wave/tower/compute/ComputeEnv.groovy similarity index 81% rename from src/main/groovy/io/seqera/wave/service/metric/model/GetPullsCountResponse.groovy rename to src/main/groovy/io/seqera/wave/tower/compute/ComputeEnv.groovy index 3de656ac7..dc2c81d7a 100644 --- a/src/main/groovy/io/seqera/wave/service/metric/model/GetPullsCountResponse.groovy +++ b/src/main/groovy/io/seqera/wave/tower/compute/ComputeEnv.groovy @@ -16,19 +16,19 @@ * along with this program. If not, see . */ -package io.seqera.wave.service.metric.model +package io.seqera.wave.tower.compute import groovy.transform.CompileStatic + /** - * Model a Wave pulls count response + * Model the response of compute environment from seqera platform * * @author Munish Chouhan */ @CompileStatic -class GetPullsCountResponse { - Long count - - GetPullsCountResponse(Long count) { - this.count = count - } +class ComputeEnv { + String id + String platform + String credentialsId } + diff --git a/src/main/groovy/io/seqera/wave/service/metric/model/GetFusionPullsCountResponse.groovy b/src/main/groovy/io/seqera/wave/tower/compute/DescribeWorkflowLaunchResponse.groovy similarity index 71% rename from src/main/groovy/io/seqera/wave/service/metric/model/GetFusionPullsCountResponse.groovy rename to src/main/groovy/io/seqera/wave/tower/compute/DescribeWorkflowLaunchResponse.groovy index 1c9659111..1859796ad 100644 --- a/src/main/groovy/io/seqera/wave/service/metric/model/GetFusionPullsCountResponse.groovy +++ b/src/main/groovy/io/seqera/wave/tower/compute/DescribeWorkflowLaunchResponse.groovy @@ -1,6 +1,6 @@ /* * Wave, containers provisioning service - * Copyright (c) 2023-2024, Seqera Labs + * 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 @@ -16,19 +16,22 @@ * along with this program. If not, see . */ -package io.seqera.wave.service.metric.model +package io.seqera.wave.tower.compute import groovy.transform.CompileStatic /** - * Model a Wave fusion pulls count response + * Model the response of workflow launch describe request * * @author Munish Chouhan */ @CompileStatic -class GetFusionPullsCountResponse { - Long count +class DescribeWorkflowLaunchResponse { - GetFusionPullsCountResponse(Long count) { - this.count = count + WorkflowLaunchResponse launch + + DescribeWorkflowLaunchResponse() {} + + DescribeWorkflowLaunchResponse(WorkflowLaunchResponse launch) { + this.launch = launch } } diff --git a/src/main/groovy/io/seqera/wave/service/metric/model/GetBuildsCountResponse.groovy b/src/main/groovy/io/seqera/wave/tower/compute/WorkflowLaunchResponse.groovy similarity index 81% rename from src/main/groovy/io/seqera/wave/service/metric/model/GetBuildsCountResponse.groovy rename to src/main/groovy/io/seqera/wave/tower/compute/WorkflowLaunchResponse.groovy index c724c9424..a271b0b89 100644 --- a/src/main/groovy/io/seqera/wave/service/metric/model/GetBuildsCountResponse.groovy +++ b/src/main/groovy/io/seqera/wave/tower/compute/WorkflowLaunchResponse.groovy @@ -16,19 +16,16 @@ * along with this program. If not, see . */ -package io.seqera.wave.service.metric.model +package io.seqera.wave.tower.compute import groovy.transform.CompileStatic + /** - * Model a Wave builds count response + * Model the response of workflow launch response from seqera platform * * @author Munish Chouhan */ @CompileStatic -class GetBuildsCountResponse { - Long count - - GetBuildsCountResponse(Long count) { - this.count = count - } +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 14ae48515..7c795d5f5 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -6,10 +6,12 @@ micronaut: # IMPORTANT: use `expire-after-write` strategy to guarantee cached entries are not retained more than the declared period. # Using `expire-after-read` can cause an entry to be retained in the cache more than expected if it is hit continuously # with a frequency shorter than the declared cache period. - cache-20sec: + cache-tower-client: expire-after-write: 20s - cache-1min: - expire-after-write: 1m + record-stats: true + cache-registry-proxy: + expire-after-write: 20s + record-stats: true # Discovery client by default uses a cache for results. This is a transitive behaviour # introduced by prometheus and management dependencies and below line disables as # it is unused at the moment by the wave project. See https://micronaut-projects.github.io/micronaut-discovery-client/latest/guide/#introduction @@ -64,7 +66,7 @@ wave: server: url: "${WAVE_SERVER_URL:`http://localhost:9090`}" build: - buildkit-image: "moby/buildkit:v0.13.2-rootless" + buildkit-image: "moby/buildkit:v0.14.1-rootless" singularity-image: "quay.io/singularity/singularity:v3.11.4-slim" singularity-image-arm64: "quay.io/singularity/singularity:v3.11.4-slim-arm64" repo: "195996028523.dkr.ecr.eu-west-1.amazonaws.com/wave/build/dev" @@ -81,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/controller/ContainerControllerHttpTest.groovy b/src/test/groovy/io/seqera/wave/controller/ContainerControllerHttpTest.groovy index d2a98b8ed..85bb245db 100644 --- a/src/test/groovy/io/seqera/wave/controller/ContainerControllerHttpTest.groovy +++ b/src/test/groovy/io/seqera/wave/controller/ContainerControllerHttpTest.groovy @@ -30,6 +30,7 @@ import io.micronaut.http.client.exceptions.HttpClientResponseException import io.micronaut.test.annotation.MockBean import io.micronaut.test.extensions.spock.annotation.MicronautTest import io.seqera.wave.api.ContainerConfig +import io.seqera.wave.api.PackagesSpec import io.seqera.wave.api.SubmitContainerTokenRequest import io.seqera.wave.api.SubmitContainerTokenResponse import io.seqera.wave.core.RouteHandler @@ -304,4 +305,59 @@ class ContainerControllerHttpTest extends Specification { deleteResp.status.code == 200 } + def 'should get the correct image name with imageSuffix name strategy'(){ + when: + def packages = new PackagesSpec(channels: ['conda-forge', 'bioconda'], entries: ['salmon'], type: 'CONDA') + SubmitContainerTokenRequest request = + new SubmitContainerTokenRequest( + nameStrategy: "imageSuffix", + packages: packages, + freeze: true, + buildRepository: "docker.io/foo/test") + and: + def response = httpClient + .toBlocking() + .exchange(HttpRequest.POST("/v1alpha2/container", request), SubmitContainerTokenResponse) + .body() + + then: + response.targetImage.startsWith("docker.io/foo/test/salmon") + } + + def 'should get the correct image name with tagPrefix name strategy'(){ + when: + def packages = new PackagesSpec(channels: ['conda-forge', 'bioconda'], entries: ['salmon'], type: 'CONDA') + SubmitContainerTokenRequest request = + new SubmitContainerTokenRequest( + nameStrategy: "tagPrefix", + packages: packages, + freeze: true, + buildRepository: "docker.io/foo/test") + and: + def response = httpClient + .toBlocking() + .exchange(HttpRequest.POST("/v1alpha2/container", request), SubmitContainerTokenResponse) + .body() + + then: + response.targetImage.startsWith("docker.io/foo/test:salmon") + } + + def 'should get the correct image name with default name strategy'(){ + when: + def packages = new PackagesSpec(channels: ['conda-forge', 'bioconda'], entries: ['salmon'], type: 'CONDA') + SubmitContainerTokenRequest request = + new SubmitContainerTokenRequest( + packages: packages, + freeze: true, + buildRepository: "docker.io/foo/test") + and: + def response = httpClient + .toBlocking() + .exchange(HttpRequest.POST("/v1alpha2/container", request), SubmitContainerTokenResponse) + .body() + + then: + response.targetImage.startsWith("docker.io/foo/test:salmon") + } } diff --git a/src/test/groovy/io/seqera/wave/core/ContainerAugmenterTest.groovy b/src/test/groovy/io/seqera/wave/core/ContainerAugmenterTest.groovy index ba3ee7aa9..69f5a166d 100644 --- a/src/test/groovy/io/seqera/wave/core/ContainerAugmenterTest.groovy +++ b/src/test/groovy/io/seqera/wave/core/ContainerAugmenterTest.groovy @@ -21,6 +21,8 @@ package io.seqera.wave.core import spock.lang.Shared import spock.lang.Specification +import java.net.http.HttpHeaders +import java.net.http.HttpResponse import java.nio.file.Files import groovy.json.JsonSlurper @@ -817,20 +819,39 @@ class ContainerAugmenterTest extends Specification { def 'should fetch container manifest for legacy image' () { given: - def REGISTRY = 'quay.io' - def IMAGE = 'biocontainers/fastqc' - def TAG = '0.11.9--0' - def registry = lookupService.lookup(REGISTRY) - def creds = credentialsProvider.getDefaultCredentials(REGISTRY) - def httpClient = HttpClientFactory.neverRedirectsHttpClient() + def REGISTRY = 'mockreg.io' + def IMAGE = 'repo/image' + def TAG = '1.0.0' and: - def client = new ProxyClient(httpClient, httpConfig) - .withRoute(Mock(RoutePath)) - .withImage(IMAGE) - .withRegistry(registry) - .withCredentials(creds) - .withLoginService(loginService) + def client = Mock(ProxyClient) + + and: + def headers = ['content-type': List.of('application/vnd.docker.distribution.manifest.v1+prettyjws'), + 'docker-content-digest': List.of('sha256:samplesha')] + def response1 = Mock(HttpResponse) + response1.headers() >> HttpHeaders.of(headers, (a, b)->true) + and: + String manifest = """ + { + "schemaVersion": 1, + "name": "mockImage", + "tag": "latest", + "architecture": "amd64", + "fsLayers": [ + {"blobSum": "sha256:mockBlobSum1"}, + {"blobSum": "sha256:mockBlobSum2"} + ], + "history": [ + { + "v1Compatibility": "{\\"id\\":\\"mockId1\\",\\"architecture\\": \\"amd64\\",\\"config\\": {\\"Cmd\\": [\\"/bin/sh\\"],\\"Env\\": [\\"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\\"],\\"Entrypoint\\": null,\\"WorkingDir\\": \\"\\"},\\"created\\": \\"2021-09-23T23:47:57.442225064Z\\",\\"docker_version\\": \\"20.10.7\\",\\"os\\": \\"linux\\",\\"parent\\": \\"mockParent1\\"}" + } + ] + } + """ + def response2 = Mock(HttpResponse) + response2.headers() >> HttpHeaders.of(Map.of('content-type', List.of('application/vnd.docker.distribution.manifest.v1+prettyjws')), (a, b)->true) + response2.body() >> manifest and: def scanner = new ContainerAugmenter() .withClient(client) @@ -838,11 +859,17 @@ class ContainerAugmenterTest extends Specification { when: def spec = scanner.getContainerSpec(IMAGE, TAG, WaveDefault.ACCEPT_HEADERS) + + then: + client.head("/v2/$IMAGE/manifests/$TAG", _) >> response1 + client.getString(_, _ ) >> response2 + client.route >> new RoutePath('docker', REGISTRY, IMAGE) + client.getRegistry() >> new RegistryInfo(REGISTRY, new URI(REGISTRY), Mock(RegistryAuth)) then: - spec.registry == 'quay.io' - spec.imageName == 'biocontainers/fastqc' - spec.reference == '0.11.9--0' - spec.digest == 'sha256:319b8d4eca0fc0367d192941f221f7fcd29a6b96996c63cbf8931dbb66e53348' + spec.registry == 'mockreg.io' + spec.imageName == 'repo/image' + spec.reference == '1.0.0' + spec.digest == 'sha256:samplesha' and: spec.isV1() !spec.isV2() diff --git a/src/test/groovy/io/seqera/wave/core/RegistryProxyServiceTest.groovy b/src/test/groovy/io/seqera/wave/core/RegistryProxyServiceTest.groovy index 9bfdff909..44d2db027 100644 --- a/src/test/groovy/io/seqera/wave/core/RegistryProxyServiceTest.groovy +++ b/src/test/groovy/io/seqera/wave/core/RegistryProxyServiceTest.groovy @@ -18,11 +18,14 @@ package io.seqera.wave.core +import spock.lang.Requires import spock.lang.Shared import spock.lang.Specification import io.micronaut.context.ApplicationContext import io.micronaut.test.extensions.spock.annotation.MicronautTest +import io.seqera.wave.service.builder.BuildRequest +import io.seqera.wave.tower.PlatformId import jakarta.inject.Inject /** * @@ -38,25 +41,31 @@ class RegistryProxyServiceTest extends Specification { @Inject RegistryProxyService registryProxyService - def 'should check manifest exist' () { + def 'should retrieve image digest' () { given: - def IMAGE = 'library/hello-world:latest' + def IMAGE = 'library/hello-world@sha256:6352af1ab4ba4b138648f8ee88e63331aae519946d3b67dae50c313c6fc8200f' + def request = Mock(BuildRequest) when: - def resp1 = registryProxyService.isManifestPresent(IMAGE) - + def resp1 = registryProxyService.getImageDigest(request) then: - resp1 + request.getTargetImage() >> IMAGE + then: + resp1 == 'sha256:6352af1ab4ba4b138648f8ee88e63331aae519946d3b67dae50c313c6fc8200f' } - def 'should retrieve image digest' () { + @Requires({System.getenv('AWS_ACCESS_KEY_ID') && System.getenv('AWS_SECRET_ACCESS_KEY')}) + def 'should retrieve image digest on ECR' () { given: - def IMAGE = 'library/hello-world@sha256:6352af1ab4ba4b138648f8ee88e63331aae519946d3b67dae50c313c6fc8200f' + def IMAGE = '195996028523.dkr.ecr.eu-west-1.amazonaws.com/wave/kaniko:0.1.0' + def request = Mock(BuildRequest) when: - def resp1 = registryProxyService.getImageDigest(IMAGE) - + def resp1 = registryProxyService.getImageDigest(request) then: - resp1 == 'sha256:6352af1ab4ba4b138648f8ee88e63331aae519946d3b67dae50c313c6fc8200f' + request.getTargetImage() >> IMAGE + request.getIdentity() >> new PlatformId() + then: + resp1 == 'sha256:05f9dc67e6ec879773de726b800d4d5044f8bd8e67da728484fbdea56af1fdff' } } diff --git a/src/test/groovy/io/seqera/wave/core/RoutePathTest.groovy b/src/test/groovy/io/seqera/wave/core/RoutePathTest.groovy index 365878ee2..db9bde60a 100644 --- a/src/test/groovy/io/seqera/wave/core/RoutePathTest.groovy +++ b/src/test/groovy/io/seqera/wave/core/RoutePathTest.groovy @@ -19,12 +19,12 @@ package io.seqera.wave.core import spock.lang.Specification +import spock.lang.Unroll import io.seqera.wave.model.ContainerCoordinates import io.seqera.wave.service.ContainerRequestData import io.seqera.wave.tower.PlatformId import io.seqera.wave.tower.User - /** * * @author Paolo Di Tommaso @@ -72,6 +72,7 @@ class RoutePathTest extends Specification { } + @Unroll def 'should get manifest path'() { expect: RoutePath.v2manifestPath(ContainerCoordinates.parse(CONTAINER)).path == PATH @@ -82,6 +83,20 @@ class RoutePathTest extends Specification { 'quay.io/foo/bar:v1.0' | '/v2/foo/bar/manifests/v1.0' } + def 'should get manifest path with identity'() { + given: + def CONTAINER = ContainerCoordinates.parse('quay.io/foo/bar:v1.0') + def PATH = '/v2/foo/bar/manifests/v1.0' + def IDENTITY = new PlatformId(new User(id: 1, email: 'paolo@seqera.io'), 2, 'xyz') + + expect: + RoutePath.v2manifestPath(CONTAINER).path == PATH + RoutePath.v2manifestPath(CONTAINER).identity == PlatformId.NULL + and: + RoutePath.v2manifestPath(CONTAINER, IDENTITY).path == PATH + RoutePath.v2manifestPath(CONTAINER, IDENTITY).identity == IDENTITY + } + def 'should parse location' () { expect: RoutePath.parse(GIVEN) == RoutePath.v2path(TYPE, REG, IMAGE, REF) diff --git a/src/test/groovy/io/seqera/wave/proxy/ProxyClientTest.groovy b/src/test/groovy/io/seqera/wave/proxy/ProxyClientTest.groovy index f44ad85c6..a3e3328e3 100644 --- a/src/test/groovy/io/seqera/wave/proxy/ProxyClientTest.groovy +++ b/src/test/groovy/io/seqera/wave/proxy/ProxyClientTest.groovy @@ -163,6 +163,27 @@ class ProxyClientTest extends Specification { resp.statusCode() == 200 } + @Requires({System.getenv('AWS_ACCESS_KEY_ID') && System.getenv('AWS_SECRET_ACCESS_KEY')}) + def 'should call head manifest on amazon' () { + given: + def IMAGE = 'wave/kaniko' + def REG = '195996028523.dkr.ecr.eu-west-1.amazonaws.com' + def registry = lookupService.lookup(REG) + def creds = credentialsProvider.getDefaultCredentials(REG) + def httpClient = HttpClientFactory.neverRedirectsHttpClient() + and: + def proxy = new ProxyClient(httpClient, httpConfig) + .withImage(IMAGE) + .withRegistry(registry) + .withLoginService(loginService) + .withCredentials(creds) + + when: + def resp = proxy.head("/v2/$IMAGE/manifests/0.1.0") + then: + resp.statusCode() == 200 + } + @Requires({System.getenv('AWS_ACCESS_KEY_ID') && System.getenv('AWS_SECRET_ACCESS_KEY')}) def 'should call target manifest on ecr public' () { given: 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/BlobCacheServiceImplTest.groovy b/src/test/groovy/io/seqera/wave/service/blob/impl/BlobCacheServiceImplTest.groovy index a9cc2116d..e7d8a5b31 100644 --- a/src/test/groovy/io/seqera/wave/service/blob/impl/BlobCacheServiceImplTest.groovy +++ b/src/test/groovy/io/seqera/wave/service/blob/impl/BlobCacheServiceImplTest.groovy @@ -19,12 +19,21 @@ package io.seqera.wave.service.blob.impl import spock.lang.Specification +import java.util.concurrent.ExecutorService + import io.seqera.wave.configuration.BlobCacheConfig import io.seqera.wave.core.RegistryProxyService import io.seqera.wave.core.RoutePath import io.seqera.wave.model.ContainerCoordinates import io.seqera.wave.service.blob.BlobCacheInfo +import io.seqera.wave.service.blob.BlobStore import io.seqera.wave.test.AwsS3TestContainer +import software.amazon.awssdk.services.s3.S3Client +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest +import software.amazon.awssdk.services.s3.model.HeadObjectRequest +import software.amazon.awssdk.services.s3.model.HeadObjectResponse +import software.amazon.awssdk.services.s3.model.S3Exception + /** * * @author Paolo Di Tommaso @@ -82,4 +91,114 @@ class BlobCacheServiceImplTest extends Specification implements AwsS3TestContain ] } + def 'should return blob size when blob exists'() { + given: + def bucket = 's3://my-cache-bucket' + def expectedSize = 1024 + def s3Client = Mock(S3Client) + def blobCacheService = new BlobCacheServiceImpl(s3Client: s3Client, blobConfig: new BlobCacheConfig(storageBucket: bucket)) + and: + def route = Mock(RoutePath) { + getTargetPath() >> 'docker.io/repo/container/latest' + } + and: + final request = + HeadObjectRequest.builder() + .bucket('my-cache-bucket') + .key('docker.io/repo/container/latest') + .build() + + when: + def size = blobCacheService.getBlobSize(route) + + then: + 1 * s3Client.headObject(_) >> HeadObjectResponse.builder().contentLength(expectedSize).build() + and: + size == expectedSize + } + + def 'should return zero when blob does not exist'() { + given: + def bucket = 's3://my-cache-bucket' + def s3Client = Mock(S3Client) + def blobCacheService = new BlobCacheServiceImpl(s3Client: s3Client, blobConfig: new BlobCacheConfig(storageBucket: bucket)) + and: + def route = Mock(RoutePath) { + getTargetPath() >> 'docker.io/repo/container/latest' + } + and: + final request = + HeadObjectRequest.builder() + .bucket('my-cache-bucket') + .key('docker.io/repo/container/latest') + .build() + + when: + def size = blobCacheService.getBlobSize(route) + + then: + 1 * s3Client.headObject(request) >> { throw S3Exception.builder().message('Not Found').build() } + and: + size == -1L + } + + def 'should delete blob when blob exists'() { + given: + def bucket = 's3://my-cache-bucket/base/dir' + def s3Client = Mock(S3Client) + def blobCacheService = new BlobCacheServiceImpl(s3Client: s3Client, blobConfig: new BlobCacheConfig(storageBucket: bucket)) + and: + def route = Mock(RoutePath) { + getTargetPath() >> 'docker.io/repo/container/latest' + } + and: + def request = DeleteObjectRequest.builder() + .bucket('my-cache-bucket') + .key('base/dir/docker.io/repo/container/latest') + .build() + + when: + blobCacheService.deleteBlob(route) + then: + 1 * s3Client.deleteObject(request) >> { } + } + + def 'should return failed BlobCacheInfo when blob size mismatch'() { + given: + def executor = Mock(ExecutorService) + def s3Client = Mock(S3Client) + s3Client.headObject(_) >> HeadObjectResponse.builder().contentLength(1234L).build() + def blobStore = Mock(BlobStore) + def blobCacheService = new BlobCacheServiceImpl(s3Client: s3Client, blobConfig: new BlobCacheConfig(storageBucket: 's3://store/blobs/'), blobStore: blobStore, executor: executor, ) + def route = RoutePath.v2manifestPath(ContainerCoordinates.parse('ubuntu@sha256:aabbcc')) + def info = BlobCacheInfo.create('http://foo', [:], ['Content-Type':['foo'], 'Cache-Control': ['bar'], 'Content-Length': ['4321']]) + info = info.completed(0, 'Blob uploaded') + + when: + def result = blobCacheService.checkUploadedBlobSize(info, route) + + then: + !result.succeeded() + result.logs == "Mismatch cache size for object http://foo" + } + + def 'should return succeeded BlobCacheInfo when blob size matches'() { + given: + def executor = Mock(ExecutorService) + def s3Client = Mock(S3Client) + s3Client.headObject(_) >> HeadObjectResponse.builder().contentLength(4321L).build() + def blobStore = Mock(BlobStore) + def blobCacheService = new BlobCacheServiceImpl(s3Client: s3Client, blobConfig: new BlobCacheConfig(storageBucket: 's3://store/blobs/'), blobStore: blobStore, executor: executor) + def route = RoutePath.v2manifestPath(ContainerCoordinates.parse('ubuntu@sha256:aabbcc')) + def info = BlobCacheInfo.create('http://foo', [:], ['Content-Type':['foo'], 'Cache-Control': ['bar'], 'Content-Length': ['4321']]) + info = info.completed(0, 'Blob uploaded') + + when: + def result = blobCacheService.checkUploadedBlobSize(info, route) + + then: + result.succeeded() + result.logs == "Blob uploaded" + } + } diff --git a/src/test/groovy/io/seqera/wave/service/blob/impl/BlobCacheServiceImplTest2.groovy b/src/test/groovy/io/seqera/wave/service/blob/impl/BlobCacheServiceImplTest2.groovy index 5d8748140..0aeabf0fa 100644 --- a/src/test/groovy/io/seqera/wave/service/blob/impl/BlobCacheServiceImplTest2.groovy +++ b/src/test/groovy/io/seqera/wave/service/blob/impl/BlobCacheServiceImplTest2.groovy @@ -25,7 +25,6 @@ import io.micronaut.context.ApplicationContext import io.seqera.wave.core.RoutePath import io.seqera.wave.model.ContainerCoordinates import io.seqera.wave.test.AwsS3TestContainer - /** * * @author Paolo Di Tommaso @@ -43,7 +42,9 @@ class BlobCacheServiceImplTest2 extends Specification implements AwsS3TestContai 'wave.blobCache.storage.bucket': BUCKET, 'wave.blobCache.baseUrl': BASE_URL, 'wave.blobCache.storage.region': 'eu-west-1', - 'wave.blobCache.storage.endpoint': testEndpoint + 'wave.blobCache.storage.endpoint': testEndpoint, + 'wave.blobCache.storage.accessKey': 'accessKey', + 'wave.blobCache.storage.secretKey': 'secretKey' ] def ctx = ApplicationContext.run(PROPS) def service = ctx.getBean(BlobCacheServiceImpl) @@ -88,7 +89,9 @@ class BlobCacheServiceImplTest2 extends Specification implements AwsS3TestContai 'wave.blobCache.storage.bucket': BUCKET, 'wave.blobCache.baseUrl': BASE_URL, 'wave.blobCache.storage.region': 'eu-west-1', - 'wave.blobCache.storage.endpoint': testEndpoint + 'wave.blobCache.storage.endpoint': testEndpoint, + 'wave.blobCache.storage.accessKey': 'accessKey', + 'wave.blobCache.storage.secretKey': 'secretKey' ] def ctx = ApplicationContext.run(PROPS) def service = ctx.getBean(BlobCacheServiceImpl) 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 new file mode 100644 index 000000000..d546baa72 --- /dev/null +++ b/src/test/groovy/io/seqera/wave/service/blob/impl/KubeTransferStrategyTest.groovy @@ -0,0 +1,82 @@ +/* + * 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.blob.impl + +import spock.lang.Specification + +import java.time.Duration +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 +/** + * + * @author Munish Chouhan + */ +class KubeTransferStrategyTest extends Specification { + + 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" + } + +} diff --git a/src/test/groovy/io/seqera/wave/service/builder/ContainerBuildServiceTest.groovy b/src/test/groovy/io/seqera/wave/service/builder/ContainerBuildServiceTest.groovy index 72cd7abdc..8002de2b3 100644 --- a/src/test/groovy/io/seqera/wave/service/builder/ContainerBuildServiceTest.groovy +++ b/src/test/groovy/io/seqera/wave/service/builder/ContainerBuildServiceTest.groovy @@ -93,8 +93,6 @@ class ContainerBuildServiceTest extends Specification implements RedisTestContai when: def result = service.launch(req) - and: - println result.logs then: result.id result.startTime @@ -589,4 +587,13 @@ class ContainerBuildServiceTest extends Specification implements RedisTestContai record2.buildId == request.buildId record2.digest == 'abc123' } + + def 'should return only the host name' () { + expect: + ContainerInspectServiceImpl.host0(CONTAINER) == EXPECTED + where: + CONTAINER | EXPECTED + 'docker.io' | 'docker.io' + 'docker.io/foo/'| 'docker.io' + } } diff --git a/src/test/groovy/io/seqera/wave/service/builder/DockerBuildStrategyTest.groovy b/src/test/groovy/io/seqera/wave/service/builder/DockerBuildStrategyTest.groovy index 7ff0e1eba..14aa3d4cb 100644 --- a/src/test/groovy/io/seqera/wave/service/builder/DockerBuildStrategyTest.groovy +++ b/src/test/groovy/io/seqera/wave/service/builder/DockerBuildStrategyTest.groovy @@ -57,7 +57,7 @@ class DockerBuildStrategyTest extends Specification { '--entrypoint', 'buildctl-daemonless.sh', '--name', 'build-1234', - 'moby/buildkit:v0.13.2-rootless'] + 'moby/buildkit:v0.14.1-rootless'] when: cmd = service.cmdForBuildkit(work, Path.of('/foo/creds.json'), null, ContainerPlatform.of('arm64'), '1234') @@ -72,7 +72,7 @@ class DockerBuildStrategyTest extends Specification { '-v', '/foo/creds.json:/home/user/.docker/config.json:ro', '--platform', 'linux/arm64', '--name', 'build-1234', - 'moby/buildkit:v0.13.2-rootless'] + 'moby/buildkit:v0.14.1-rootless'] when: cmd = service.cmdForBuildkit(work, Path.of('/foo/creds.json'), spackConfig, null, '1234') @@ -87,7 +87,7 @@ class DockerBuildStrategyTest extends Specification { '-v', '/foo/creds.json:/home/user/.docker/config.json:ro', '-v', '/host/spack/key:/opt/spack/key:ro', '--name', 'build-1234', - 'moby/buildkit:v0.13.2-rootless'] + 'moby/buildkit:v0.14.1-rootless'] cleanup: ctx.close() @@ -120,7 +120,7 @@ class DockerBuildStrategyTest extends Specification { '-v', '/work/creds.json:/home/user/.docker/config.json:ro', '--platform', 'linux/amd64', '--name', 'build-1234', - 'moby/buildkit:v0.13.2-rootless', + 'moby/buildkit:v0.14.1-rootless', 'build', '--frontend', 'dockerfile.v0', diff --git a/src/test/groovy/io/seqera/wave/service/builder/KubeBuildStrategyTest.groovy b/src/test/groovy/io/seqera/wave/service/builder/KubeBuildStrategyTest.groovy index 356e0bcb6..45b70a1c6 100644 --- a/src/test/groovy/io/seqera/wave/service/builder/KubeBuildStrategyTest.groovy +++ b/src/test/groovy/io/seqera/wave/service/builder/KubeBuildStrategyTest.groovy @@ -104,7 +104,7 @@ class KubeBuildStrategyTest extends Specification { def req = new BuildRequest(containerId, dockerfile, null, null, PATH, targetImage, USER, ContainerPlatform.of('amd64'), cache, "10.20.30.40", '{"config":"json"}', null,null , null, null, BuildFormat.DOCKER).withBuildId('1') then: 'should return buildkit image' - strategy.getBuildImage(req) == 'moby/buildkit:v0.13.2-rootless' + strategy.getBuildImage(req) == 'moby/buildkit:v0.14.1-rootless' when:'getting singularity with amd64 arch in build request' req = new BuildRequest(containerId, dockerfile, null, null, PATH, targetImage, USER, ContainerPlatform.of('amd64'), cache, "10.20.30.40", '{}', null,null , null, null, BuildFormat.SINGULARITY).withBuildId('1') diff --git a/src/test/groovy/io/seqera/wave/service/inspect/ContainerInspectServiceImplTest.groovy b/src/test/groovy/io/seqera/wave/service/inspect/ContainerInspectServiceImplTest.groovy index 09c2a4397..2a8301acb 100644 --- a/src/test/groovy/io/seqera/wave/service/inspect/ContainerInspectServiceImplTest.groovy +++ b/src/test/groovy/io/seqera/wave/service/inspect/ContainerInspectServiceImplTest.groovy @@ -90,11 +90,11 @@ class ContainerInspectServiceImplTest extends Specification { and: ContainerInspectServiceImpl.findRepositories(''' - FROM moby/buildkit:v0.13.2-rootless AS bkt + FROM moby/buildkit:v0.14.1-rootless AS bkt RUN this and that FROM amazoncorretto:17.0.4 COPY --from=bkt /usr/bin/buildctl /usr/bin/buildctl - ''') == ['moby/buildkit:v0.13.2-rootless', 'amazoncorretto:17.0.4'] + ''') == ['moby/buildkit:v0.14.1-rootless', 'amazoncorretto:17.0.4'] } 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 6a9ff4ccd..f03d30b32 100644 --- a/src/test/groovy/io/seqera/wave/service/k8s/K8sServiceImplTest.groovy +++ b/src/test/groovy/io/seqera/wave/service/k8s/K8sServiceImplTest.groovy @@ -24,8 +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 @@ -590,4 +593,57 @@ class K8sServiceImplTest extends Specification { String resultString = result.text resultString == "INFO: Build is in progress" } + + 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/src/test/groovy/io/seqera/wave/service/metric/impl/MetricsServiceImplTest.groovy b/src/test/groovy/io/seqera/wave/service/metric/impl/MetricsServiceImplTest.groovy index 23c9335f1..87389577e 100644 --- a/src/test/groovy/io/seqera/wave/service/metric/impl/MetricsServiceImplTest.groovy +++ b/src/test/groovy/io/seqera/wave/service/metric/impl/MetricsServiceImplTest.groovy @@ -25,15 +25,12 @@ import java.time.LocalDate import java.time.format.DateTimeFormatter import io.seqera.wave.service.counter.impl.LocalCounterProvider -import io.seqera.wave.service.counter.impl.RedisCounterProvider -import io.seqera.wave.service.metric.MetricConstants import io.seqera.wave.service.metric.MetricsCounterStore import io.seqera.wave.test.RedisTestContainer import io.seqera.wave.tower.PlatformId import io.seqera.wave.tower.User -import org.testcontainers.shaded.org.bouncycastle.cms.OriginatorInfoGenerator -import software.amazon.awssdk.regions.servicemetadata.OrganizationsServiceMetadata +import static io.seqera.wave.service.metric.MetricsConstants.* /** * * @author Munish Chouhan @@ -59,19 +56,19 @@ class MetricsServiceImplTest extends Specification implements RedisTestContainer metricsService.incrementBuildsCounter(null) then: - def res1 = metricsService.getOrgCount(MetricConstants.PREFIX_BUILDS, date, null) + def res1 = metricsService.getOrgCount(PREFIX_BUILDS, date, null) res1.count == 3 res1.orgs == ['org1.com': 1, 'org2.com': 1] and: - def res2 = metricsService.getOrgCount(MetricConstants.PREFIX_BUILDS, null, 'org1.com') + def res2 = metricsService.getOrgCount(PREFIX_BUILDS, null, 'org1.com') res2.count == 1 res2.orgs == ['org1.com': 1] and: - def res3 = metricsService.getOrgCount(MetricConstants.PREFIX_BUILDS, date, 'org2.com') + def res3 = metricsService.getOrgCount(PREFIX_BUILDS, date, 'org2.com') res3.count == 1 res3.orgs == ['org2.com': 1] and: - def res4 = metricsService.getOrgCount(MetricConstants.PREFIX_BUILDS, date, null) + def res4 = metricsService.getOrgCount(PREFIX_BUILDS, date, null) res4.count == 3 res4.orgs == ['org1.com': 1, 'org2.com': 1] } @@ -93,15 +90,15 @@ class MetricsServiceImplTest extends Specification implements RedisTestContainer metricsService.incrementPullsCounter(null) then: - def res1 = metricsService.getOrgCount(MetricConstants.PREFIX_PULLS, null, 'org1.com') + def res1 = metricsService.getOrgCount(PREFIX_PULLS, null, 'org1.com') res1.count == 1 res1.orgs == ['org1.com': 1] and: - def res2 = metricsService.getOrgCount(MetricConstants.PREFIX_PULLS,date, 'org2.com') + def res2 = metricsService.getOrgCount(PREFIX_PULLS,date, 'org2.com') res2.count == 1 res2.orgs == ['org2.com': 1] and: - def res3 = metricsService.getOrgCount(MetricConstants.PREFIX_PULLS,date, null) + def res3 = metricsService.getOrgCount(PREFIX_PULLS,date, null) res3.count == 3 res3.orgs == ['org1.com': 1, 'org2.com': 1] } @@ -123,15 +120,15 @@ class MetricsServiceImplTest extends Specification implements RedisTestContainer metricsService.incrementFusionPullsCounter(null) then: - def res1 = metricsService.getOrgCount(MetricConstants.PREFIX_FUSION,null, 'org1.com') + def res1 = metricsService.getOrgCount(PREFIX_FUSION,null, 'org1.com') res1.count == 1 res1.orgs == ['org1.com': 1] and: - def res2 = metricsService.getOrgCount(MetricConstants.PREFIX_FUSION, date, 'org2.com') + def res2 = metricsService.getOrgCount(PREFIX_FUSION, date, 'org2.com') res2.count == 1 res2.orgs == ['org2.com': 1] and: - def res3 = metricsService.getOrgCount(MetricConstants.PREFIX_FUSION, date, null) + def res3 = metricsService.getOrgCount(PREFIX_FUSION, date, null) res3.count == 3 res3.orgs == ['org1.com': 1, 'org2.com': 1] } @@ -143,18 +140,18 @@ class MetricsServiceImplTest extends Specification implements RedisTestContainer where: PREFIX | DAY | ORG | KEY - MetricConstants.PREFIX_BUILDS | null | null | null - MetricConstants.PREFIX_BUILDS | null | 'wave' | 'builds/o/wave' - MetricConstants.PREFIX_BUILDS | '2024-03-25' | 'wave' | 'builds/o/wave/d/2024-03-25' - MetricConstants.PREFIX_BUILDS | '2024-03-25' | null | 'builds/d/2024-03-25' - MetricConstants.PREFIX_PULLS | null | null | null - MetricConstants.PREFIX_PULLS | null | 'wave' | 'pulls/o/wave' - MetricConstants.PREFIX_PULLS | '2024-03-25' | 'wave' | 'pulls/o/wave/d/2024-03-25' - MetricConstants.PREFIX_PULLS | '2024-03-25' | null | 'pulls/d/2024-03-25' - MetricConstants.PREFIX_FUSION | null | null | null - MetricConstants.PREFIX_FUSION | null | 'wave' | 'fusion/o/wave' - MetricConstants.PREFIX_FUSION | '2024-03-25' | 'wave' | 'fusion/o/wave/d/2024-03-25' - MetricConstants.PREFIX_FUSION | '2024-03-25' | null | 'fusion/d/2024-03-25' + PREFIX_BUILDS | null | null | null + PREFIX_BUILDS | null | 'wave' | 'builds/o/wave' + PREFIX_BUILDS | '2024-03-25' | 'wave' | 'builds/o/wave/d/2024-03-25' + PREFIX_BUILDS | '2024-03-25' | null | 'builds/d/2024-03-25' + PREFIX_PULLS | null | null | null + PREFIX_PULLS | null | 'wave' | 'pulls/o/wave' + PREFIX_PULLS | '2024-03-25' | 'wave' | 'pulls/o/wave/d/2024-03-25' + PREFIX_PULLS | '2024-03-25' | null | 'pulls/d/2024-03-25' + PREFIX_FUSION | null | null | null + PREFIX_FUSION | null | 'wave' | 'fusion/o/wave' + PREFIX_FUSION | '2024-03-25' | 'wave' | 'fusion/o/wave/d/2024-03-25' + PREFIX_FUSION | '2024-03-25' | null | 'fusion/d/2024-03-25' } @Unroll @@ -192,21 +189,21 @@ class MetricsServiceImplTest extends Specification implements RedisTestContainer metricsService.incrementFusionPullsCounter(platformId2) metricsService.incrementFusionPullsCounter(null) and: - def buildOrgCounts = metricsService.getAllOrgCount(MetricConstants.PREFIX_BUILDS) - def pullOrgCounts = metricsService.getAllOrgCount(MetricConstants.PREFIX_PULLS) - def fusionOrgCounts = metricsService.getAllOrgCount(MetricConstants.PREFIX_FUSION) + def buildOrgCounts = metricsService.getAllOrgCount(PREFIX_BUILDS) + def pullOrgCounts = metricsService.getAllOrgCount(PREFIX_PULLS) + def fusionOrgCounts = metricsService.getAllOrgCount(PREFIX_FUSION) def emptyOrgCounts = metricsService.getAllOrgCount(null) then: - buildOrgCounts.metric == MetricConstants.PREFIX_BUILDS + buildOrgCounts.metric == PREFIX_BUILDS buildOrgCounts.count == 2 buildOrgCounts.orgs == ['org1.com': 1, 'org2.com': 1] and: - pullOrgCounts.metric == MetricConstants.PREFIX_PULLS + pullOrgCounts.metric == PREFIX_PULLS pullOrgCounts.count == 2 pullOrgCounts.orgs == ['org1.com': 1, 'org2.com': 1] and: - fusionOrgCounts.metric == MetricConstants.PREFIX_FUSION + fusionOrgCounts.metric == PREFIX_FUSION fusionOrgCounts.count == 2 fusionOrgCounts.orgs == ['org1.com': 1, 'org2.com': 1] and: @@ -237,21 +234,21 @@ class MetricsServiceImplTest extends Specification implements RedisTestContainer metricsService.incrementFusionPullsCounter(platformId2) metricsService.incrementFusionPullsCounter(null) and: - def buildOrgCounts = metricsService.getOrgCount(MetricConstants.PREFIX_BUILDS, date, null) - def pullOrgCounts = metricsService.getOrgCount(MetricConstants.PREFIX_PULLS, date, null) - def fusionOrgCounts = metricsService.getOrgCount(MetricConstants.PREFIX_FUSION, date, null) + def buildOrgCounts = metricsService.getOrgCount(PREFIX_BUILDS, date, null) + def pullOrgCounts = metricsService.getOrgCount(PREFIX_PULLS, date, null) + def fusionOrgCounts = metricsService.getOrgCount(PREFIX_FUSION, date, null) def emptyOrgCounts = metricsService.getOrgCount(null, date, null) then: - buildOrgCounts.metric == MetricConstants.PREFIX_BUILDS + buildOrgCounts.metric == PREFIX_BUILDS buildOrgCounts.count == 3 buildOrgCounts.orgs == ['org1.com': 1, 'org2.com': 1] and: - pullOrgCounts.metric == MetricConstants.PREFIX_PULLS + pullOrgCounts.metric == PREFIX_PULLS pullOrgCounts.count == 3 pullOrgCounts.orgs == ['org1.com': 1, 'org2.com': 1] and: - fusionOrgCounts.metric == MetricConstants.PREFIX_FUSION + fusionOrgCounts.metric == PREFIX_FUSION fusionOrgCounts.count == 3 fusionOrgCounts.orgs == ['org1.com': 1, 'org2.com': 1] and: diff --git a/src/test/groovy/io/seqera/wave/test/AwsS3TestContainer.groovy b/src/test/groovy/io/seqera/wave/test/AwsS3TestContainer.groovy index 77954fe4b..3ba68147f 100644 --- a/src/test/groovy/io/seqera/wave/test/AwsS3TestContainer.groovy +++ b/src/test/groovy/io/seqera/wave/test/AwsS3TestContainer.groovy @@ -40,8 +40,7 @@ trait AwsS3TestContainer { awsS3Container.start() log.debug "Started AWS S3 test container" } - - + String getAwsS3HostName(){ awsS3Container.getHost() } diff --git a/src/test/groovy/io/seqera/wave/tower/client/TowerClientHttpTest.groovy b/src/test/groovy/io/seqera/wave/tower/client/TowerClientHttpTest.groovy index ea70837c3..477edef5d 100644 --- a/src/test/groovy/io/seqera/wave/tower/client/TowerClientHttpTest.groovy +++ b/src/test/groovy/io/seqera/wave/tower/client/TowerClientHttpTest.groovy @@ -131,8 +131,8 @@ class TowerClientHttpTest extends Specification{ def setup() { jwtAuthStore.clear() - cacheManager.getCache("cache-1min").invalidateAll() - cacheManager.getCache("cache-20sec").invalidateAll() + cacheManager.getCache("cache-tower-client").invalidateAll() + cacheManager.getCache("cache-registry-proxy").invalidateAll() towerConnector.refreshCache0().invalidateAll() } diff --git a/typespec/main.tsp b/typespec/main.tsp new file mode 100644 index 000000000..6633a9e30 --- /dev/null +++ b/typespec/main.tsp @@ -0,0 +1,4 @@ +import "@typespec/http"; +import "@typespec/rest"; +import "@typespec/openapi3"; +import "./routes.tsp"; diff --git a/typespec/models/BuildStatusResponse.tsp b/typespec/models/BuildStatusResponse.tsp new file mode 100644 index 000000000..18da9ea1c --- /dev/null +++ b/typespec/models/BuildStatusResponse.tsp @@ -0,0 +1,8 @@ +@doc("Response payload for build status.") +model BuildStatusResponse { + duration: string; + id: string; + startTime: string; + status: "PENDING" | "COMPLETED"; + succeeded: boolean; +} diff --git a/typespec/models/CondaOpts.tsp b/typespec/models/CondaOpts.tsp new file mode 100644 index 000000000..f1c94356a --- /dev/null +++ b/typespec/models/CondaOpts.tsp @@ -0,0 +1,6 @@ +@doc("Options for Conda environments.") +model CondaOpts { + basePackages: string; + commands: string[]; + mambaImage: string; +} \ No newline at end of file diff --git a/typespec/models/ContainerConfig.tsp b/typespec/models/ContainerConfig.tsp new file mode 100644 index 000000000..834297775 --- /dev/null +++ b/typespec/models/ContainerConfig.tsp @@ -0,0 +1,10 @@ +import "./ContainerLayer.tsp"; + +@doc("Configuration details for a container.") +model ContainerConfig { + cmd: string[]; + entrypoint: string[]; + env: string[]; + layers: ContainerLayer[]; + workingDir: string; +} diff --git a/typespec/models/ContainerInspectConfig.tsp b/typespec/models/ContainerInspectConfig.tsp new file mode 100644 index 000000000..bf59c40e9 --- /dev/null +++ b/typespec/models/ContainerInspectConfig.tsp @@ -0,0 +1,18 @@ +import "./RootFS.tsp"; + +@doc("Configuration details of a container.") +model Config { + architecture: string; + config: { + attachStdin: boolean; + attachStdout: boolean; + attachStderr: boolean; + tty: boolean; + env: string[]; + cmd: string[]; + image: string; + }; + container: string; + created: string; + rootfs: RootFS; +} \ No newline at end of file diff --git a/typespec/models/ContainerInspectRequest.tsp b/typespec/models/ContainerInspectRequest.tsp new file mode 100644 index 000000000..00e2dd23e --- /dev/null +++ b/typespec/models/ContainerInspectRequest.tsp @@ -0,0 +1,7 @@ +@doc("Request payload for inspecting a container.") +model ContainerInspectRequest { + containerImage: string; + towerAccessToken: string; + towerEndpoint: string; + towerWorkspaceId: int64; +} \ No newline at end of file diff --git a/typespec/models/ContainerInspectResponse.tsp b/typespec/models/ContainerInspectResponse.tsp new file mode 100644 index 000000000..6aee1cb04 --- /dev/null +++ b/typespec/models/ContainerInspectResponse.tsp @@ -0,0 +1,18 @@ +import "./ContainerInspectConfig.tsp"; +import "./Manifest.tsp"; + +@doc("Response payload for inspecting a container.") +model ContainerInspectResponse { + Container: { + registry: string; + hostName: string; + imageName: string; + reference: string; + digest: string; + config: Config; + manifest: Manifest; + v1: boolean; + v2: boolean; + oci: boolean; + } +} diff --git a/typespec/models/ContainerLayer.tsp b/typespec/models/ContainerLayer.tsp new file mode 100644 index 000000000..873cb4c84 --- /dev/null +++ b/typespec/models/ContainerLayer.tsp @@ -0,0 +1,8 @@ +@doc("Represents a layer in a container image.") +model ContainerLayer { + gzipDigest: string; + gzipSize: string; + location: string; + skipHashing: boolean; + tarDigest: string; +} diff --git a/typespec/models/ContainerRequest.tsp b/typespec/models/ContainerRequest.tsp new file mode 100644 index 000000000..666feee43 --- /dev/null +++ b/typespec/models/ContainerRequest.tsp @@ -0,0 +1,26 @@ +import "./ContainerConfig.tsp"; +import "./Packages.tsp"; + +@doc("Request payload for creating a container token.") +model ContainerRequest { + buildContext: ContainerLayer; + buildRepository?: string; + cacheRepository?: string; + containerConfig: ContainerConfig; + containerFile?: string; + containerImage: string; + containerIncludes: string[]; + containerPlatform: string; + dryRun: boolean; + fingerprint?: string; + format: "sif" | "docker"; + freeze?: boolean; + nameStrategy?: "none" | "tagPrefix" | "imageSuffix"; + packages?: Packages; + timestamp: string; + towerAccessToken?: string; + towerEndpoint?: string; + towerRefreshToken?: string; + towerWorkspaceId?: int32; + workflowId: string; +} diff --git a/typespec/models/ContainerResponse.tsp b/typespec/models/ContainerResponse.tsp new file mode 100644 index 000000000..649d64048 --- /dev/null +++ b/typespec/models/ContainerResponse.tsp @@ -0,0 +1,10 @@ +@doc("Response payload for container token creation.") +model ContainerResponse { + buildId: string; + cached: boolean; + containerImage: string; + containerToken: string; + expiration: string; + freeze?: boolean; + targetImage: string; +} \ No newline at end of file diff --git a/typespec/models/Manifest.tsp b/typespec/models/Manifest.tsp new file mode 100644 index 000000000..60f93bcc2 --- /dev/null +++ b/typespec/models/Manifest.tsp @@ -0,0 +1,13 @@ +import "./ManifestLayer.tsp"; + +@doc("Manifest details of a container.") +model Manifest { + config: { + digest: string; + mediaType: string; + size: int64; + }; + layers: ManifestLayer[]; + mediaType: string; + schemaVersion: int32; +} \ No newline at end of file diff --git a/typespec/models/ManifestLayer.tsp b/typespec/models/ManifestLayer.tsp new file mode 100644 index 000000000..418904eb9 --- /dev/null +++ b/typespec/models/ManifestLayer.tsp @@ -0,0 +1,6 @@ +@doc("Manifest layer details of a container.") +model ManifestLayer { + digest: string; + mediaType: string; + size: int64; +} \ No newline at end of file diff --git a/typespec/models/MetricsResponse.tsp b/typespec/models/MetricsResponse.tsp new file mode 100644 index 000000000..f17da0d26 --- /dev/null +++ b/typespec/models/MetricsResponse.tsp @@ -0,0 +1,11 @@ +@doc("Response payload for metrics.") +model MetricsResponse { + count: int64; + metric: "builds" | "fusion" | "pulls"; + orgs: Orgs; +} + +model Orgs { + key: string; + value: int64; +} \ No newline at end of file diff --git a/typespec/models/Packages.tsp b/typespec/models/Packages.tsp new file mode 100644 index 000000000..6fa59e16d --- /dev/null +++ b/typespec/models/Packages.tsp @@ -0,0 +1,12 @@ +import "./CondaOpts.tsp"; +import "./SpackOpts.tsp"; + +@doc("Package configurations for container builds.") +model Packages { + channels: string[]; + condaOpts?: CondaOpts; + entries: string[]; + environment: string; + spackOpts?: SpackOpts; + type: "CONDA" | "SPACK"; +} \ No newline at end of file diff --git a/typespec/models/RootFS.tsp b/typespec/models/RootFS.tsp new file mode 100644 index 000000000..2ec6be063 --- /dev/null +++ b/typespec/models/RootFS.tsp @@ -0,0 +1,5 @@ +@doc("Details about the root filesystem of a container.") +model RootFS { + diff_ids: string[]; + type: string; +} diff --git a/typespec/models/SpackOpts.tsp b/typespec/models/SpackOpts.tsp new file mode 100644 index 000000000..813caa6a2 --- /dev/null +++ b/typespec/models/SpackOpts.tsp @@ -0,0 +1,5 @@ +@doc("Options for Spack environments. Spack support will be removed in future releases") +model SpackOpts { + basePackages: string; + commands: string[]; +} diff --git a/typespec/models/ValidateRegistryCredsRequest.tsp b/typespec/models/ValidateRegistryCredsRequest.tsp new file mode 100644 index 000000000..c1ef4ba95 --- /dev/null +++ b/typespec/models/ValidateRegistryCredsRequest.tsp @@ -0,0 +1,7 @@ +@doc("request payload of validate credentials request") +model ValidateRegistryCredsRequest { + password: string; + registry: string; + userName: string; + } + \ No newline at end of file diff --git a/typespec/models/Vulnerability.tsp b/typespec/models/Vulnerability.tsp new file mode 100644 index 000000000..9a4e5339c --- /dev/null +++ b/typespec/models/Vulnerability.tsp @@ -0,0 +1,10 @@ +@doc("Scan Vulnerability details") +model Vulnerability { + fixedVersion: string; + id: string; + installedVersion: string; + pkgName: string; + primaryUrl: string; + severity: string; + title: string; + } \ No newline at end of file diff --git a/typespec/models/WaveBuildRecord.tsp b/typespec/models/WaveBuildRecord.tsp new file mode 100644 index 000000000..f8e40bc4c --- /dev/null +++ b/typespec/models/WaveBuildRecord.tsp @@ -0,0 +1,21 @@ +model WaveBuildRecord { + buildId: string; + condaFile: string; + digest: string; + dockerFile: string; + duration: int64; + exitStatus: int32; + format: "docker" | "sif"; + offsetId: string; + platform: string; + requestIp: string; + scanId: string; + spackFile: string; + startTime: string; + succeeded: boolean; + targetImage: string; + userEmail: string; + userId: int64; + userName: string; + } + \ No newline at end of file diff --git a/typespec/models/WaveScanRecord.tsp b/typespec/models/WaveScanRecord.tsp new file mode 100644 index 000000000..e74e56cf9 --- /dev/null +++ b/typespec/models/WaveScanRecord.tsp @@ -0,0 +1,11 @@ +import "./Vulnerability.tsp"; + +@doc("Response Payload for wave scan") +model WaveScanRecord { + buildId: string; + duration: int64; + id: string; + startTime: string; + status: string; + vulnerabilities: Vulnerability[]; + } \ No newline at end of file diff --git a/typespec/models/models.tsp b/typespec/models/models.tsp new file mode 100644 index 000000000..c63c2e6f3 --- /dev/null +++ b/typespec/models/models.tsp @@ -0,0 +1,9 @@ +import "./ContainerRequest.tsp"; +import "./ContainerResponse.tsp"; +import "./BuildStatusResponse.tsp"; +import "./ContainerInspectRequest.tsp"; +import "./ContainerInspectResponse.tsp"; +import "./MetricsResponse.tsp"; +import "./WaveScanRecord.tsp"; +import "./WaveBuildRecord.tsp"; +import "./ValidateRegistryCredsRequest.tsp"; \ No newline at end of file diff --git a/typespec/package.json b/typespec/package.json new file mode 100644 index 000000000..5e792742d --- /dev/null +++ b/typespec/package.json @@ -0,0 +1,12 @@ +{ + "name": "wave", + "version": "1.8.2", + "type": "module", + "dependencies": { + "@typespec/compiler": "latest", + "@typespec/http": "latest", + "@typespec/rest": "latest", + "@typespec/openapi3": "latest" + }, + "private": true +} \ No newline at end of file diff --git a/typespec/routes.tsp b/typespec/routes.tsp new file mode 100644 index 000000000..d3d1c7623 --- /dev/null +++ b/typespec/routes.tsp @@ -0,0 +1,98 @@ +import "./models/models.tsp"; + +using TypeSpec.Http; +using TypeSpec.Rest; + +@service({ + title: "Wave service", +}) +@server("https://wave.seqera.io", "wave endopint") +namespace wave { + @route("/v1alpha2/container") + interface ContainerService { + + @post op createV1Alpha2Container(@body requestBody: ContainerRequest): { + @body response: ContainerResponse; + @statusCode statusCode: 200; + }; + + } + + @route("/v1alpha1/builds/{buildId}") + interface BuildService { + + @get op getBuildRecord(@path buildId: string): { + @body response: WaveBuildRecord; + @statusCode statusCode: 200; + }|{ + @statusCode statusCode: 404; + }; + + @route("/status") + @get op getBuildStatus(@path buildId: string): { + @body response: BuildStatusResponse; + @statusCode statusCode: 200; + }|{ + @statusCode statusCode: 404; + }; + + @route("/logs") + @get op getBuildLogs(@path buildId: string): { + @body response: string; + @statusCode statusCode: 200; + }|{ + @statusCode statusCode: 404; + }; + + } + + @route("/v1alpha1/scans/{scanId}") + interface scanService{ + + @get op scanImage(@path scanId: string) : { + @body response: WaveScanRecord; + @statusCode statusCode: 200; + }|{ + @statusCode statusCode: 404; + }; + + } + + @route("/v1alpha1/inspect") + interface InspectService { + + @post op inspectContainer(@body requestBody: ContainerInspectRequest): { + @body response: ContainerInspectResponse; + @statusCode statusCode: 200; + }|{ + @statusCode statusCode: 404; + }; + + } + + @route("/v1alpha2/metrics") + interface MetricsService { + + @route("/builds") + @get op getBuildMetrics(@query date?: string, @query org?: string): { + @body response: MetricsResponse; + @statusCode statusCode: 200; + }; + + @route("/pulls") + @get op getPullMetrics(@query date?: string, @query org?: string): { + @body response: MetricsResponse; + @statusCode statusCode: 200; + }; + + @route("/fusion/pulls") + @get op getFusionPullMetrics(@query date?: string, @query org?: string): { + @body response: MetricsResponse; + @statusCode statusCode: 200; + }; + } + + @route("validate-creds") + @post op validateCreds(@body request: ValidateRegistryCredsRequest): boolean; + +} diff --git a/typespec/tspconfig.yaml b/typespec/tspconfig.yaml new file mode 100644 index 000000000..a3fe48f13 --- /dev/null +++ b/typespec/tspconfig.yaml @@ -0,0 +1,2 @@ +emit: + - "@typespec/openapi3"