diff --git a/.codespellignore b/.codespellignore new file mode 100644 index 000000000..ab316676a --- /dev/null +++ b/.codespellignore @@ -0,0 +1,2 @@ +carrer +ser diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml new file mode 100644 index 000000000..6621d2fa9 --- /dev/null +++ b/.github/workflows/codespell.yml @@ -0,0 +1,26 @@ +--- +name: Codespell + +on: + push: + branches: [master] + pull_request: + branches: [master] + +permissions: + contents: read + +jobs: + codespell: + name: Check for spelling errors + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Codespell + uses: codespell-project/actions-codespell@v2 + with: + only_warn: 1 + ignore_words_file: .codespellignore + exclude_file: src/main/resources/application-reserved-words.yml 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 732738298..f8e233b27 100644 --- a/VERSION +++ b/VERSION @@ -1,2 +1 @@ -1.7.9 - +1.9.0 diff --git a/changelog.txt b/changelog.txt index 7c1acec05..2a5e5cf94 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,7 +1,50 @@ # Wave changelog +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] +- Implement cache store for build records (#505) [dd195deb] +- Fix do not show null in build view [763ffe03] +- Fix type in Const [b1bdb948] +- Fix handling blob response headers (#504) [de97575e] +- Fix missing endpoint on patched JWT token [46bbcd77] +- Improve digest retrivial logs [5976967a] +- Excludes JWT records with no endpoint [c98db8fb] +- Bump Gradle 8.8 [2e4568da] + 1.7.9 - 29 May 2024 - Improve JWT refresh handling (#512) [751edb03] -- Mark release as draft when ending with -Ax -Bx -RCx [ci skip] [66ba7ea7] +- Mark release as draft when ending with -Ax -Bx -RCx [66ba7ea7] - Removed unused imports (#510) [182e3f0d] - Fix Use atomic strategy for Cacheable ops [923ae67c] - Fix failing build [8adb52ab] @@ -69,7 +112,7 @@ - Fix Containerfile encoding (#441) [d742aa67] - Improve error reporting for AWS ECR auth failure [06141219] - Remove unused exception [bc5570b3] -- Fix syntax error repored by Intellij [343887a4] +- Fix syntax error reported by Intellij [343887a4] - Fix Build pod name (#438) [4e54ce1a] - Fix RedisTestContainer flaky test (#436) [a90b44e5] - Update platform api endpoint to api.cloud.seqera.io (#434) [1e43e8b1] @@ -87,7 +130,7 @@ - Add fixed executor to future store [9f61abe9] - Add timeout retry limiter [0a98aa96] - Fix failing tests [eeb9ddfe] -- Add thred pool monitor cron [a1cdcc13] +- Add thread pool monitor cron [a1cdcc13] - Bump corretto-17.0.10-al2023-jemalloc as base image [7238a86d] - Evict status redesigned (#419) [9cf07b56] - Add API and UI to evict a wave request from token cache (#410) [5833c18d] @@ -143,7 +186,7 @@ v1.1.7 - 19 Dec 2023 - Implements streaming client for large binary downloads (#358) [592ce522] - Update failing tests [31edb5e7] - Fix typo in comment [c10cc472] -- Remove unsused consts [24315f03] +- Remove unused consts [24315f03] - Refactor build settings via BuildConfig class (#324) [f6924f4a] - Bump trivy 0.47.0 [3ff4a89f] @@ -463,7 +506,7 @@ v0.29.0 - 5 Apr 2023 v0.28.6 - 5 Apr 2023 - Add consumers list [7d561f6d] -- Use singleton websocket instace [c0390502] +- Use singleton websocket instance [c0390502] - Add custom MN worker pool [be3cbe78] - Remove redis session [b23c7225] - Improve logs [0f29cef7] @@ -482,7 +525,7 @@ v0.28.2 - 4 Apr 2023 - Improve future poll + refactor [53c6a335] v0.28.1 - 4 Apr 2023 -- Fix missing timeout exeception on redis future [a9d640a0] +- Fix missing timeout exception on redis future [a9d640a0] - Minor logging changes [c81e78bf] [4943ba6f] [6768b7eb] - Update application dev [db4ae108] @@ -684,7 +727,7 @@ v0.21.6 - 17 Oct 2022 [BROKEN BASE IMAGE] - Improve registry auth lookup error [0f696146] v0.21.5 - 4 Oct 2022 -- Rollback timestamp and fingerpint fields [466b3a46] +- Rollback timestamp and fingerprint fields [466b3a46] v0.21.4 - 3 Oct 2022 - Count all manifest digest requests [02435bff] @@ -795,7 +838,7 @@ v0.14.2 - 7 Sep 2022 v0.14.1 - 5 Sep 2022 - Fix registry auth invalid cache key [15bd1a57] -- Fix retry policy for temportary IO exeception [15e2723f] +- Fix retry policy for temporary IO exception [15e2723f] - Add support for Azure CR [cac5b661] - Improve logging [7af3109f] diff --git a/configuration.md b/configuration.md index 7bccee1c1..9c8daf16b 100644 --- a/configuration.md +++ b/configuration.md @@ -81,11 +81,11 @@ Below are the standard format for known registries, but you can change registry - **`wave.build.timeout`**: the timeout for the build process. Its default value is `5m` (5 minutes), providing a reasonable time frame for the build operation. *Optional*. -- **`wave.build.workspace`**: defines the path to the directory used by Wave to store artifacts such as Dockerfiles, Trivy cache for scan, Kaniko context, authentication configuration files, etc. For example, `/efs/wave/build`. *Mandatory*. +- **`wave.build.workspace`**: defines the path to the directory used by Wave to store artifacts such as Dockerfiles, Trivy cache for scan, Buildkit context, authentication configuration files, etc. For example, `/efs/wave/build`. *Mandatory*. - **`wave.build.cleanup`**: determines the cleanup strategy after the build process. Options include `OnSuccess`, meaning cleanup occurs only if the build is successful. *Optional*. -- **`wave.build.kaniko-image`**: specifies the [Kaniko](https://github.com/GoogleContainerTools/kaniko) Docker image used in the Wave build process. The default is `gcr.io/kaniko-project/executor:v1.19.2`. *Optional*. +- **`wave.build.buildkit-image`**: specifies the [Buildkit](https://github.com/moby/buildkit) Docker image used in the Wave build process. The default is `moby/buildkit:v0.13.2-rootless`. *Optional*. - **`wave.build.singularity-image`**: sets the [Singularity](https://quay.io/repository/singularity/singularity?tab=tags) image used in the build process. The default is `quay.io/singularity/singularity:v3.11.4-slim`. *Optional*. @@ -101,11 +101,16 @@ Below are the standard format for known registries, but you can change registry - **`wave.build.public`**: indicates whether the Docker container repository is public. If set to true, Wave freeze will prefer this public repository over `wave.build.repo`. *Optional*. -- **`wave.build.compress-caching`**: determines whether to compress cache layers produced by the build process. The default is `true`, enabling compression for more efficient storage. *Optional*. +- **`wave.build.oci-mediatypes`**: defines whether to use OCI mediatypes in exported manifests. its default value is `true`. *Optional*. +- **`wave.build.compression`**: defines which type of compression will be applied to cache layers. its default value is `gzip` and other options are `uncompressed|estargz|zstd`. *Optional*. + +- **`wave.build.force-compression`**: determines whether to force the compression for each cache layers produced by the build process. The default is `false`, enabling compression for more efficient storage. *Optional*. ### 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 0fd126aa2..019a95b39 100644 --- a/docs/api.mdx +++ b/docs/api.mdx @@ -67,11 +67,11 @@ 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). | -| `timestamp` | Request submission timestap using ISO-8601. | +| `timestamp` | Request submission timestamp using ISO-8601. | | `fingerprint` | Request unique fingerprint. | | `freeze` | Freeze requires buildRepository to push the build container to a user-defined repository. This provides the container URL from the user-defined repository, not the Wave generated URL. This URL won't change. | | `towerEndpoint` | Seqera Platform service endpoint from where container registry credentials are retrieved (optional). Default `https://api.cloud.seqera.io`. | @@ -168,19 +168,19 @@ 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). | -| `timestamp` | Request submission timestap using ISO-8601. | +| `timestamp` | Request submission timestamp using ISO-8601. | | `fingerprint` | Request unique fingerprint. | | `freeze` | Freeze requires buildRepository to push the build container to a user-defined repository. This provides the container URL from the user-defined repository, not the Wave generated URL. This URL won't change. | | `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 c812fc4ea..f28739522 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -7,3 +7,66 @@ Containers are an essential part of modern data analysis pipelines in bioinforma However, the increasing complexity of pipelines and the need to deploy them across diverse cloud and HPC environments poses new challenges. Today, workflows may comprise dozens of distinct container images. Pipeline developers must manage and maintain these container images and ensure that their functionality precisely aligns with the requirements of every pipeline task, creating unnecessary friction in the maintenance and deployment of data pipelines. Wave tackles this problem by provisioning containers on-demand during the pipeline execution. This allows the delivery of container images that are defined precisely depending on the requirements of each pipeline task in terms of dependencies and platform architecture. This process is completely transparent and fully automated, removing all the plumbing and friction commonly needed to create, upload, and maintain dozens of container images that might be required by a pipeline execution. + +It allows for the on-demand assembly, augmentation, and deployment of containerized images based on task requirements. + +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 + +Container registry authentication is the new norm. Yet when it comes to authenticating against cloud-specific container registries, the process is hardly hassle free. +Wave integrates with Seqera Platform credentials management enabling seamless access and publishing to private registries. + +### Augment existing containers + +Regulatory and security requirements sometimes dictate specific container images, but additional context is often needed. +Wave enables any existing container to be extended without rebuilding it. Developers can add user-provided content such as custom scripts and logging agents, providing greater flexibility in the container’s configuration. + +Wave offers a flexible approach to container image management. It allows you to dynamically add custom layers to existing docker images, creating new images tailored to your specific needs. + +#### 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. + +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. +3. New image creation: Wave combines the downloaded Ubuntu layers with your custom layer, effectively creating a new image on the fly. + +![](_images/wave_container_augmentation.png) + +#### Benefits of Wave augmentation + +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 + +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. + +### Deploying containers across multi-clouds + +Cloud vendors provide integrated container registries, providing better performance and cost-efficiency than central, remote registries. +This requires mirroring container collections across multiple accounts, regions, and cloud providers when deploying multi-cloud pipelines. +Wave streamlines this process by provisioning the required containers to the target registry on-demand during the pipeline executions. + +### Container security scanning + +Builds for OCI-compliant container images are automatically scanned for known security vulnerabilities. Wave conducts a vulnerability scan using the [Trivy](https://trivy.dev/) security scanner. Seqera Platform customers receive an email that includes a link to the security report listing any vulnerabilities discovered. + +### Optimize workloads for specific architectures + +Modern data pipelines can be deployed across different data centers having different hardware architectures. e.g., amd64, arm64, and others. This requires curating different collections of containers for each architecture. +Wave allows for the on-demand provisioning of containers, depending on the target execution platform (in development). + +### Near caching + +The deployment of production pipelines at scale can require the use of multiple cloud regions to enable efficient resource allocation. +However, this can result in an increased overhead when pulling container images from a central container registry. Wave allows the transparent caching of container images in the same region where computation occurs, reducing data transfer costs and time (in development). + diff --git a/docs/sidebar.json b/docs/sidebar.json index c41686ab3..1508c24a1 100644 --- a/docs/sidebar.json +++ b/docs/sidebar.json @@ -52,6 +52,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/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 7454180f2..e6441136f 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index e411586a5..a4413138c 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 1b6c78733..b740cf133 100755 --- a/gradlew +++ b/gradlew @@ -55,7 +55,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -80,13 +80,11 @@ do esac done -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit - -APP_NAME="Gradle" +# This is normally unused +# shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -133,22 +131,29 @@ location of your Java installation." fi else JAVACMD=java - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac @@ -193,11 +198,15 @@ if "$cygwin" || "$msys" ; then done fi -# Collect all arguments for the java command; -# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of -# shell script including quotes and variable substitutions, so put them in -# double quotes to make sure that they get re-expanded; and -# * put everything else in single quotes, so that it's not re-expanded. + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ @@ -205,6 +214,12 @@ set -- \ org.gradle.wrapper.GradleWrapperMain \ "$@" +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + # Use "xargs" to parse quoted args. # # With -n1 it outputs one arg per line, with the quotes and backslashes removed. diff --git a/src/main/groovy/io/seqera/wave/Boostrap.groovy b/src/main/groovy/io/seqera/wave/Bootstrap.groovy similarity index 96% rename from src/main/groovy/io/seqera/wave/Boostrap.groovy rename to src/main/groovy/io/seqera/wave/Bootstrap.groovy index e8d4c7b37..5d110d55c 100644 --- a/src/main/groovy/io/seqera/wave/Boostrap.groovy +++ b/src/main/groovy/io/seqera/wave/Bootstrap.groovy @@ -28,14 +28,14 @@ import io.seqera.wave.util.StringUtils import jakarta.inject.Inject /** - * Basic bean to log some info at boostrap + * Basic bean to log some info at bootstrap * * @author Paolo Di Tommaso */ @Slf4j @Context @CompileStatic -class Boostrap { +class Bootstrap { @Inject RegistryCredentialsProvider provider 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/RegistryAuth.groovy b/src/main/groovy/io/seqera/wave/auth/RegistryAuth.groovy index c6cf3fc95..aa43a4395 100644 --- a/src/main/groovy/io/seqera/wave/auth/RegistryAuth.groovy +++ b/src/main/groovy/io/seqera/wave/auth/RegistryAuth.groovy @@ -35,7 +35,7 @@ import groovy.transform.ToString class RegistryAuth { private static final Pattern AUTH = ~/(?i)(?.+) realm="(?[^"]+)",service="(?[^"]+)"/ - // some registries doesnt send the service + // some registries doesn't send the service private static final Pattern AUTH2 = ~/(?i)(?.+) realm="(?[^"]+)"/ enum Type { Basic, Bearer } 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/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..fe33b40d6 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 diff --git a/src/main/groovy/io/seqera/wave/configuration/BuildConfig.groovy b/src/main/groovy/io/seqera/wave/configuration/BuildConfig.groovy index 62ca17c05..acc21afb8 100644 --- a/src/main/groovy/io/seqera/wave/configuration/BuildConfig.groovy +++ b/src/main/groovy/io/seqera/wave/configuration/BuildConfig.groovy @@ -36,8 +36,8 @@ import jakarta.inject.Singleton @Slf4j class BuildConfig { - @Value('${wave.build.kaniko-image}') - String kanikoImage + @Value('${wave.build.buildkit-image}') + String buildkitImage @Value('${wave.build.singularity-image}') String singularityImage @@ -75,16 +75,26 @@ class BuildConfig { @Nullable String cleanup - @Value('${wave.build.compress-caching:true}') - Boolean compressCaching = true - @Value('${wave.build.reserved-words:[]}') Set reservedWords + @Value('${wave.build.record.duration:5d}') + Duration recordDuration + + @Value('${wave.build.oci-mediatypes:true}') + Boolean ociMediatypes + + //check here for other options https://github.com/moby/buildkit?tab=readme-ov-file#registry-push-image-and-cache-separately + @Value('${wave.build.compression:gzip}') + String compression + + @Value('${wave.build.force-compression:false}') + Boolean forceCompression + @PostConstruct private void init() { log.debug("Builder config: " + - "kaniko-image=${kanikoImage}; " + + "buildkit-image=${buildkitImage}; " + "singularity-image=${singularityImage}; " + "singularity-image-amr64=${singularityImageArm64}; " + "default-build-repository=${defaultBuildRepository}; " + @@ -94,8 +104,11 @@ class BuildConfig { "build-timeout=${buildTimeout}; " + "status-delay=${statusDelay}; " + "status-duration=${statusDuration}; " + - "compress-caching=$compressCaching; " + - "cleanup=${cleanup}; ") + "record-duration=${recordDuration}; " + + "cleanup=${cleanup}; "+ + "oci-mediatypes=${ociMediatypes}; " + + "compression=${compression}; " + + "force-compression=${forceCompression}; ") } String singularityImage(ContainerPlatform containerPlatform){ @@ -107,4 +120,5 @@ class BuildConfig { String getSingularityImageArm64(){ return singularityImageArm64 ?: singularityImage + "-arm64" } + } 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/BuildController.groovy b/src/main/groovy/io/seqera/wave/controller/BuildController.groovy index 988eec65e..28891d3d9 100644 --- a/src/main/groovy/io/seqera/wave/controller/BuildController.groovy +++ b/src/main/groovy/io/seqera/wave/controller/BuildController.groovy @@ -29,11 +29,11 @@ import io.micronaut.http.annotation.Produces import io.micronaut.http.server.types.files.StreamedFile import io.micronaut.scheduling.TaskExecutors import io.micronaut.scheduling.annotation.ExecuteOn +import io.seqera.wave.api.BuildStatusResponse +import io.seqera.wave.service.builder.ContainerBuildService import io.seqera.wave.service.logs.BuildLogService -import io.seqera.wave.service.persistence.PersistenceService import io.seqera.wave.service.persistence.WaveBuildRecord import jakarta.inject.Inject -import io.seqera.wave.api.BuildStatusResponse /** * Implements a controller for container builds * @@ -46,7 +46,7 @@ import io.seqera.wave.api.BuildStatusResponse class BuildController { @Inject - private PersistenceService persistenceService + private ContainerBuildService buildService @Inject @Nullable @@ -54,7 +54,7 @@ class BuildController { @Get("/v1alpha1/builds/{buildId}") HttpResponse getBuildRecord(String buildId){ - final record = persistenceService.loadBuild(buildId) + final record = buildService.getBuildRecord(buildId) return record ? HttpResponse.ok(record) : HttpResponse.notFound() @@ -73,7 +73,7 @@ class BuildController { @Get("/v1alpha1/builds/{buildId}/status") HttpResponse getBuildStatus(String buildId){ - final build = persistenceService.loadBuild(buildId) + final build = buildService.getBuildRecord(buildId) build != null ? HttpResponse.ok(build.toStatusResponse()) : HttpResponse.notFound() diff --git a/src/main/groovy/io/seqera/wave/controller/ContainerController.groovy b/src/main/groovy/io/seqera/wave/controller/ContainerController.groovy index 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 83fe7298a..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,28 +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.getOrgCount(MetricConstants.PREFIX_BUILDS)) + return HttpResponse.ok(metricsService.getAllOrgCount(MetricsConstants.PREFIX_BUILDS)) validateQueryParams(date) - final count = metricsService.getBuildsMetrics(date, org) - return HttpResponse.ok(new GetBuildsCountResponse(count)) + 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.getOrgCount(MetricConstants.PREFIX_PULLS)) + return HttpResponse.ok(metricsService.getAllOrgCount(MetricsConstants.PREFIX_PULLS)) validateQueryParams(date) - final count = metricsService.getPullsMetrics(date, org) - return HttpResponse.ok(new GetPullsCountResponse(count)) + 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.getOrgCount(MetricConstants.PREFIX_FUSION)) + return HttpResponse.ok(metricsService.getAllOrgCount(MetricsConstants.PREFIX_FUSION)) validateQueryParams(date) - final count = metricsService.getFusionPullsMetrics(date, org) - return HttpResponse.ok(new GetFusionPullsCountResponse(count)) + return HttpResponse.ok(metricsService.getOrgCount(MetricsConstants.PREFIX_FUSION, date, org)) } diff --git a/src/main/groovy/io/seqera/wave/controller/RegistryProxyController.groovy b/src/main/groovy/io/seqera/wave/controller/RegistryProxyController.groovy index 3ec690ca7..a687da7cd 100644 --- a/src/main/groovy/io/seqera/wave/controller/RegistryProxyController.groovy +++ b/src/main/groovy/io/seqera/wave/controller/RegistryProxyController.groovy @@ -317,8 +317,9 @@ class RegistryProxyController { } MutableHttpResponse fromDownloadResponse(final DelegateResponse resp, RoutePath route, Map> headers) { - final blobCache = blobCacheService .retrieveBlobCache(route, headers) - log.debug "Blob cache $blobCache" + log.debug "== Blob cache upstream $resp" + final blobCache = blobCacheService .retrieveBlobCache(route, headers, resp.headers) + log.debug "== Blob cache response [succeeded=${blobCache.succeeded()}] $blobCache" if( !blobCache.succeeded() ) { final String msg = blobCache.logs ?: "Unable to cache blob ${blobCache.locationUri}" return badRequest(msg) 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/controller/ViewController.groovy b/src/main/groovy/io/seqera/wave/controller/ViewController.groovy index 49aac310d..5a41979c9 100644 --- a/src/main/groovy/io/seqera/wave/controller/ViewController.groovy +++ b/src/main/groovy/io/seqera/wave/controller/ViewController.groovy @@ -29,6 +29,7 @@ import io.micronaut.scheduling.TaskExecutors import io.micronaut.scheduling.annotation.ExecuteOn import io.micronaut.views.View import io.seqera.wave.exception.NotFoundException +import io.seqera.wave.service.builder.ContainerBuildService import io.seqera.wave.service.logs.BuildLogService import io.seqera.wave.service.persistence.PersistenceService import io.seqera.wave.service.persistence.WaveBuildRecord @@ -53,6 +54,9 @@ class ViewController { @Inject private PersistenceService persistenceService + @Inject + private ContainerBuildService buildService + @Inject @Nullable private BuildLogService buildLogService @@ -60,7 +64,7 @@ class ViewController { @View("build-view") @Get('/builds/{buildId}') HttpResponse> viewBuild(String buildId) { - final record = persistenceService.loadBuild(buildId) + final record = buildService.getBuildRecord(buildId) if( !record ) throw new NotFoundException("Unknown build id '$buildId'") return HttpResponse.ok(renderBuildView(record)) @@ -81,7 +85,7 @@ class ViewController { binding.build_containerfile = result.dockerFile ?: '-' binding.build_condafile = result.condaFile binding.build_spackfile = result.spackFile - binding.build_digest = result.digest + binding.build_digest = result.digest ?: '-' binding.put('server_url', serverUrl) binding.scan_url = result.scanId && result.succeeded() ? "$serverUrl/view/scans/${result.scanId}" : null binding.scan_id = result.scanId diff --git a/src/main/groovy/io/seqera/wave/core/ContainerAugmenter.groovy b/src/main/groovy/io/seqera/wave/core/ContainerAugmenter.groovy index 05a7ae97a..243585147 100644 --- a/src/main/groovy/io/seqera/wave/core/ContainerAugmenter.groovy +++ b/src/main/groovy/io/seqera/wave/core/ContainerAugmenter.groovy @@ -140,7 +140,7 @@ class ContainerAugmenter { // resolve image tag to digest final resp1 = client.head("/v2/$imageName/manifests/$tag", headers) final digest = resp1.headers().firstValue('docker-content-digest').orElse(null) - log.trace "Resolve (1): image $imageName:$tag => digest=$digest; reponse code=${resp1.statusCode()}" + log.trace "Resolve (1): image $imageName:$tag => digest=$digest; response code=${resp1.statusCode()}" checkResponseCode(resp1, client.route, false) // get manifest list for digest diff --git a/src/main/groovy/io/seqera/wave/core/RegistryProxyService.groovy b/src/main/groovy/io/seqera/wave/core/RegistryProxyService.groovy index fe7d67630..da1f75db3 100644 --- a/src/main/groovy/io/seqera/wave/core/RegistryProxyService.groovy +++ b/src/main/groovy/io/seqera/wave/core/RegistryProxyService.groovy @@ -19,14 +19,13 @@ package io.seqera.wave.core import groovy.transform.CompileStatic +import groovy.transform.ToString import groovy.util.logging.Slf4j 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 @@ -37,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 @@ -45,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 @@ -187,39 +188,34 @@ 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) - return resp.statusCode() == 200 - ? resp.headers().firstValue('docker-content-digest').orElse(null) - : null + 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 } + @ToString(includeNames = true, includePackage = false) static class DelegateResponse { int statusCode Map> headers 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/model/ContainerCoordinates.groovy b/src/main/groovy/io/seqera/wave/model/ContainerCoordinates.groovy index e0b9455fc..7dbd76fd3 100644 --- a/src/main/groovy/io/seqera/wave/model/ContainerCoordinates.groovy +++ b/src/main/groovy/io/seqera/wave/model/ContainerCoordinates.groovy @@ -56,7 +56,7 @@ class ContainerCoordinates implements ContainerPath { final scheme = StringUtils.getUrlProtocol(path) if( scheme ) { - if( scheme!='oras') throw new IllegalArgumentException("Invalid container scheme: '$scheme' - offending iamge: '$path'") + if( scheme!='oras') throw new IllegalArgumentException("Invalid container scheme: '$scheme' - offending image: '$path'") path = path.substring(7) } 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..4fcef1eae 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,18 @@ class CredentialServiceImpl implements CredentialsService { return parsePayload(credentials) } + CredentialsDescription findComputeCreds(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/AwsEcrService.groovy b/src/main/groovy/io/seqera/wave/service/aws/AwsEcrService.groovy index 0c6445413..17fdb3720 100644 --- a/src/main/groovy/io/seqera/wave/service/aws/AwsEcrService.groovy +++ b/src/main/groovy/io/seqera/wave/service/aws/AwsEcrService.groovy @@ -120,7 +120,7 @@ class AwsEcrService { */ String getLoginToken(String accessKey, String secretKey, String region, boolean isPublic) { assert accessKey, "Missing AWS accessKey argument" - assert secretKey, "Missing AWS secretKet argument" + assert secretKey, "Missing AWS secretKey argument" assert region, "Missing AWS region argument" try { 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/BlobCacheInfo.groovy b/src/main/groovy/io/seqera/wave/service/blob/BlobCacheInfo.groovy index b50b16752..a3d5eaf45 100644 --- a/src/main/groovy/io/seqera/wave/service/blob/BlobCacheInfo.groovy +++ b/src/main/groovy/io/seqera/wave/service/blob/BlobCacheInfo.groovy @@ -23,11 +23,14 @@ import groovy.transform.Canonical import groovy.transform.CompileStatic import groovy.transform.Memoized import groovy.transform.ToString +import groovy.util.logging.Slf4j + /** * Model a blob cache metadata entry * * @author Paolo Di Tommaso */ +@Slf4j @ToString(includePackage = false, includeNames = true) @Canonical @CompileStatic @@ -43,6 +46,21 @@ class BlobCacheInfo { */ final Map headers + /** + * The blob length + */ + final Long contentLength + + /** + * The content type of this blob + */ + final String contentType + + /** + * The blob cache control directive + */ + final String cacheControl + /** * The instant when the cache request was created */ @@ -73,29 +91,37 @@ class BlobCacheInfo { locationUri && completionTime!=null } - String getContentType() { - headers?.find(it-> it.key.toLowerCase()=='content-type')?.value - } - - String getCacheControl() { - headers?.find(it-> it.key.toLowerCase()=='cache-control')?.value - } - - static BlobCacheInfo create(String locationUrl, Map> headers) { + static BlobCacheInfo create(String locationUrl, Map> request, Map> response) { final headers0 = new LinkedHashMap() - for( Map.Entry> it : headers ) + for( Map.Entry> it : request ) headers0.put( it.key, it.value.join(',') ) - new BlobCacheInfo(locationUrl, headers0, Instant.now()) + final length = headerLong0(response, 'Content-Length') + final type = headerString0(response, 'Content-Type') + final cache = headerString0(response, 'Cache-Control') + new BlobCacheInfo(locationUrl, headers0, length, type, cache, Instant.now(), null, null, null) + } + + static String headerString0(Map> headers, String name) { + headers?.find(it-> it.key.toLowerCase()==name.toLowerCase())?.value?.first() } - static BlobCacheInfo create1(String locationUrl, Map headers) { - new BlobCacheInfo(locationUrl, headers, Instant.now()) + static Long headerLong0(Map> headers, String name) { + try { + return headerString0(headers,name) as Long + } + catch (NumberFormatException e) { + log.warn "Unexpected content length value - cause: $e" + return null + } } BlobCacheInfo cached() { new BlobCacheInfo( locationUri, headers, + contentLength, + contentType, + cacheControl, creationTime, creationTime, 0) @@ -105,6 +131,9 @@ class BlobCacheInfo { new BlobCacheInfo( locationUri, headers, + contentLength, + contentType, + cacheControl, creationTime, Instant.now(), status, @@ -115,25 +144,33 @@ class BlobCacheInfo { new BlobCacheInfo( locationUri, headers, + contentLength, + contentType, + cacheControl, creationTime, Instant.now(), null, - logs) + logs + ) } BlobCacheInfo withLocation(String uri) { new BlobCacheInfo( uri, headers, + contentLength, + contentType, + cacheControl, creationTime, completionTime, exitStatus, - logs) + logs + ) } @Memoized static BlobCacheInfo unknown() { - new BlobCacheInfo(null, null, Instant.ofEpochMilli(0), Instant.ofEpochMilli(0), null) { + new BlobCacheInfo(null, null, null, null, null, Instant.ofEpochMilli(0), Instant.ofEpochMilli(0), null) { @Override BlobCacheInfo withLocation(String uri) { // prevent the change of location for unknown status diff --git a/src/main/groovy/io/seqera/wave/service/blob/BlobCacheService.groovy b/src/main/groovy/io/seqera/wave/service/blob/BlobCacheService.groovy index ab536fa9f..780776d1c 100644 --- a/src/main/groovy/io/seqera/wave/service/blob/BlobCacheService.groovy +++ b/src/main/groovy/io/seqera/wave/service/blob/BlobCacheService.groovy @@ -31,7 +31,7 @@ interface BlobCacheService { /** * Store a container blob into the cache storage that allows fast retrieval - * via HTTP content delivery network, and retuns a {@link BlobCacheInfo} object + * via HTTP content delivery network, and returns a {@link BlobCacheInfo} object * holding the HTTP download URI. * * Note this method is thread safe is expected to be thread safe across multiple replicas. @@ -42,9 +42,10 @@ interface BlobCacheService { * information. * * @param route The HTTP request of a container layer blob - * @param headers The HTTP headers of a container layer blob + * @param requestHeaders The HTTP headers of the upstream request + * @param responseHeaders The HTTP headers of the response providing the blob to be cached * @return */ - BlobCacheInfo retrieveBlobCache(RoutePath route, Map> headers) + BlobCacheInfo retrieveBlobCache(RoutePath route, Map> requestHeaders, Map> responseHeaders) } 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 fc3d6a513..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 @@ -91,11 +100,11 @@ class BlobCacheServiceImpl implements BlobCacheService { } @Override - BlobCacheInfo retrieveBlobCache(RoutePath route, Map> headers) { + BlobCacheInfo retrieveBlobCache(RoutePath route, Map> requestHeaders, Map> responseHeaders) { final uri = blobDownloadUri(route) log.trace "Container blob download uri: $uri" - final info = BlobCacheInfo.create(uri, headers) + final info = BlobCacheInfo.create(uri, requestHeaders, responseHeaders) final target = route.targetPath if( blobStore.storeIfAbsent(target, info) ) { // start download and caching job @@ -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/DockerTransferStrategy.groovy b/src/main/groovy/io/seqera/wave/service/blob/impl/DockerTransferStrategy.groovy index b8c9a4a05..431465209 100644 --- a/src/main/groovy/io/seqera/wave/service/blob/impl/DockerTransferStrategy.groovy +++ b/src/main/groovy/io/seqera/wave/service/blob/impl/DockerTransferStrategy.groovy @@ -46,7 +46,7 @@ class DockerTransferStrategy implements TransferStrategy { @Override BlobCacheInfo transfer(BlobCacheInfo info, List command) { final proc = createProcess(command).start() - // wait for the completion and save thr result + // wait for the completion and save the result final completed = proc.waitFor(blobConfig.transferTimeout.toSeconds(), TimeUnit.SECONDS) final int status = completed ? proc.exitValue() : -1 final logs = proc.inputStream.text diff --git a/src/main/groovy/io/seqera/wave/service/blob/impl/SimpleTransferStrategy.groovy b/src/main/groovy/io/seqera/wave/service/blob/impl/SimpleTransferStrategy.groovy index e61628aa7..274e4e624 100644 --- a/src/main/groovy/io/seqera/wave/service/blob/impl/SimpleTransferStrategy.groovy +++ b/src/main/groovy/io/seqera/wave/service/blob/impl/SimpleTransferStrategy.groovy @@ -40,7 +40,7 @@ class SimpleTransferStrategy implements TransferStrategy { @Override BlobCacheInfo transfer(BlobCacheInfo info, List cli) { final proc = createProcess(cli).start() - // wait for the completion and save thr result + // wait for the completion and save the result final completed = proc.waitFor(blobConfig.transferTimeout.toSeconds(), TimeUnit.SECONDS) final int status = completed ? proc.exitValue() : -1 final logs = proc.inputStream.text diff --git a/src/main/groovy/io/seqera/wave/service/builder/BuildStrategy.groovy b/src/main/groovy/io/seqera/wave/service/builder/BuildStrategy.groovy index 0d8c7dc5b..723d2611a 100644 --- a/src/main/groovy/io/seqera/wave/service/builder/BuildStrategy.groovy +++ b/src/main/groovy/io/seqera/wave/service/builder/BuildStrategy.groovy @@ -37,6 +37,8 @@ abstract class BuildStrategy { abstract BuildResult build(BuildRequest req) + static final public String BUILDKIT_ENTRYPOINT = 'buildctl-daemonless.sh' + void cleanup(BuildRequest req) { req.workDir?.deleteDir() } @@ -55,35 +57,48 @@ abstract class BuildStrategy { protected List dockerLaunchCmd(BuildRequest req) { final result = new ArrayList(10) result - << "--dockerfile" - << "$req.workDir/Containerfile".toString() - << "--context" - << "$req.workDir/context".toString() - << "--destination" - << req.targetImage - << "--cache=true" - << "--custom-platform" - << req.platform.toString() + << "build" + << "--frontend" + << "dockerfile.v0" + << "--local" + << "dockerfile=$req.workDir".toString() + << "--opt" + << "filename=Containerfile" + << "--local" + << "context=$req.workDir/context".toString() + << "--output" + << "type=image,name=$req.targetImage,push=true,oci-mediatypes=${buildConfig.ociMediatypes}".toString() + << "--opt" + << "platform=$req.platform".toString() if( req.cacheRepository ) { - result << "--cache-repo" << req.cacheRepository - } + result << "--export-cache" + def exportCache = new StringBuilder() + exportCache << "type=registry," + exportCache << "image-manifest=true," + exportCache << "ref=${req.cacheRepository}:${req.containerId}," + exportCache << "mode=max," + exportCache << "ignore-error=true," + exportCache << "oci-mediatypes=${buildConfig.ociMediatypes}," + exportCache << "compression=${buildConfig.compression}," + exportCache << "force-compression=${buildConfig.forceCompression}" + result << exportCache.toString() - if( !buildConfig.compressCaching ){ - result << "--compressed-caching=false" + result << "--import-cache" + result << "type=registry,ref=$req.cacheRepository:$req.containerId".toString() } if(req.spackFile){ - result << '--build-arg' - result << 'AWS_STS_REGIONAL_ENDPOINTS=$(AWS_STS_REGIONAL_ENDPOINTS)' - result << '--build-arg' - result << 'AWS_REGION=$(AWS_REGION)' - result << '--build-arg' - result << 'AWS_DEFAULT_REGION=$(AWS_DEFAULT_REGION)' - result << '--build-arg' - result << 'AWS_ROLE_ARN=$(AWS_ROLE_ARN)' - result << '--build-arg' - result << 'AWS_WEB_IDENTITY_TOKEN_FILE=$(AWS_WEB_IDENTITY_TOKEN_FILE)' + result << '--opt' + result << 'build-arg:AWS_STS_REGIONAL_ENDPOINTS=$(AWS_STS_REGIONAL_ENDPOINTS)' + result << '--opt' + result << 'build-arg:AWS_REGION=$(AWS_REGION)' + result << '--opt' + result << 'build-arg:AWS_DEFAULT_REGION=$(AWS_DEFAULT_REGION)' + result << '--opt' + result << 'build-arg:AWS_ROLE_ARN=$(AWS_ROLE_ARN)' + result << '--opt' + result << 'build-arg:AWS_WEB_IDENTITY_TOKEN_FILE=$(AWS_WEB_IDENTITY_TOKEN_FILE)' } return result @@ -97,4 +112,5 @@ abstract class BuildStrategy { << "singularity build image.sif ${req.workDir}/Containerfile && singularity push image.sif ${req.targetImage}".toString() return result } + } diff --git a/src/main/groovy/io/seqera/wave/service/builder/ContainerBuildService.groovy b/src/main/groovy/io/seqera/wave/service/builder/ContainerBuildService.groovy index d1551d35e..dfb8c3a74 100644 --- a/src/main/groovy/io/seqera/wave/service/builder/ContainerBuildService.groovy +++ b/src/main/groovy/io/seqera/wave/service/builder/ContainerBuildService.groovy @@ -20,12 +20,17 @@ package io.seqera.wave.service.builder import java.util.concurrent.CompletableFuture +import groovy.transform.CompileStatic +import io.micronaut.runtime.event.annotation.EventListener import io.seqera.wave.core.RoutePath +import io.seqera.wave.service.persistence.WaveBuildRecord + /** * Declare container build service interface * * @author Paolo Di Tommaso */ +@CompileStatic interface ContainerBuildService { /** @@ -65,4 +70,66 @@ interface ContainerBuildService { : null } + + // ************************************************************** + // ** build record operations + // ************************************************************** + + @EventListener + default void onBuildEvent(BuildEvent event) { + saveBuildRecord(event) + } + + /** + * Store a build record for the given {@link BuildRequest} object. + * + * This method is expected to store the build record associated with the request + * *only* in the short term store caching system, ie. without hitting the + * long-term SurrealDB storage + * + * @param request The build request that needs to be storage + */ + default void createBuildRecord(BuildRequest request) { + final record0 = WaveBuildRecord.fromEvent(new BuildEvent(request)) + createBuildRecord(record0.buildId, record0) + } + + /** + * Store the build record associated with the specified event both in the + * short-term cache (redis) and long-term persistence layer (surrealdb) + * + * @param event The {@link BuildEvent} object for which the build record needs to be stored + */ + default void saveBuildRecord(BuildEvent event) { + final record0 = WaveBuildRecord.fromEvent(event) + saveBuildRecord(record0.buildId, record0) + } + + /** + * Store a build record object. + * + * This method is expected to store the build record *only* in the short term store cache (redis), + * ie. without hitting the long-term storage (surrealdb) + * + * @param buildId The Id of the build record + * @param value The {@link WaveBuildRecord} to be stored + */ + void createBuildRecord(String buildId, WaveBuildRecord value) + + /** + * Store the specified build record both in the short-term cache (redis) + * and long-term persistence layer (surrealdb) + * + * @param buildId The Id of the build record + * @param value The {@link WaveBuildRecord} to be stored + */ + void saveBuildRecord(String buildId, WaveBuildRecord value) + + /** + * Retrieve the build record for the specified id. + * + * @param buildId The ID of the build record to be retrieve + * @return The {@link WaveBuildRecord} associated with the corresponding Id, or {@code null} if it cannot be found + */ + WaveBuildRecord getBuildRecord(String buildId) } diff --git a/src/main/groovy/io/seqera/wave/service/builder/ContainerBuildServiceImpl.groovy b/src/main/groovy/io/seqera/wave/service/builder/ContainerBuildServiceImpl.groovy index 3f8369ca6..7e57ae642 100644 --- a/src/main/groovy/io/seqera/wave/service/builder/ContainerBuildServiceImpl.groovy +++ b/src/main/groovy/io/seqera/wave/service/builder/ContainerBuildServiceImpl.groovy @@ -39,6 +39,7 @@ import io.seqera.wave.configuration.SpackConfig import io.seqera.wave.exception.HttpServerRetryableErrorException import io.seqera.wave.ratelimit.AcquireRequest import io.seqera.wave.ratelimit.RateLimiterService +import io.seqera.wave.service.builder.store.BuildRecordStore import io.seqera.wave.service.cleanup.CleanupStrategy import io.seqera.wave.service.metric.MetricsService import io.seqera.wave.service.persistence.PersistenceService @@ -115,6 +116,9 @@ class ContainerBuildServiceImpl implements ContainerBuildService { @Inject private MetricsService metricsService + @Inject + BuildRecordStore buildRecordStore + /** * Build a container image for the given {@link BuildRequest} * @@ -237,8 +241,8 @@ class ContainerBuildServiceImpl implements ContainerBuildService { //increment metrics CompletableFuture.supplyAsync(() -> metricsService.incrementBuildsCounter(request.identity), executor) - // persist the container request - persistenceService.createBuild(WaveBuildRecord.fromEvent(new BuildEvent(request))) + // save the container request in the underlying storage (redis) + createBuildRecord(request) // launch the build async CompletableFuture @@ -341,4 +345,34 @@ class ContainerBuildServiceImpl implements ContainerBuildService { .retryCondition((Throwable t) -> t instanceof SocketException || t instanceof HttpServerRetryableErrorException) .onRetry((event)-> log.warn("$message - event: $event")) } + + // ************************************************************** + // ** build record implementation + // ************************************************************** + + /** + * @Inherited + */ + @Override + void createBuildRecord(String buildId, WaveBuildRecord value) { + buildRecordStore.putBuildRecord(buildId, value) + } + + /** + * @Inherited + */ + @Override + void saveBuildRecord(String buildId, WaveBuildRecord value) { + buildRecordStore.putBuildRecord(buildId, value) + persistenceService.saveBuild(value) + } + + /** + * @Inherited + */ + @Override + WaveBuildRecord getBuildRecord(String buildId) { + return buildRecordStore.getBuildRecord(buildId) ?: persistenceService.loadBuild(buildId) + } + } 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 a4ed1e745..f0460f95d 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 { @@ -110,21 +110,25 @@ class DockerBuildStrategy extends BuildStrategy { final spack = req.isSpackBuild ? spackConfig : null final dockerCmd = req.formatDocker() - ? cmdForKaniko( req.workDir, credsFile, spack, req.platform) + ? cmdForBuildkit( req.workDir, credsFile, spack, req.platform) : cmdForSingularity( req.workDir, credsFile, spack, req.platform) return dockerCmd + launchCmd(req) } - protected List cmdForKaniko(Path workDir, Path credsFile, SpackConfig spackConfig, ContainerPlatform platform ) { + protected List cmdForBuildkit(Path workDir, Path credsFile, SpackConfig spackConfig, ContainerPlatform platform ) { + //checkout the documentation here to know more about these options https://github.com/moby/buildkit/blob/master/docs/rootless.md#docker final wrapper = ['docker', 'run', '--rm', - '-v', "$workDir:$workDir".toString()] + '--privileged', + '-v', "$workDir:$workDir".toString(), + '--entrypoint', + BUILDKIT_ENTRYPOINT] if( credsFile ) { wrapper.add('-v') - wrapper.add("$credsFile:/kaniko/.docker/config.json:ro".toString()) + wrapper.add("$credsFile:/home/user/.docker/config.json:ro".toString()) } if( spackConfig ) { @@ -137,8 +141,9 @@ class DockerBuildStrategy extends BuildStrategy { wrapper.add('--platform') wrapper.add(platform.toString()) } - // the container image to be used t - wrapper.add( buildConfig.kanikoImage ) + + // the container image to be used to build + wrapper.add( buildConfig.buildkitImage ) // return it return wrapper } 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 be7fdc178..a5bf97c7d 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) { @@ -117,7 +117,7 @@ class KubeBuildStrategy extends BuildStrategy { protected String getBuildImage(BuildRequest buildRequest){ if( buildRequest.formatDocker() ) { - return buildConfig.kanikoImage + return buildConfig.buildkitImage } if( buildRequest.formatSingularity() ) { diff --git a/src/main/groovy/io/seqera/wave/service/builder/store/BuildRecordCacheStore.groovy b/src/main/groovy/io/seqera/wave/service/builder/store/BuildRecordCacheStore.groovy new file mode 100644 index 000000000..0f9884cfd --- /dev/null +++ b/src/main/groovy/io/seqera/wave/service/builder/store/BuildRecordCacheStore.groovy @@ -0,0 +1,69 @@ +/* + * Wave, containers provisioning service + * Copyright (c) 2023-2024, Seqera Labs + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package io.seqera.wave.service.builder.store + +import java.time.Duration + +import groovy.transform.CompileStatic +import io.seqera.wave.configuration.BuildConfig +import io.seqera.wave.encoder.MoshiEncodeStrategy +import io.seqera.wave.service.cache.AbstractCacheStore +import io.seqera.wave.service.cache.impl.CacheProvider +import io.seqera.wave.service.persistence.WaveBuildRecord +import jakarta.inject.Inject +import jakarta.inject.Singleton +/** + * Implements a store service to cache {@link WaveBuildRecord} object + * + * @author Paolo Di Tommaso + */ +@Singleton +@CompileStatic +class BuildRecordCacheStore extends AbstractCacheStore implements BuildRecordStore { + + /** + * An instance of {@link BuildConfig} modelling the build config settings + */ + @Inject + private BuildConfig buildConfig + + BuildRecordCacheStore(CacheProvider provider) { + super(provider, new MoshiEncodeStrategy() {}) + } + + @Override + protected String getPrefix() { + return 'wave-buildrecord/v1:' + } + + @Override + protected Duration getDuration() { + return buildConfig.recordDuration + } + + @Override + WaveBuildRecord getBuildRecord(String buildId) { + return get(buildId) + } + + @Override + void putBuildRecord(String buildId, WaveBuildRecord value) { + put(buildId, value) + } +} diff --git a/src/main/groovy/io/seqera/wave/service/builder/store/BuildRecordStore.groovy b/src/main/groovy/io/seqera/wave/service/builder/store/BuildRecordStore.groovy new file mode 100644 index 000000000..13d453a9d --- /dev/null +++ b/src/main/groovy/io/seqera/wave/service/builder/store/BuildRecordStore.groovy @@ -0,0 +1,34 @@ +/* + * Wave, containers provisioning service + * Copyright (c) 2023-2024, Seqera Labs + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package io.seqera.wave.service.builder.store + +import io.seqera.wave.service.persistence.WaveBuildRecord + +/** + * Define the contract to cache {@link WaveBuildRecord} object + * + * @author Paolo Di Tommaso + */ +interface BuildRecordStore { + + WaveBuildRecord getBuildRecord(String buildId) + + void putBuildRecord(String buildId, WaveBuildRecord value) + +} diff --git a/src/main/groovy/io/seqera/wave/service/counter/impl/LocalCounterProvider.groovy b/src/main/groovy/io/seqera/wave/service/counter/impl/LocalCounterProvider.groovy index ab3f697a2..c7ea99ac6 100644 --- a/src/main/groovy/io/seqera/wave/service/counter/impl/LocalCounterProvider.groovy +++ b/src/main/groovy/io/seqera/wave/service/counter/impl/LocalCounterProvider.groovy @@ -20,6 +20,7 @@ package io.seqera.wave.service.counter.impl import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicLong +import java.util.regex.Pattern import groovy.transform.CompileStatic import io.micronaut.context.annotation.Requires @@ -49,10 +50,11 @@ class LocalCounterProvider implements CounterProvider { @Override Map getAllMatchingEntries(String key, String pattern) { - def keyStore = store.get(key) + Pattern compiledPattern = Pattern.compile(pattern.replace('*', '.*')) + Map keyStore = store.get(key) Map result = [:] if (keyStore){ - def matchingPairs = keyStore.findAll { k, v -> k =~ pattern } + def matchingPairs = keyStore.findAll {entry -> compiledPattern.matcher(entry.key).matches()} matchingPairs.each { k, v -> result.put(k, v as Long) } diff --git a/src/main/groovy/io/seqera/wave/service/data/future/impl/RedisFutureHash.groovy b/src/main/groovy/io/seqera/wave/service/data/future/impl/RedisFutureHash.groovy index 567a89435..0541486ef 100644 --- a/src/main/groovy/io/seqera/wave/service/data/future/impl/RedisFutureHash.groovy +++ b/src/main/groovy/io/seqera/wave/service/data/future/impl/RedisFutureHash.groovy @@ -34,7 +34,7 @@ import redis.clients.jedis.params.SetParams /** * Implements a future queue using Redis hash. The hash was chosen over * a Redis list, because values that fail to be collected within the - * expected timout, are evicted by Redis by simply specifying the hash expiration. + * expected timeout, are evicted by Redis by simply specifying the hash expiration. * * @author Paolo Di Tommaso */ diff --git a/src/main/groovy/io/seqera/wave/service/data/queue/AbstractMessageQueue.groovy b/src/main/groovy/io/seqera/wave/service/data/queue/AbstractMessageQueue.groovy index 20f15e849..d3a2eda64 100644 --- a/src/main/groovy/io/seqera/wave/service/data/queue/AbstractMessageQueue.groovy +++ b/src/main/groovy/io/seqera/wave/service/data/queue/AbstractMessageQueue.groovy @@ -118,7 +118,7 @@ abstract class AbstractMessageQueue implements Runnable { * * @param target * Identify the websocket target i.e. the remote endpoint. For the same target - * it's possible to have one ore more clients + * it's possible to have more than one client * @param clientId * A unique id for the Websocket client instance * @param sender @@ -139,7 +139,7 @@ abstract class AbstractMessageQueue implements Runnable { * * @param target * Identify the websocket target i.e. the remote endpoint. For the same target - * it's possible to have one ore more clients + * it's possible to have one more than one client * @param clientId * A unique id for the Websocket client instance */ diff --git a/src/main/groovy/io/seqera/wave/service/inclusion/ContainerInclusionImpl.groovy b/src/main/groovy/io/seqera/wave/service/inclusion/ContainerInclusionImpl.groovy index 8725e825f..ffc956466 100644 --- a/src/main/groovy/io/seqera/wave/service/inclusion/ContainerInclusionImpl.groovy +++ b/src/main/groovy/io/seqera/wave/service/inclusion/ContainerInclusionImpl.groovy @@ -51,7 +51,7 @@ class ContainerInclusionImpl implements ContainerInclusionService { final result = new ArrayList() for( String it : containerNames ) { - // submit a container inspect request to find out the layers making up the contaner + // submit a container inspect request to find out the layers making up the container final spec = inspectService.containerSpec(it, identity) final List layerRef = spec.getManifest().getLayers(); // add each entry as a new container layer in the request container config 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 df64fc33b..01d03dd98 100644 --- a/src/main/groovy/io/seqera/wave/service/k8s/K8sServiceImpl.groovy +++ b/src/main/groovy/io/seqera/wave/service/k8s/K8sServiceImpl.groovy @@ -50,6 +50,7 @@ import io.seqera.wave.core.ContainerPlatform import io.seqera.wave.service.scan.Trivy import jakarta.inject.Inject import jakarta.inject.Singleton +import static io.seqera.wave.service.builder.BuildStrategy.BUILDKIT_ENTRYPOINT /** * implements the support for Kubernetes cluster * @@ -104,6 +105,16 @@ class K8sServiceImpl implements K8sService { @Inject private BuildConfig buildConfig + // check this link to know more about these options https://github.com/moby/buildkit/tree/master/examples/kubernetes#kubernetes-manifests-for-buildkit + private final static Map BUILDKIT_FLAGS = ['BUILDKITD_FLAGS': '--oci-worker-no-process-sandbox'] + + private Map getBuildkitAnnotations(String containerName, boolean singularity) { + if( singularity ) + return null + final key = "container.apparmor.security.beta.kubernetes.io/${containerName}".toString() + return Map.of(key, "unconfined") + } + /** * Validate config setting */ @@ -304,7 +315,7 @@ class K8sServiceImpl implements K8sService { } /** - * Create a container for container image building via Kaniko + * Create a container for container image building via buildkit * * @param name * The name of pod @@ -341,7 +352,7 @@ class K8sServiceImpl implements K8sService { if( credsFile ){ if( !singularity ) { - mounts.add(0, mountHostPath(credsFile, storageMountPath, '/kaniko/.docker/config.json')) + mounts.add(0, mountHostPath(credsFile, storageMountPath, '/home/user/.docker/config.json')) } else { final remoteFile = credsFile.resolveSibling('singularity-remote.yaml') @@ -361,6 +372,7 @@ class K8sServiceImpl implements K8sService { .withNamespace(namespace) .withName(name) .addToLabels(labels) + .addToAnnotations(getBuildkitAnnotations(name,singularity)) .endMetadata() //spec section @@ -391,10 +403,13 @@ class K8sServiceImpl implements K8sService { // use 'command' to override the entrypoint of the container .withCommand(args) .withNewSecurityContext().withPrivileged(true).endSecurityContext() - } - else { - // use 'arg' to avoid overriding the entrypoint of the container set by kaniko - container.withArgs(args) + } else { + container + //required by buildkit rootless container + .withEnv(toEnvList(BUILDKIT_FLAGS)) + // buildCommand is to set entrypoint for buildkit + .withCommand(BUILDKIT_ENTRYPOINT) + .withArgs(args) } // spec section @@ -582,4 +597,5 @@ class K8sServiceImpl implements K8sService { result.add( new V1EnvVar().name(it.key).value(it.value) ) return result } + } 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 98f2e67d5..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 @@ -63,7 +63,7 @@ class MailServiceImpl implements MailService { sendCompletionEmail(event.request, event.result) } catch (Exception e) { - log.warn "Unable to send completion notication - reason: ${e.message ?: e}" + log.warn "Unable to send completion notification - reason: ${e.message ?: e}" } } @@ -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/MetricsService.groovy b/src/main/groovy/io/seqera/wave/service/metric/MetricsService.groovy index 35987e142..08cc7b200 100644 --- a/src/main/groovy/io/seqera/wave/service/metric/MetricsService.groovy +++ b/src/main/groovy/io/seqera/wave/service/metric/MetricsService.groovy @@ -26,33 +26,6 @@ import io.seqera.wave.tower.PlatformId * @author Munish Chouhan */ interface MetricsService { - /** - * get the Wave builds metrics - * - * @param date, date of the required metrics - * @param org, org of the required metrics - * @return Long, builds counts - */ - Long getBuildsMetrics(String date, String org) - - /** - * get the Wave pulls metrics - * - * @param date, date of the required metrics - * @param org, org of the required metrics - * @return Long, pulls counts - */ - Long getPullsMetrics(String date, String org) - - /** - * get the Wave fusion pulls metrics - * - * @param date, date of the required metrics - * @param org, org of the required metrics - * @return Long, fusion pulls counts - */ - Long getFusionPullsMetrics(String date, String org) - /** * increment wave fusion pulls count * @@ -80,5 +53,13 @@ interface MetricsService { * @param metric * @return GetOrgCountResponse */ - GetOrgCountResponse getOrgCount(String metric) + GetOrgCountResponse getAllOrgCount(String metric) + + /** + * Get counts per organisations or per date or per both + * + * @param metric + * @return GetOrgCountResponse + */ + GetOrgCountResponse getOrgCount(String metric, String date, String org) } 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 03298ddda..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 @@ -20,16 +20,19 @@ package io.seqera.wave.service.metric.impl import java.time.LocalDate import java.time.format.DateTimeFormatter +import java.util.regex.Matcher +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 * @@ -40,66 +43,75 @@ import jakarta.inject.Singleton @CompileStatic class MetricsServiceImpl implements MetricsService { - @Inject - private MetricsCounterStore metricsCounterStore - - DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") - - @Override - Long getBuildsMetrics(String date, String org) { - return metricsCounterStore.get(getKey(MetricConstants.PREFIX_BUILDS, date, org)) ?: 0 - } + static final private DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd") - @Override - Long getPullsMetrics(String date, String org) { - return metricsCounterStore.get(getKey(MetricConstants.PREFIX_PULLS, date, org)) ?: 0 - } + static final private Pattern ORG_DATE_KEY_PATTERN = Pattern.compile('(builds|pulls|fusion)/o/([^/]+)/d/\\d{4}-\\d{2}-\\d{2}') - @Override - Long getFusionPullsMetrics(String date, String org) { - return metricsCounterStore.get(getKey(MetricConstants.PREFIX_FUSION, date, org)) ?: 0 - } + @Inject + private MetricsCounterStore metricsCounterStore @Override - GetOrgCountResponse getOrgCount(String metric){ + 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 + } + + @Override + GetOrgCountResponse getOrgCount(String metric, String date, String org) { + final response = new GetOrgCountResponse(metric, 0, [:]) + + // count is stored per date and per org, so it can be extracted from get method + response.count = metricsCounterStore.get(getKey(metric, date, org)) ?: 0L + + //when org and date is provided, return the org count for given date + if (org) { + 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/$PREFIX_ORG/*/$PREFIX_DAY/$date") + for(def entry : orgCounts) { + response.orgs.put(extractOrgFromKey(entry.key), entry.value) } } + return response + } @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) { def org = getOrg(email) - def key = getKey(prefix, LocalDate.now().format(dateFormatter), null) + def key = getKey(prefix, LocalDate.now().format(DATE_FORMATTER), null) metricsCounterStore.inc(key) log.trace("increment metrics count of: $key") if ( org ) { key = getKey(prefix, null, org) metricsCounterStore.inc(key) log.trace("increment metrics count of: $key") - key = getKey(prefix, LocalDate.now().format(dateFormatter), org) + key = getKey(prefix, LocalDate.now().format(DATE_FORMATTER), org) metricsCounterStore.inc(key) log.trace("increment metrics count of: $key") } @@ -116,15 +128,19 @@ 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 } + protected static String extractOrgFromKey(String key) { + Matcher matcher = ORG_DATE_KEY_PATTERN.matcher(key) + return matcher.matches() ? matcher.group(2) : "unknown" + } } diff --git a/src/main/groovy/io/seqera/wave/service/persistence/PersistenceService.groovy b/src/main/groovy/io/seqera/wave/service/persistence/PersistenceService.groovy index d326727a0..0469fc37b 100644 --- a/src/main/groovy/io/seqera/wave/service/persistence/PersistenceService.groovy +++ b/src/main/groovy/io/seqera/wave/service/persistence/PersistenceService.groovy @@ -19,12 +19,9 @@ package io.seqera.wave.service.persistence import groovy.transform.CompileStatic -import io.micronaut.runtime.event.annotation.EventListener import io.seqera.wave.core.ContainerDigestPair import io.seqera.wave.exception.NotFoundException import io.seqera.wave.service.scan.ScanResult -import io.seqera.wave.service.builder.BuildEvent - /** * A storage for statistic data * @@ -34,11 +31,6 @@ import io.seqera.wave.service.builder.BuildEvent @CompileStatic interface PersistenceService { - @EventListener - default void onBuildEvent(BuildEvent event) { - updateBuild(WaveBuildRecord.fromEvent(event)) - } - /** * Store a {@link WaveBuildRecord} object in the underlying persistence layer. * @@ -47,17 +39,7 @@ interface PersistenceService { * * @param build A {@link WaveBuildRecord} object */ - void createBuild(WaveBuildRecord build) - - /** - * Update the build record. This function is expect to update only the following fields - * - digest - * - duration - * - exitStatus - * - * @param build The build record to be updated - */ - void updateBuild(WaveBuildRecord build) + void saveBuild(WaveBuildRecord build) /** * Retrieve a {@link WaveBuildRecord} object for the given build id diff --git a/src/main/groovy/io/seqera/wave/service/persistence/impl/LocalPersistenceService.groovy b/src/main/groovy/io/seqera/wave/service/persistence/impl/LocalPersistenceService.groovy index 0073e0fb4..f3dc86686 100644 --- a/src/main/groovy/io/seqera/wave/service/persistence/impl/LocalPersistenceService.groovy +++ b/src/main/groovy/io/seqera/wave/service/persistence/impl/LocalPersistenceService.groovy @@ -40,12 +40,7 @@ class LocalPersistenceService implements PersistenceService { private Map scanStore = new HashMap<>() @Override - void createBuild(WaveBuildRecord record) { - buildStore[record.buildId] = record - } - - @Override - void updateBuild(WaveBuildRecord record) { + void saveBuild(WaveBuildRecord record) { buildStore[record.buildId] = record } diff --git a/src/main/groovy/io/seqera/wave/service/persistence/impl/SurrealPersistenceService.groovy b/src/main/groovy/io/seqera/wave/service/persistence/impl/SurrealPersistenceService.groovy index 4071d2022..f47ded54c 100644 --- a/src/main/groovy/io/seqera/wave/service/persistence/impl/SurrealPersistenceService.groovy +++ b/src/main/groovy/io/seqera/wave/service/persistence/impl/SurrealPersistenceService.groovy @@ -94,37 +94,19 @@ class SurrealPersistenceService implements PersistenceService { } @Override - void createBuild(WaveBuildRecord build) { - surrealDb.insertBuild(getAuthorization(), build) + void saveBuild(WaveBuildRecord build) { + surrealDb.insertBuildAsync(getAuthorization(), build).subscribe({ result-> + log.trace "Build request with id '$build.buildId' saved record: ${result}" + }, {error-> + def msg = error.message + if( error instanceof HttpClientResponseException ){ + msg += ":\n $error.response.body" + } + log.error("Error saving Build request record ${msg}\n${build}", error) + }) } @Override - void updateBuild(WaveBuildRecord build) { - // create the scan record - final statement = """\ - UPDATE wave_build - SET - digest = '${build.digest}', - duration = '${build.duration}', - exitStatus = ${build.exitStatus} - where - buildId = '${build.buildId}' - """.stripIndent() - final result = surrealDb - .sqlAsync(authorization, statement) - .subscribe({result -> - log.trace "Update build record id: $build.buildId: ${result}" - }, - {error-> - def msg = error.message - if( error instanceof HttpClientResponseException ){ - msg += ":\n $error.response.body" - } - log.error("Error updating build record id: $build.buildId => ${msg}\n", error) - }) - log.trace "Scan update result=$result" - } - WaveBuildRecord loadBuild(String buildId) { if( !buildId ) throw new IllegalArgumentException("Missing 'buildId' argument") diff --git a/src/main/groovy/io/seqera/wave/service/stream/StreamServiceImpl.groovy b/src/main/groovy/io/seqera/wave/service/stream/StreamServiceImpl.groovy index dfb5eecc4..78da8edfa 100644 --- a/src/main/groovy/io/seqera/wave/service/stream/StreamServiceImpl.groovy +++ b/src/main/groovy/io/seqera/wave/service/stream/StreamServiceImpl.groovy @@ -105,7 +105,7 @@ class StreamServiceImpl implements StreamService { // otherwise cache the blob and stream the resulting uri if( blobCacheService ) { log.debug "Streaming blob cache for route: $route" - final blobCache = blobCacheService .retrieveBlobCache(route, empty) + final blobCache = blobCacheService .retrieveBlobCache(route, empty, resp.headers) if( blobCache.succeeded() ) { return httpStream0(blobCache.locationUri) } diff --git a/src/main/groovy/io/seqera/wave/storage/DigestStoreFactory.java b/src/main/groovy/io/seqera/wave/storage/DigestStoreFactory.java index 04b4584b0..27823dc59 100644 --- a/src/main/groovy/io/seqera/wave/storage/DigestStoreFactory.java +++ b/src/main/groovy/io/seqera/wave/storage/DigestStoreFactory.java @@ -26,7 +26,7 @@ import static io.seqera.wave.util.StringUtils.trunc; /** - * Implement a factory class for {@link DigestStore} objets + * Implement a factory class for {@link DigestStore} objects * * @author Paolo Di Tommaso */ 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/auth/JwtAuth.groovy b/src/main/groovy/io/seqera/wave/tower/auth/JwtAuth.groovy index db44c52a7..150b77cec 100644 --- a/src/main/groovy/io/seqera/wave/tower/auth/JwtAuth.groovy +++ b/src/main/groovy/io/seqera/wave/tower/auth/JwtAuth.groovy @@ -65,7 +65,7 @@ class JwtAuth { final Instant createdAt /** - * The instant when the objct was last updated + * The instant when the object was last updated */ final Instant updatedAt @@ -73,6 +73,10 @@ class JwtAuth { new JwtAuth(value, endpoint, bearer, refresh, createdAt, updatedAt) } + JwtAuth withEndpoint(String value) { + new JwtAuth(key, value, bearer, refresh, createdAt, updatedAt) + } + JwtAuth withBearer(String value) { new JwtAuth(key, endpoint, value, refresh, createdAt, updatedAt) } diff --git a/src/main/groovy/io/seqera/wave/tower/auth/JwtAuthStore.groovy b/src/main/groovy/io/seqera/wave/tower/auth/JwtAuthStore.groovy index 5263b4570..17fc61a2b 100644 --- a/src/main/groovy/io/seqera/wave/tower/auth/JwtAuthStore.groovy +++ b/src/main/groovy/io/seqera/wave/tower/auth/JwtAuthStore.groovy @@ -79,7 +79,7 @@ class JwtAuthStore extends AbstractCacheStore { } if( result && !result.key ) { final now = Instant.now() - final patched = result.withKey(auth.key).withCreatedAt(now).withUpdatedAt(now) + final patched = result.withKey(auth.key).withEndpoint(auth.endpoint).withCreatedAt(now).withUpdatedAt(now) log.warn "JWT patched legacy record - $patched" return patched } diff --git a/src/main/groovy/io/seqera/wave/tower/auth/JwtConfig.groovy b/src/main/groovy/io/seqera/wave/tower/auth/JwtConfig.groovy index c6604b19d..e6fd0b7a6 100644 --- a/src/main/groovy/io/seqera/wave/tower/auth/JwtConfig.groovy +++ b/src/main/groovy/io/seqera/wave/tower/auth/JwtConfig.groovy @@ -48,7 +48,7 @@ class JwtConfig { Duration monitorInterval /** - * Determine the delay after which the JWT monitor service is launcher on boostrap + * Determine the delay after which the JWT monitor service is launcher on bootstrap */ @Value('${wave.jwt.monitor.delay:5s}') Duration monitorDelay diff --git a/src/main/groovy/io/seqera/wave/tower/auth/JwtMonitor.groovy b/src/main/groovy/io/seqera/wave/tower/auth/JwtMonitor.groovy index 5eff31a54..41262b847 100644 --- a/src/main/groovy/io/seqera/wave/tower/auth/JwtMonitor.groovy +++ b/src/main/groovy/io/seqera/wave/tower/auth/JwtMonitor.groovy @@ -102,6 +102,10 @@ class JwtMonitor implements Runnable { log.warn "JWT record has no receivedAt timestamp - entry=$entry" return } + if( !entry.endpoint ) { + log.warn "JWT record has no endpoint field - entry=$entry" + return + } // check if the JWT record is expired final deadline = entry.createdAt + tokenConfig.cache.duration if( now > deadline ) { 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/RegHelper.groovy b/src/main/groovy/io/seqera/wave/util/RegHelper.groovy index 2f8e4f2a1..ed55907d7 100644 --- a/src/main/groovy/io/seqera/wave/util/RegHelper.groovy +++ b/src/main/groovy/io/seqera/wave/util/RegHelper.groovy @@ -45,7 +45,7 @@ class RegHelper { final private static char PADDING = '_' as char final private static BaseEncoding BASE32 = BaseEncoding.base32() .withPadChar(PADDING) - // this clas is not to be extended + // this class is not to be extended private RegHelper(){ throw new AssertionError() } 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-reserved-words.yml b/src/main/resources/application-reserved-words.yml index f83b390f0..937051815 100644 --- a/src/main/resources/application-reserved-words.yml +++ b/src/main/resources/application-reserved-words.yml @@ -1341,6 +1341,7 @@ wave: - nigre - nil - nimphomania + - nymphomania - nip - nipple - nipplering diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5acc31b06..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: - kaniko-image: "gcr.io/kaniko-project/executor:v1.22.0" + 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/BuildControllerTest.groovy b/src/test/groovy/io/seqera/wave/controller/BuildControllerTest.groovy index b9c1dcb90..c72b899f5 100644 --- a/src/test/groovy/io/seqera/wave/controller/BuildControllerTest.groovy +++ b/src/test/groovy/io/seqera/wave/controller/BuildControllerTest.groovy @@ -101,7 +101,7 @@ class BuildControllerTest extends Specification { final event = new BuildEvent(build, result) final entry = WaveBuildRecord.fromEvent(event) and: - persistenceService.createBuild(entry) + persistenceService.saveBuild(entry) when: def req = HttpRequest.GET("/v1alpha1/builds/${build.buildId}") def res = client.toBlocking().exchange(req, WaveBuildRecord) @@ -141,7 +141,7 @@ class BuildControllerTest extends Specification { requestIp: '127.0.0.1', startTime: Instant.now().minus(1, ChronoUnit.DAYS) ) and: - persistenceService.createBuild(build1) + persistenceService.saveBuild(build1) sleep(500) when: diff --git a/src/test/groovy/io/seqera/wave/controller/ContainerControllerHttpTest.groovy b/src/test/groovy/io/seqera/wave/controller/ContainerControllerHttpTest.groovy index d2a98b8ed..411101c33 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 @@ -41,6 +42,7 @@ import io.seqera.wave.service.pairing.PairingService import io.seqera.wave.service.pairing.PairingServiceImpl import io.seqera.wave.tower.User import io.seqera.wave.tower.auth.JwtAuth +import io.seqera.wave.tower.client.ListCredentialsResponse import io.seqera.wave.tower.client.TowerClient import io.seqera.wave.tower.client.UserInfoResponse import jakarta.inject.Inject @@ -304,4 +306,104 @@ class ContainerControllerHttpTest extends Specification { deleteResp.status.code == 200 } + def 'should get the correct image name with imageSuffix name strategy'(){ + given: + def endpoint = 'http://cloud.seqera.io' + def token = 'foo' + def refresh = 'foo2' + def auth = JwtAuth.of(endpoint, token, refresh) + and: + pairingService.getPairingRecord(TOWER_SERVICE, endpoint) >> { new PairingRecord('tower', endpoint) } + towerClient.userInfo(endpoint, auth) >> CompletableFuture.completedFuture(new UserInfoResponse(user:new User(id:1))) + towerClient.listCredentials(_,_,_) >> CompletableFuture.completedFuture(new ListCredentialsResponse(credentials:[])) + + when: + def cfg = new ContainerConfig(workingDir: '/foo') + def packages = new PackagesSpec(channels: ['conda-forge', 'bioconda'], entries: ['salmon'], type: 'CONDA') + SubmitContainerTokenRequest request = + new SubmitContainerTokenRequest( + towerAccessToken: token, + towerRefreshToken: refresh, + towerEndpoint: endpoint, + towerWorkspaceId: 10, + nameStrategy: "imageSuffix", + packages: packages, + freeze: true, + buildRepository: "registry/repository") + and: + def response = httpClient + .toBlocking() + .exchange(HttpRequest.POST("/v1alpha2/container", request), SubmitContainerTokenResponse) + .body() + + then: + response.targetImage.startsWith("registry/repository/salmon") + } + + def 'should get the correct image name with tagPrefix name strategy'(){ + given: + def endpoint = 'http://cloud.seqera.io' + def token = 'foo' + def refresh = 'foo2' + def auth = JwtAuth.of(endpoint, token, refresh) + and: + pairingService.getPairingRecord(TOWER_SERVICE, endpoint) >> { new PairingRecord('tower', endpoint) } + towerClient.userInfo(endpoint, auth) >> CompletableFuture.completedFuture(new UserInfoResponse(user:new User(id:1))) + towerClient.listCredentials(_,_,_) >> CompletableFuture.completedFuture(new ListCredentialsResponse(credentials:[])) + + when: + def cfg = new ContainerConfig(workingDir: '/foo') + def packages = new PackagesSpec(channels: ['conda-forge', 'bioconda'], entries: ['salmon'], type: 'CONDA') + SubmitContainerTokenRequest request = + new SubmitContainerTokenRequest( + towerAccessToken: token, + towerRefreshToken: refresh, + towerEndpoint: endpoint, + towerWorkspaceId: 10, + nameStrategy: "tagPrefix", + packages: packages, + freeze: true, + buildRepository: "registry/repository") + and: + def response = httpClient + .toBlocking() + .exchange(HttpRequest.POST("/v1alpha2/container", request), SubmitContainerTokenResponse) + .body() + + then: + response.targetImage.startsWith("registry/repository:salmon") + } + + def 'should get the correct image name with default name strategy'(){ + given: + def endpoint = 'http://cloud.seqera.io' + def token = 'foo' + def refresh = 'foo2' + def auth = JwtAuth.of(endpoint, token, refresh) + and: + pairingService.getPairingRecord(TOWER_SERVICE, endpoint) >> { new PairingRecord('tower', endpoint) } + towerClient.userInfo(endpoint, auth) >> CompletableFuture.completedFuture(new UserInfoResponse(user:new User(id:1))) + towerClient.listCredentials(_,_,_) >> CompletableFuture.completedFuture(new ListCredentialsResponse(credentials:[])) + + when: + def cfg = new ContainerConfig(workingDir: '/foo') + def packages = new PackagesSpec(channels: ['conda-forge', 'bioconda'], entries: ['salmon'], type: 'CONDA') + SubmitContainerTokenRequest request = + new SubmitContainerTokenRequest( + towerAccessToken: token, + towerRefreshToken: refresh, + towerEndpoint: endpoint, + towerWorkspaceId: 10, + packages: packages, + freeze: true, + buildRepository: "registry/repository") + and: + def response = httpClient + .toBlocking() + .exchange(HttpRequest.POST("/v1alpha2/container", request), SubmitContainerTokenResponse) + .body() + + then: + response.targetImage.startsWith("registry/repository:salmon") + } } diff --git a/src/test/groovy/io/seqera/wave/controller/MetricsControllerTest.groovy b/src/test/groovy/io/seqera/wave/controller/MetricsControllerTest.groovy index 5e3c073ac..c3af168ef 100644 --- a/src/test/groovy/io/seqera/wave/controller/MetricsControllerTest.groovy +++ b/src/test/groovy/io/seqera/wave/controller/MetricsControllerTest.groovy @@ -75,7 +75,7 @@ class MetricsControllerTest extends Specification { def res = client.toBlocking().exchange(req, Map) then: 'should get the correct count' - res.body() == [count: 3] + res.body() == [metric:'builds', count:3, orgs:['org1.com': 1, 'org2.com': 1]] res.status.code == 200 when: 'date and org is provided' @@ -83,7 +83,7 @@ class MetricsControllerTest extends Specification { res = client.toBlocking().exchange(req, Map) then: 'should get the correct count' - res.body() == [count: 1] + res.body() == [metric:'builds', count:1, orgs:['org1.com': 1]] res.status.code == 200 when: 'only org is provided' @@ -91,7 +91,7 @@ class MetricsControllerTest extends Specification { res = client.toBlocking().exchange(req, Map) then: 'should get the correct count' - res.body() == [count: 1] + res.body() == [metric:'builds', count:1, orgs:['org1.com': 1]] res.status.code == 200 when: 'no param is provided' @@ -119,15 +119,15 @@ class MetricsControllerTest extends Specification { def res = client.toBlocking().exchange(req, Map) then: 'should get the correct count' - res.body() == [count: 3] + res.body() == [metric:'pulls', count:3, orgs:['org1.com': 1, 'org2.com': 1]] res.status.code == 200 when: 'date and org is provided' - req = HttpRequest.GET("/v1alpha2/metrics/pulls?date=$date&org=org2.com").basicAuth("username", "password") + req = HttpRequest.GET("/v1alpha2/metrics/pulls?date=$date&org=org1.com").basicAuth("username", "password") res = client.toBlocking().exchange(req, Map) then: 'should get the correct count' - res.body() == [count: 1] + res.body() == [metric:'pulls', count:1, orgs:['org1.com': 1]] res.status.code == 200 when: 'only org is provided' @@ -135,7 +135,7 @@ class MetricsControllerTest extends Specification { res = client.toBlocking().exchange(req, Map) then: 'should get the correct count' - res.body() == [count: 1] + res.body() == [metric:'pulls', count:1, orgs:['org2.com': 1]] res.status.code == 200 when: 'no param is provided' @@ -163,15 +163,15 @@ class MetricsControllerTest extends Specification { def res = client.toBlocking().exchange(req, Map) then: 'should get the correct count' - res.body() == [count: 3] + res.body() == [metric:'fusion', count:3, orgs:['org1.com': 1, 'org2.com': 1]] res.status.code == 200 when: 'date and org is provided' - req = HttpRequest.GET("/v1alpha2/metrics/fusion/pulls?date=$date&org=org2.com").basicAuth("username", "password") + req = HttpRequest.GET("/v1alpha2/metrics/fusion/pulls?date=$date&org=org1.com").basicAuth("username", "password") res = client.toBlocking().exchange(req, Map) then: 'should get the correct count' - res.body() == [count: 1] + res.body() == [metric:'fusion', count:1, orgs:['org1.com': 1]] res.status.code == 200 when: 'only org is provided' @@ -179,7 +179,7 @@ class MetricsControllerTest extends Specification { res = client.toBlocking().exchange(req, Map) then: 'should get the correct count' - res.body() == [count: 1] + res.body() == [metric:'fusion', count:1, orgs:['org2.com': 1]] res.status.code == 200 when: 'no param is provided' diff --git a/src/test/groovy/io/seqera/wave/controller/ViewControllerTest.groovy b/src/test/groovy/io/seqera/wave/controller/ViewControllerTest.groovy index e449af64d..772d61151 100644 --- a/src/test/groovy/io/seqera/wave/controller/ViewControllerTest.groovy +++ b/src/test/groovy/io/seqera/wave/controller/ViewControllerTest.groovy @@ -119,7 +119,7 @@ class ViewControllerTest extends Specification { exitStatus: 0 ) when: - persistenceService.createBuild(record1) + persistenceService.saveBuild(record1) and: def request = HttpRequest.GET("/view/builds/${record1.buildId}") def response = client.toBlocking().exchange(request, String) @@ -148,7 +148,7 @@ class ViewControllerTest extends Specification { exitStatus: 0 ) when: - persistenceService.createBuild(record1) + persistenceService.saveBuild(record1) and: def request = HttpRequest.GET("/view/builds/${record1.buildId}") def response = client.toBlocking().exchange(request, String) @@ -179,7 +179,7 @@ class ViewControllerTest extends Specification { exitStatus: 0 ) when: - persistenceService.createBuild(record1) + persistenceService.saveBuild(record1) and: def request = HttpRequest.GET("/view/builds/${record1.buildId}") def response = client.toBlocking().exchange(request, String) 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/encoder/MoshiEncodingStrategyTest.groovy b/src/test/groovy/io/seqera/wave/encoder/MoshiEncodingStrategyTest.groovy index 99565e1a6..f9074a75e 100644 --- a/src/test/groovy/io/seqera/wave/encoder/MoshiEncodingStrategyTest.groovy +++ b/src/test/groovy/io/seqera/wave/encoder/MoshiEncodingStrategyTest.groovy @@ -20,17 +20,22 @@ package io.seqera.wave.encoder import spock.lang.Specification +import java.nio.file.Path import java.time.Duration import java.time.Instant +import io.seqera.wave.api.BuildContext import io.seqera.wave.api.ContainerConfig import io.seqera.wave.core.ContainerPlatform import io.seqera.wave.service.ContainerRequestData +import io.seqera.wave.service.builder.BuildEvent +import io.seqera.wave.service.builder.BuildRequest import io.seqera.wave.service.builder.BuildResult import io.seqera.wave.service.pairing.socket.msg.PairingHeartbeat import io.seqera.wave.service.pairing.socket.msg.PairingResponse import io.seqera.wave.service.pairing.socket.msg.ProxyHttpRequest import io.seqera.wave.service.pairing.socket.msg.ProxyHttpResponse +import io.seqera.wave.service.persistence.WaveBuildRecord import io.seqera.wave.storage.DigestStore import io.seqera.wave.storage.DockerDigestStore import io.seqera.wave.storage.LazyDigestStore @@ -361,4 +366,43 @@ class MoshiEncodingStrategyTest extends Specification { copy.size == data.size } + def 'should encode and decode wave build record' () { + given: + def encoder = new MoshiEncodeStrategy() { } + and: + def context = new BuildContext('http://foo.com', '12345', 100, '67890') + and: + def build = new BuildRequest( + '12345', + 'from foo', + 'conda spec', + 'spack spec', + Path.of("/some/path"), + 'docker.io/some:image:12345', + PlatformId.NULL, + ContainerPlatform.of('linux/amd64'), + 'cacherepo', + "1.2.3.4", + '{"config":"json"}', + null, + null, + 'scan12345', + context, + null) + .withBuildId('1') + def result = new BuildResult(build.buildId, -1, "ok", Instant.now(), Duration.ofSeconds(3), null) + def event = new BuildEvent(build, result) + + and: + def record1 = WaveBuildRecord.fromEvent(event) + + when: + def json = encoder.encode(record1) + and: + def copy = encoder.decode(json) + then: + copy.getClass() == record1.getClass() + and: + copy == record1 + } } diff --git a/src/test/groovy/io/seqera/wave/filter/PullMetricsRequestsFilterTest.groovy b/src/test/groovy/io/seqera/wave/filter/PullMetricsRequestsFilterTest.groovy index fd2f13f59..e7278c535 100644 --- a/src/test/groovy/io/seqera/wave/filter/PullMetricsRequestsFilterTest.groovy +++ b/src/test/groovy/io/seqera/wave/filter/PullMetricsRequestsFilterTest.groovy @@ -69,7 +69,7 @@ class PullMetricsRequestsFilterTest extends Specification { def res = httpClient.toBlocking().exchange(req, Map) then: - res.body() == [count: 1] + res.body() == [metric:'pulls', count: 1] res.status.code == 200 } } 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/BlobCacheInfoTest.groovy b/src/test/groovy/io/seqera/wave/service/blob/BlobCacheInfoTest.groovy index 58c99b32b..8b97bdc07 100644 --- a/src/test/groovy/io/seqera/wave/service/blob/BlobCacheInfoTest.groovy +++ b/src/test/groovy/io/seqera/wave/service/blob/BlobCacheInfoTest.groovy @@ -30,73 +30,88 @@ class BlobCacheInfoTest extends Specification { def 'should create blob info' () { expect: - BlobCacheInfo.create('http://foo.com', [:]) + BlobCacheInfo.create('http://foo.com', [:], [:]) .locationUri == 'http://foo.com' and: - BlobCacheInfo.create('http://foo.com', [:]) + BlobCacheInfo.create('http://foo.com', [:], [:]) .headers == [:] and: - BlobCacheInfo.create('http://foo.com', [Foo:['alpha'], Bar:['delta', 'gamma', 'omega']]) + BlobCacheInfo.create('http://foo.com', [Foo:['alpha'], Bar:['delta', 'gamma', 'omega']], [:]) .headers == [Foo:'alpha', Bar: 'delta,gamma,omega'] - and: - BlobCacheInfo.create1('http://foo.com', [Foo:'alpha', Bar:'beta']) - .headers == [Foo:'alpha', Bar: 'beta'] - } def 'should find content type' () { expect: - BlobCacheInfo.create1('http:/foo', HEADERS ).getContentType() == EXPECTED + BlobCacheInfo.create('http://foo', [:], HEADERS ).getContentType() == EXPECTED where: HEADERS | EXPECTED - ['Content-Type': 'alpha'] | 'alpha' - ['Content-type': 'delta'] | 'delta' - ['content-type': 'gamma'] | 'gamma' + ['Content-Type': ['alpha']] | 'alpha' + ['Content-type': ['delta']] | 'delta' + ['content-type': ['gamma']] | 'gamma' } - def 'should find content type' () { + def 'should find cache control' () { expect: - BlobCacheInfo.create1('http:/foo', HEADERS ).getCacheControl() == EXPECTED + BlobCacheInfo.create('http://foo', [:], HEADERS ).getCacheControl() == EXPECTED where: - HEADERS | EXPECTED - ['Cache-Control': 'alpha'] | 'alpha' - ['cache-control': 'delta'] | 'delta' - ['CACHE-CONTROL': 'gamma'] | 'gamma' + HEADERS | EXPECTED + ['Cache-Control': ['alpha']] | 'alpha' + ['cache-control': ['delta']] | 'delta' + ['CACHE-CONTROL': ['gamma']] | 'gamma' + + } + + def 'should find content length' () { + expect: + BlobCacheInfo.create('http://foo', [:], HEADERS ).getContentLength() == EXPECTED + + where: + HEADERS | EXPECTED + [:] | null + ['Content-Length': ['']] | null + ['Content-Length': ['100']] | 100L + ['content-length': ['200']] | 200L } def 'should complete blob info' () { given: def location = 'http://foo.com' - def headers = [Foo:'something'] - def cache = BlobCacheInfo.create1(location, headers) + def headers = [Foo:['something']] + def response = ['Content-Length':['100'], 'Content-Type':['text'], 'Cache-Control': ['12345']] + def cache = BlobCacheInfo.create(location, headers, response) when: def result = cache.completed(0, 'OK') then: - result.headers == headers + result.headers == [Foo:'something'] result.locationUri == 'http://foo.com' result.creationTime == cache.creationTime result.completionTime >= cache.creationTime result.exitStatus == 0 result.logs == 'OK' + result.contentLength == 100L + result.contentType == 'text' + result.cacheControl == '12345' and: result.done() result.succeeded() - when: result = cache.completed(1, 'Oops') then: - result.headers == headers + result.headers == [Foo:'something'] result.locationUri == 'http://foo.com' result.creationTime == cache.creationTime result.completionTime >= cache.creationTime result.exitStatus == 1 + result.contentLength == 100L + result.contentType == 'text' + result.cacheControl == '12345' and: result.done() !result.succeeded() @@ -105,17 +120,22 @@ class BlobCacheInfoTest extends Specification { def 'should fail blob info' () { given: def location = 'http://foo.com' - def headers = [Foo:'something'] - def cache = BlobCacheInfo.create1(location, headers) + def headers = [Foo:['something']] + def response = ['Content-Length':['100'], 'Content-Type':['text'], 'Cache-Control': ['12345']] + def cache = BlobCacheInfo.create(location, headers, response) + when: def result = cache.failed('Oops') then: - result.headers == headers + result.headers == [Foo:'something'] result.locationUri == 'http://foo.com' result.creationTime == cache.creationTime result.completionTime >= cache.creationTime result.exitStatus == null result.logs == 'Oops' + result.contentLength == 100L + result.contentType == 'text' + result.cacheControl == '12345' and: result.done() !result.succeeded() @@ -124,17 +144,22 @@ class BlobCacheInfoTest extends Specification { def 'should cache blob info' () { given: def location = 'http://foo.com' - def headers = [Foo:'something'] - def cache = BlobCacheInfo.create1(location, headers) + def headers = [Foo:['something']] + def response = ['Content-Length':['100'], 'Content-Type':['text'], 'Cache-Control': ['12345']] + and: + def cache = BlobCacheInfo.create(location, headers, response) when: def result = cache.cached() then: - result.headers == headers + result.headers == [Foo:'something'] result.locationUri == 'http://foo.com' result.creationTime == cache.creationTime result.completionTime == cache.creationTime result.exitStatus == 0 result.logs == null + result.cacheControl == '12345' + result.contentType == 'text' + result.contentLength == 100L and: result.done() result.succeeded() @@ -160,11 +185,27 @@ class BlobCacheInfoTest extends Specification { def 'should change location uri' () { given: - def result = BlobCacheInfo.create('http://foo.com', [:]) + def headers = [Foo:['something']] + def response = ['Content-Length':['100'], 'Content-Type':['text'], 'Cache-Control': ['12345']] - expect: - result.locationUri == 'http://foo.com' - result.withLocation('http://bar.com') - .locationUri == 'http://bar.com' + when: + def result1 = BlobCacheInfo.create('http://foo.com', headers, response) + then: + result1.locationUri == 'http://foo.com' + and: + result1.headers == [Foo:'something'] + result1.contentType == 'text' + result1.contentLength == 100L + result1.cacheControl == '12345' + + when: + def result2 = result1.withLocation('http://bar.com') + then: + result2.locationUri == 'http://bar.com' + and: + result2.headers == [Foo:'something'] + result2.contentType == 'text' + result2.contentLength == 100L + result2.cacheControl == '12345' } } 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 635931683..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 @@ -43,7 +52,7 @@ class BlobCacheServiceImplTest extends Specification implements AwsS3TestContain result == ['s5cmd', '--json', 'pipe', 's3://store/blobs/docker.io/v2/library/ubuntu/manifests/sha256:aabbcc'] when: - result = service.s5cmd(route, BlobCacheInfo.create('http://foo', ['Content-Type':['foo'], 'Cache-Control': ['bar']])) + result = service.s5cmd(route, BlobCacheInfo.create('http://foo', [:], ['Content-Type':['foo'], 'Cache-Control': ['bar']])) then: result == ['s5cmd', '--json', 'pipe', '--content-type', 'foo', '--cache-control', 'bar', 's3://store/blobs/docker.io/v2/library/ubuntu/manifests/sha256:aabbcc'] @@ -67,13 +76,13 @@ class BlobCacheServiceImplTest extends Specification implements AwsS3TestContain def service = new BlobCacheServiceImpl( blobConfig: new BlobCacheConfig(storageBucket: 's3://store/blobs/'), proxyService: proxyService ) def route = RoutePath.v2manifestPath(ContainerCoordinates.parse('ubuntu@sha256:aabbcc')) and: - def headers = ['content-type': 'something'] - def blobCache = BlobCacheInfo.create1('http://foo', headers) + def response = ['content-type': ['something']] + def blobCache = BlobCacheInfo.create('http://foo', ['foo': ['one']], response) when: def result = service.transferCommand(route, blobCache) then: - proxyService.curl(route, headers) >> ['curl', '-X', 'GET', 'http://foo'] + proxyService.curl(route, [foo:'one']) >> ['curl', '-X', 'GET', 'http://foo'] and: result == [ 'sh', @@ -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/builder/BuildStrategyTest.groovy b/src/test/groovy/io/seqera/wave/service/builder/BuildStrategyTest.groovy index 5eda1fe44..db621a613 100644 --- a/src/test/groovy/io/seqera/wave/service/builder/BuildStrategyTest.groovy +++ b/src/test/groovy/io/seqera/wave/service/builder/BuildStrategyTest.groovy @@ -22,81 +22,89 @@ import spock.lang.Specification import java.nio.file.Path -import io.seqera.wave.configuration.BuildConfig +import io.micronaut.test.extensions.spock.annotation.MicronautTest import io.seqera.wave.core.ContainerPlatform import io.seqera.wave.tower.PlatformId import io.seqera.wave.util.ContainerHelper +import jakarta.inject.Inject /** * * @author Paolo Di Tommaso */ +@MicronautTest class BuildStrategyTest extends Specification { - def 'should get kaniko command' () { + @Inject + BuildStrategy strategy + + def 'should get buildkit command' () { given: - def cache = 'reg.io/wave/build/cache' - def service = Spy(BuildStrategy) - service.@buildConfig = new BuildConfig() - and: def req = new BuildRequest( + id: 'c168dba125e28777', workDir: Path.of('/work/foo/c168dba125e28777'), platform: ContainerPlatform.of('linux/amd64'), targetImage: 'quay.io/wave:c168dba125e28777', cacheRepository: 'reg.io/wave/build/cache' ) when: - def cmd = service.launchCmd(req) + def cmd = strategy.launchCmd(req) then: cmd == [ - '--dockerfile', - '/work/foo/c168dba125e28777/Containerfile', - '--context', - '/work/foo/c168dba125e28777/context', - '--destination', - 'quay.io/wave:c168dba125e28777', - '--cache=true', - '--custom-platform', - 'linux/amd64', - '--cache-repo', - 'reg.io/wave/build/cache', + 'build', + '--frontend', + 'dockerfile.v0', + '--local', + 'dockerfile=/work/foo/c168dba125e28777', + '--opt', + 'filename=Containerfile', + '--local', + 'context=/work/foo/c168dba125e28777/context', + '--output', + 'type=image,name=quay.io/wave:c168dba125e28777,push=true,oci-mediatypes=true', + '--opt', + 'platform=linux/amd64', + '--export-cache', + 'type=registry,image-manifest=true,ref=reg.io/wave/build/cache:c168dba125e28777,mode=max,ignore-error=true,oci-mediatypes=true,compression=gzip,force-compression=false', + '--import-cache', + 'type=registry,ref=reg.io/wave/build/cache:c168dba125e28777' ] } - def 'should get kaniko command with build context' () { + def 'should get buildkit command with build context' () { given: - def cache = 'reg.io/wave/build/cache' - def service = Spy(BuildStrategy) - service.@buildConfig = new BuildConfig() - and: def req = new BuildRequest( + id: 'c168dba125e28777', workDir: Path.of('/work/foo/3980470531b4a52a'), platform: ContainerPlatform.of('linux/amd64'), targetImage: 'quay.io/wave:3980470531b4a52a', cacheRepository: 'reg.io/wave/build/cache' ) when: - def cmd = service.launchCmd(req) + def cmd = strategy.launchCmd(req) then: cmd == [ - '--dockerfile', - '/work/foo/3980470531b4a52a/Containerfile', - '--context', - '/work/foo/3980470531b4a52a/context', - '--destination', - 'quay.io/wave:3980470531b4a52a', - '--cache=true', - '--custom-platform', - 'linux/amd64', - '--cache-repo', - 'reg.io/wave/build/cache', + 'build', + '--frontend', + 'dockerfile.v0', + '--local', + 'dockerfile=/work/foo/3980470531b4a52a', + '--opt', + 'filename=Containerfile', + '--local', + 'context=/work/foo/3980470531b4a52a/context', + '--output', + 'type=image,name=quay.io/wave:3980470531b4a52a,push=true,oci-mediatypes=true', + '--opt', + 'platform=linux/amd64', + '--export-cache', + 'type=registry,image-manifest=true,ref=reg.io/wave/build/cache:c168dba125e28777,mode=max,ignore-error=true,oci-mediatypes=true,compression=gzip,force-compression=false', + '--import-cache', + 'type=registry,ref=reg.io/wave/build/cache:c168dba125e28777' ] } def 'should get singularity command' () { given: - def cache = 'reg.io/wave/build/cache' - def service = Spy(BuildStrategy) - and: def req = new BuildRequest( workDir: Path.of('/work/foo/c168dba125e28777'), platform: ContainerPlatform.of('linux/amd64'), @@ -104,7 +112,7 @@ class BuildStrategyTest extends Specification { format: BuildFormat.SINGULARITY, cacheRepository: 'reg.io/wave/build/cache' ) when: - def cmd = service.launchCmd(req) + def cmd = strategy.launchCmd(req) then: cmd == [ "sh", @@ -155,5 +163,4 @@ class BuildStrategyTest extends Specification { build.buildId == 'af15cb0a413a2d48_100' build.workDir == Path.of('.').toRealPath().resolve('some/path/af15cb0a413a2d48_100') } - } 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 556b50ae1..72cd7abdc 100644 --- a/src/test/groovy/io/seqera/wave/service/builder/ContainerBuildServiceTest.groovy +++ b/src/test/groovy/io/seqera/wave/service/builder/ContainerBuildServiceTest.groovy @@ -24,6 +24,7 @@ import spock.lang.Specification import java.nio.file.Files import java.nio.file.Path import java.time.Duration +import java.time.Instant import com.sun.net.httpserver.HttpExchange import com.sun.net.httpserver.HttpHandler @@ -39,9 +40,14 @@ import io.seqera.wave.configuration.BuildConfig import io.seqera.wave.configuration.HttpClientConfig import io.seqera.wave.configuration.SpackConfig import io.seqera.wave.core.ContainerPlatform +import io.seqera.wave.service.builder.store.BuildRecordStore import io.seqera.wave.service.cleanup.CleanupStrategy import io.seqera.wave.service.inspect.ContainerInspectServiceImpl +import io.seqera.wave.service.persistence.PersistenceService +import io.seqera.wave.service.persistence.WaveBuildRecord import io.seqera.wave.storage.reader.ContentReaderFactory +import io.seqera.wave.test.RedisTestContainer +import io.seqera.wave.test.SurrealDBTestContainer import io.seqera.wave.tower.PlatformId import io.seqera.wave.util.Packer import io.seqera.wave.util.SpackHelper @@ -54,7 +60,8 @@ import io.seqera.wave.util.ContainerHelper */ @Slf4j @MicronautTest -class ContainerBuildServiceTest extends Specification { + +class ContainerBuildServiceTest extends Specification implements RedisTestContainer, SurrealDBTestContainer{ @Inject ContainerBuildServiceImpl service @Inject RegistryLookupService lookupService @@ -62,6 +69,8 @@ class ContainerBuildServiceTest extends Specification { @Inject ContainerInspectServiceImpl dockerAuthService @Inject HttpClientConfig httpClientConfig @Inject BuildConfig buildConfig + @Inject BuildRecordStore buildRecordStore + @Inject PersistenceService persistenceService @Requires({System.getenv('AWS_ACCESS_KEY_ID') && System.getenv('AWS_SECRET_ACCESS_KEY')}) @@ -472,4 +481,112 @@ class ContainerBuildServiceTest extends Specification { server?.stop(0) } + void "an event insert a build"() { + given: + final request = new BuildRequest( + 'container1234', + 'test', + 'test', + 'test', + Path.of("."), + 'docker.io/my/repo:container1234', + PlatformId.NULL, + ContainerPlatform.of('amd64'), + 'docker.io/my/cache', + '127.0.0.1', + '{"config":"json"}', + null, + null, + 'scan12345', + null, + BuildFormat.DOCKER + ).withBuildId('123') + + and: + def result = new BuildResult(request.buildId, 0, "content", Instant.now(), Duration.ofSeconds(1), 'abc123') + def event = new BuildEvent(request, result) + + when: + service.onBuildEvent(event) + + then: + def record = service.getBuildRecord(request.buildId) + record.buildId == request.buildId + record.digest == 'abc123' + } + + void "should create build record in redis"() { + given: + final request = new BuildRequest( + 'container1234', + 'test', + 'test', + 'test', + Path.of("."), + 'docker.io/my/repo:container1234', + PlatformId.NULL, + ContainerPlatform.of('amd64'), + 'docker.io/my/cache', + '127.0.0.1', + '{"config":"json"}', + null, + null, + 'scan12345', + null, + BuildFormat.DOCKER + ).withBuildId('123') + + and: + def result = BuildResult.completed(request.buildId, 1, 'Hello', Instant.now().minusSeconds(60), 'xyz') + + and: + def build = WaveBuildRecord.fromEvent(new BuildEvent(request, result)) + + when: + service.createBuildRecord(build.buildId, build) + + then: + def record = buildRecordStore.getBuildRecord(request.buildId) + record.buildId == request.buildId + record.digest == 'xyz' + } + + void "should save build record in redis and surrealdb"() { + given: + final request = new BuildRequest( + 'container1234', + 'test', + 'test', + 'test', + Path.of("."), + 'docker.io/my/repo:container1234', + PlatformId.NULL, + ContainerPlatform.of('amd64'), + 'docker.io/my/cache', + '127.0.0.1', + '{"config":"json"}', + null, + null, + 'scan12345', + null, + BuildFormat.DOCKER + ).withBuildId('123') + + and: + def result = new BuildResult(request.buildId, 0, "content", Instant.now(), Duration.ofSeconds(1), 'abc123') + def event = new BuildEvent(request, result) + + when: + service.saveBuildRecord(event) + + then: + def record = persistenceService.loadBuild(request.buildId) + record.buildId == request.buildId + record.digest == 'abc123' + + and: + def record2 = buildRecordStore.getBuildRecord(request.buildId) + record2.buildId == request.buildId + record2.digest == 'abc123' + } } 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 658066ae1..31cbc7aef 100644 --- a/src/test/groovy/io/seqera/wave/service/builder/DockerBuildStrategyTest.groovy +++ b/src/test/groovy/io/seqera/wave/service/builder/DockerBuildStrategyTest.groovy @@ -45,41 +45,50 @@ class DockerBuildStrategyTest extends Specification { and: def work = Path.of('/work/foo') when: - def cmd = service.cmdForKaniko(work, null, null, null) + def cmd = service.cmdForBuildkit(work, null, null, null) then: cmd == ['docker', 'run', '--rm', + '--privileged', '-v', '/work/foo:/work/foo', - 'gcr.io/kaniko-project/executor:v1.22.0'] + '--entrypoint', + 'buildctl-daemonless.sh', + 'moby/buildkit:v0.14.1-rootless'] when: - cmd = service.cmdForKaniko(work, Path.of('/foo/creds.json'), null, ContainerPlatform.of('arm64')) + cmd = service.cmdForBuildkit(work, Path.of('/foo/creds.json'), null, ContainerPlatform.of('arm64')) then: cmd == ['docker', 'run', '--rm', + '--privileged', '-v', '/work/foo:/work/foo', - '-v', '/foo/creds.json:/kaniko/.docker/config.json:ro', + '--entrypoint', + 'buildctl-daemonless.sh', + '-v', '/foo/creds.json:/home/user/.docker/config.json:ro', '--platform', 'linux/arm64', - 'gcr.io/kaniko-project/executor:v1.22.0'] + 'moby/buildkit:v0.14.1-rootless'] when: - cmd = service.cmdForKaniko(work, Path.of('/foo/creds.json'), spackConfig, null) + cmd = service.cmdForBuildkit(work, Path.of('/foo/creds.json'), spackConfig, null) then: cmd == ['docker', 'run', '--rm', + '--privileged', '-v', '/work/foo:/work/foo', - '-v', '/foo/creds.json:/kaniko/.docker/config.json:ro', + '--entrypoint', + 'buildctl-daemonless.sh', + '-v', '/foo/creds.json:/home/user/.docker/config.json:ro', '-v', '/host/spack/key:/opt/spack/key:ro', - 'gcr.io/kaniko-project/executor:v1.22.0'] + 'moby/buildkit:v0.14.1-rootless'] cleanup: ctx.close() } - def 'should get kaniko build command' () { + def 'should get buildkit build command' () { given: def ctx = ApplicationContext.run() def service = ctx.getBean(DockerBuildStrategy) @@ -87,6 +96,7 @@ class DockerBuildStrategyTest extends Specification { def creds = Path.of('/work/creds.json') and: def req = new BuildRequest( + id: '89fb83ce6ec8627b', workDir: Path.of('/work/foo/89fb83ce6ec8627b'), platform: ContainerPlatform.of('linux/amd64'), targetImage: 'repo:89fb83ce6ec8627b', @@ -97,42 +107,30 @@ class DockerBuildStrategyTest extends Specification { cmd == ['docker', 'run', '--rm', + '--privileged', '-v', '/work/foo/89fb83ce6ec8627b:/work/foo/89fb83ce6ec8627b', - '-v', '/work/creds.json:/kaniko/.docker/config.json:ro', + '--entrypoint', + 'buildctl-daemonless.sh', + '-v', '/work/creds.json:/home/user/.docker/config.json:ro', '--platform', 'linux/amd64', - 'gcr.io/kaniko-project/executor:v1.22.0', - '--dockerfile', '/work/foo/89fb83ce6ec8627b/Containerfile', - '--context', '/work/foo/89fb83ce6ec8627b/context', - '--destination', 'repo:89fb83ce6ec8627b', - '--cache=true', - '--custom-platform', 'linux/amd64', - '--cache-repo', 'reg.io/wave/build/cache' ] - - cleanup: - ctx.close() - } - - def 'should disable compress-caching' () { - given: - def ctx = ApplicationContext.run(['wave.build.compress-caching': false]) - def service = ctx.getBean(DockerBuildStrategy) - and: - def req = new BuildRequest( - workDir: Path.of('/work/foo/89fb83ce6ec8627b'), - platform: ContainerPlatform.of('linux/amd64'), - targetImage: 'repo:89fb83ce6ec8627b', - cacheRepository: 'reg.io/wave/build/cache' ) - when: - def cmd = service.launchCmd(req) - then: - cmd == [ - '--dockerfile', '/work/foo/89fb83ce6ec8627b/Containerfile', - '--context', '/work/foo/89fb83ce6ec8627b/context', - '--destination', 'repo:89fb83ce6ec8627b', - '--cache=true', - '--custom-platform', 'linux/amd64', - '--cache-repo', 'reg.io/wave/build/cache', - '--compressed-caching=false' ] + 'moby/buildkit:v0.14.1-rootless', + 'build', + '--frontend', + 'dockerfile.v0', + '--local', + 'dockerfile=/work/foo/89fb83ce6ec8627b', + '--opt', + 'filename=Containerfile', + '--local', + 'context=/work/foo/89fb83ce6ec8627b/context', + '--output', + 'type=image,name=repo:89fb83ce6ec8627b,push=true,oci-mediatypes=true', + '--opt', + 'platform=linux/amd64', + '--export-cache', + 'type=registry,image-manifest=true,ref=reg.io/wave/build/cache:89fb83ce6ec8627b,mode=max,ignore-error=true,oci-mediatypes=true,compression=gzip,force-compression=false', + '--import-cache', + 'type=registry,ref=reg.io/wave/build/cache:89fb83ce6ec8627b' ] cleanup: ctx.close() 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 6f4af98e8..d50f44ca3 100644 --- a/src/test/groovy/io/seqera/wave/service/builder/KubeBuildStrategyTest.groovy +++ b/src/test/groovy/io/seqera/wave/service/builder/KubeBuildStrategyTest.groovy @@ -103,8 +103,8 @@ class KubeBuildStrategyTest extends Specification { def targetImage = ContainerHelper.makeTargetImage(BuildFormat.DOCKER, repo, containerId, null, null, null) 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 kaniko image' - strategy.getBuildImage(req) == 'gcr.io/kaniko-project/executor:v1.22.0' + then: 'should return buildkit image' + 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/counter/impl/LocalCounterProviderTest.groovy b/src/test/groovy/io/seqera/wave/service/counter/impl/LocalCounterProviderTest.groovy index 3ce53dad3..1787b61f5 100644 --- a/src/test/groovy/io/seqera/wave/service/counter/impl/LocalCounterProviderTest.groovy +++ b/src/test/groovy/io/seqera/wave/service/counter/impl/LocalCounterProviderTest.groovy @@ -62,11 +62,15 @@ class LocalCounterProviderTest extends Specification { localCounterProvider.inc('metrics/v1', 'pulls/o/foo.it', 1) localCounterProvider.inc('metrics/v1', 'pulls/o/bar.es', 2) localCounterProvider.inc('metrics/v1', 'pulls/o/abc.in', 3) - localCounterProvider.inc('metrics/v1', 'pulls/o/abc.com.au/date/yyyy-mm-dd', 1) + localCounterProvider.inc('metrics/v1', 'pulls/o/abc.com.au/d/2024-05-30', 1) + localCounterProvider.inc('metrics/v1', 'pulls/o/abc.com.au/d/2024-05-31', 1) then: localCounterProvider.getAllMatchingEntries('metrics/v1', 'pulls/o/*') == - ['pulls/o/abc.com.au/date/yyyy-mm-dd':1, 'pulls/o/abc.in':3, 'pulls/o/bar.es':2, 'pulls/o/foo.it':1] + ['pulls/o/abc.in':3, 'pulls/o/bar.es':2, 'pulls/o/foo.it':1, 'pulls/o/abc.com.au/d/2024-05-30':1, 'pulls/o/abc.com.au/d/2024-05-31':1] + and: + localCounterProvider.getAllMatchingEntries('metrics/v1', 'pulls/o/*/d/2024-05-30') == + ['pulls/o/abc.com.au/d/2024-05-30':1] } } diff --git a/src/test/groovy/io/seqera/wave/service/counter/impl/RedisCounterProviderTest.groovy b/src/test/groovy/io/seqera/wave/service/counter/impl/RedisCounterProviderTest.groovy index 765adf789..d592171a9 100644 --- a/src/test/groovy/io/seqera/wave/service/counter/impl/RedisCounterProviderTest.groovy +++ b/src/test/groovy/io/seqera/wave/service/counter/impl/RedisCounterProviderTest.groovy @@ -67,17 +67,19 @@ class RedisCounterProviderTest extends Specification implements RedisTestContain def 'should get correct org count' () { when: redisCounterProvider.inc('metrics/v1', 'builds/o/foo.com', 1) - redisCounterProvider.inc('metrics/v1', 'builds/o/bar.org', 1) - redisCounterProvider.inc('metrics/v1', 'builds/o/abc.it', 2) - redisCounterProvider.inc('metrics/v1', 'pulls/o/foo.es', 1) - redisCounterProvider.inc('metrics/v1', 'pulls/o/bar.in', 2) - redisCounterProvider.inc('metrics/v1', 'pulls/o/abc.au', 3) - redisCounterProvider.inc('metrics/v1', 'pulls/o/abc.com/date/yyyy-mm-dd', 1) + redisCounterProvider.inc('metrics/v1', 'builds/o/bar.io', 1) + redisCounterProvider.inc('metrics/v1', 'builds/o/abc.org', 2) + redisCounterProvider.inc('metrics/v1', 'pulls/o/foo.it', 1) + redisCounterProvider.inc('metrics/v1', 'pulls/o/bar.es', 2) + redisCounterProvider.inc('metrics/v1', 'pulls/o/abc.in', 3) + redisCounterProvider.inc('metrics/v1', 'pulls/o/abc.com.au/d/2024-05-30', 1) + redisCounterProvider.inc('metrics/v1', 'pulls/o/abc.com.au/d/2024-05-31', 1) then: redisCounterProvider.getAllMatchingEntries('metrics/v1', 'pulls/o/*') == - ['pulls/o/foo.es':1, 'pulls/o/bar.in':2, 'pulls/o/abc.au':3, 'pulls/o/abc.com/date/yyyy-mm-dd': 1] + ['pulls/o/abc.in':3, 'pulls/o/bar.es':2, 'pulls/o/foo.it':1, 'pulls/o/abc.com.au/d/2024-05-30':1, 'pulls/o/abc.com.au/d/2024-05-31':1] and: - redisCounterProvider.getAllMatchingEntries('metrics/v1', 'fusion/o/*') == [:] + redisCounterProvider.getAllMatchingEntries('metrics/v1', 'pulls/o/*/d/2024-05-30') == + ['pulls/o/abc.com.au/d/2024-05-30':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 e5137d886..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 gcr.io/kaniko-project/executor:latest AS knk + FROM moby/buildkit:v0.14.1-rootless AS bkt RUN this and that FROM amazoncorretto:17.0.4 - COPY --from=knk /kaniko/executor /kaniko/executor - ''') == ['gcr.io/kaniko-project/executor:latest', 'amazoncorretto:17.0.4'] + COPY --from=bkt /usr/bin/buildctl /usr/bin/buildctl + ''') == ['moby/buildkit:v0.14.1-rootless', 'amazoncorretto:17.0.4'] } @@ -127,7 +127,7 @@ class ContainerInspectServiceImplTest extends Specification { and: def result = ContainerInspectServiceImpl.inspectItems(DOCKERFILE) then: - // capture both the repository name and the explicity entrypoint + // capture both the repository name and the explicit entrypoint // the entrypoint is returned first because it has higher priority result == [ new ContainerInspectServiceImpl.InspectEntrypoint(["this","--that"]), diff --git a/src/test/groovy/io/seqera/wave/service/k8s/K8sClientTest.groovy b/src/test/groovy/io/seqera/wave/service/k8s/K8sClientTest.groovy index bf6e8ac56..27ef124ac 100644 --- a/src/test/groovy/io/seqera/wave/service/k8s/K8sClientTest.groovy +++ b/src/test/groovy/io/seqera/wave/service/k8s/K8sClientTest.groovy @@ -56,7 +56,7 @@ class K8sClientTest extends Specification { def pod = k8sService.buildContainer( 'my-pod', 'busybox', - ['cat','/kaniko/.docker/config.json'], + ['cat','/home/user/.docker/config.json'], Path.of('/work/dir'), Path.of('/creds'), Path.of('/spack/dir'), 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 16184693c..39e2f585c 100644 --- a/src/test/groovy/io/seqera/wave/service/k8s/K8sServiceImplTest.groovy +++ b/src/test/groovy/io/seqera/wave/service/k8s/K8sServiceImplTest.groovy @@ -155,10 +155,10 @@ class K8sServiceImplTest extends Specification { def k8sService = ctx.getBean(K8sServiceImpl) when: - def mount = k8sService.mountHostPath(Path.of('/foo/work/x1/config.json'), '/foo','/kaniko/.docker/config.json') + def mount = k8sService.mountHostPath(Path.of('/foo/work/x1/config.json'), '/foo','/home/user/.docker/config.json') then: mount.name == 'build-data' - mount.mountPath == '/kaniko/.docker/config.json' + mount.mountPath == '/home/user/.docker/config.json' mount.readOnly mount.subPath == 'work/x1/config.json' @@ -190,42 +190,42 @@ class K8sServiceImplTest extends Specification { ctx.close() } - def 'should create build pod for kaniko' () { + def 'should create build pod for buildkit' () { given: def PROPS = [ - 'wave.build.workspace': '/build/work', - 'wave.build.timeout': '10s', - 'wave.build.k8s.namespace': 'my-ns', - 'wave.build.k8s.configPath': '/home/kube.config', + 'wave.build.workspace' : '/build/work', + 'wave.build.timeout' : '10s', + 'wave.build.k8s.namespace' : 'my-ns', + 'wave.build.k8s.configPath' : '/home/kube.config', 'wave.build.k8s.storage.claimName': 'build-claim', - 'wave.build.k8s.storage.mountPath': '/build' ] + 'wave.build.k8s.storage.mountPath': '/build'] and: def ctx = ApplicationContext.run(PROPS) def k8sService = ctx.getBean(K8sServiceImpl) when: - def result = k8sService.buildSpec('foo', 'my-image:latest', ['this','that'], Path.of('/build/work/xyz'), Path.of('/build/work/xyz/config.json'), null, [:]) + def result = k8sService.buildSpec('foo', 'my-image:latest', ['this', 'that'], Path.of('/build/work/xyz'), Path.of('/build/work/xyz/config.json'), null, [:]) then: result.metadata.name == 'foo' result.metadata.namespace == 'my-ns' and: result.spec.activeDeadlineSeconds == 10 and: - result.spec.containers.get(0).name == 'foo' - result.spec.containers.get(0).image == 'my-image:latest' - result.spec.containers.get(0).args == ['this','that'] - result.spec.containers.get(0).command == null - and: - result.spec.containers.get(0).volumeMounts.size() == 2 - and: - result.spec.containers.get(0).volumeMounts.get(0).name == 'build-data' - result.spec.containers.get(0).volumeMounts.get(0).mountPath == '/kaniko/.docker/config.json' - result.spec.containers.get(0).volumeMounts.get(0).subPath == 'work/xyz/config.json' - and: - result.spec.containers.get(0).volumeMounts.get(1).name == 'build-data' - result.spec.containers.get(0).volumeMounts.get(1).mountPath == '/build/work/xyz' - result.spec.containers.get(0).volumeMounts.get(1).subPath == 'work/xyz' - + verifyAll(result.spec.containers.get(0)) { + name == 'foo' + image == 'my-image:latest' + args == ['this', 'that'] + env.name == ['BUILDKITD_FLAGS'] + env.value == ['--oci-worker-no-process-sandbox'] + command == ['buildctl-daemonless.sh'] + volumeMounts.size() == 2 + volumeMounts.get(0).name == 'build-data' + volumeMounts.get(0).mountPath == '/home/user/.docker/config.json' + volumeMounts.get(0).subPath == 'work/xyz/config.json' + volumeMounts.get(1).name == 'build-data' + volumeMounts.get(1).mountPath == '/build/work/xyz' + volumeMounts.get(1).subPath == 'work/xyz' + } and: result.spec.volumes.get(0).name == 'build-data' result.spec.volumes.get(0).persistentVolumeClaim.claimName == 'build-claim' @@ -255,28 +255,25 @@ class K8sServiceImplTest extends Specification { and: result.spec.activeDeadlineSeconds == 10 and: - result.spec.containers.get(0).name == 'foo' - result.spec.containers.get(0).image == 'singularity:latest' - result.spec.containers.get(0).command == ['this','that'] - result.spec.containers.get(0).args == null - and: - result.spec.containers.get(0).volumeMounts.size() == 3 - and: - result.spec.containers.get(0).volumeMounts.get(0).name == 'build-data' - result.spec.containers.get(0).volumeMounts.get(0).mountPath == '/root/.singularity/docker-config.json' - result.spec.containers.get(0).volumeMounts.get(0).subPath == 'work/xyz/config.json' - and: - result.spec.containers.get(0).volumeMounts.get(1).name == 'build-data' - result.spec.containers.get(0).volumeMounts.get(1).mountPath == '/root/.singularity/remote.yaml' - result.spec.containers.get(0).volumeMounts.get(1).subPath == 'work/xyz/singularity-remote.yaml' - and: - result.spec.containers.get(0).volumeMounts.get(2).name == 'build-data' - result.spec.containers.get(0).volumeMounts.get(2).mountPath == '/build/work/xyz' - result.spec.containers.get(0).volumeMounts.get(2).subPath == 'work/xyz' - and: - result.spec.containers.get(0).getWorkingDir() == null - result.spec.containers.get(0).getSecurityContext().privileged + verifyAll(result.spec.containers.get(0)) { + name == 'foo' + image == 'singularity:latest' + command == ['this', 'that'] + args == null + volumeMounts.size() == 3 + volumeMounts.get(0).name == 'build-data' + volumeMounts.get(0).mountPath == '/root/.singularity/docker-config.json' + volumeMounts.get(0).subPath == 'work/xyz/config.json' + volumeMounts.get(1).name == 'build-data' + volumeMounts.get(1).mountPath == '/root/.singularity/remote.yaml' + volumeMounts.get(1).subPath == 'work/xyz/singularity-remote.yaml' + volumeMounts.get(2).name == 'build-data' + volumeMounts.get(2).mountPath == '/build/work/xyz' + volumeMounts.get(2).subPath == 'work/xyz' + getWorkingDir() == null + getSecurityContext().privileged + } and: result.spec.volumes.get(0).name == 'build-data' result.spec.volumes.get(0).persistentVolumeClaim.claimName == 'build-claim' @@ -309,21 +306,21 @@ class K8sServiceImplTest extends Specification { and: result.spec.activeDeadlineSeconds == 10 and: - result.spec.containers.get(0).name == 'foo' - result.spec.containers.get(0).image == 'my-image:latest' - result.spec.containers.get(0).args == ['this','that'] - and: - result.spec.containers.get(0).volumeMounts.size() == 2 - and: - result.spec.containers.get(0).volumeMounts.get(0).name == 'build-data' - result.spec.containers.get(0).volumeMounts.get(0).mountPath == '/build/work/xyz' - result.spec.containers.get(0).volumeMounts.get(0).subPath == 'work/xyz' - and: - result.spec.containers.get(0).volumeMounts.get(1).name == 'build-data' - result.spec.containers.get(0).volumeMounts.get(1).mountPath == '/opt/container/spack/key' - result.spec.containers.get(0).volumeMounts.get(1).subPath == 'host/spack/key' - result.spec.containers.get(0).volumeMounts.get(1).readOnly - + verifyAll(result.spec.containers.get(0)) { + name == 'foo' + image == 'my-image:latest' + args == ['this', 'that'] + env.name == ['BUILDKITD_FLAGS'] + env.value == ['--oci-worker-no-process-sandbox'] + volumeMounts.size() == 2 + volumeMounts.get(0).name == 'build-data' + volumeMounts.get(0).mountPath == '/build/work/xyz' + volumeMounts.get(0).subPath == 'work/xyz' + volumeMounts.get(1).name == 'build-data' + volumeMounts.get(1).mountPath == '/opt/container/spack/key' + volumeMounts.get(1).subPath == 'host/spack/key' + volumeMounts.get(1).readOnly + } and: result.spec.volumes.get(0).name == 'build-data' result.spec.volumes.get(0).persistentVolumeClaim.claimName == 'build-claim' @@ -355,16 +352,17 @@ class K8sServiceImplTest extends Specification { and: !result.spec.initContainers and: - result.spec.containers.get(0).name == 'foo' - result.spec.containers.get(0).image == 'my-image:latest' - result.spec.containers.get(0).args == ['this','that'] - and: - result.spec.containers.get(0).volumeMounts.size() == 1 - and: - result.spec.containers.get(0).volumeMounts.get(0).name == 'build-data' - result.spec.containers.get(0).volumeMounts.get(0).mountPath == '/build/work/xyz' - result.spec.containers.get(0).volumeMounts.get(0).subPath == 'work/xyz' - + verifyAll(result.spec.containers.get(0)) { + name == 'foo' + image == 'my-image:latest' + args == ['this', 'that'] + env.name == ['BUILDKITD_FLAGS'] + env.value == ['--oci-worker-no-process-sandbox'] + volumeMounts.size() == 1 + volumeMounts.get(0).name == 'build-data' + volumeMounts.get(0).mountPath == '/build/work/xyz' + volumeMounts.get(0).subPath == 'work/xyz' + } and: result.spec.volumes.get(0).name == 'build-data' result.spec.volumes.get(0).persistentVolumeClaim.claimName == 'build-claim' @@ -473,24 +471,21 @@ class K8sServiceImplTest extends Specification { and: result.spec.activeDeadlineSeconds == 10 and: - result.spec.containers.get(0).name == 'foo' - result.spec.containers.get(0).image == 'my-image:latest' - result.spec.containers.get(0).args == ['this','that'] - and: - result.spec.containers.get(0).volumeMounts.size() == 3 - and: - result.spec.containers.get(0).volumeMounts.get(0).name == 'build-data' - result.spec.containers.get(0).volumeMounts.get(0).mountPath == '/root/.docker/config.json' - result.spec.containers.get(0).volumeMounts.get(0).subPath == 'work/xyz/config.json' - and: - result.spec.containers.get(0).volumeMounts.get(1).name == 'build-data' - result.spec.containers.get(0).volumeMounts.get(1).mountPath == '/build/work/xyz' - result.spec.containers.get(0).volumeMounts.get(1).subPath == 'work/xyz' - and: - result.spec.containers.get(0).volumeMounts.get(2).name == 'build-data' - result.spec.containers.get(0).volumeMounts.get(2).mountPath == '/root/.cache/' - result.spec.containers.get(0).volumeMounts.get(2).subPath == 'work/.trivy' - + verifyAll(result.spec.containers.get(0)) { + name == 'foo' + image == 'my-image:latest' + args == ['this', 'that'] + volumeMounts.size() == 3 + volumeMounts.get(0).name == 'build-data' + volumeMounts.get(0).mountPath == '/root/.docker/config.json' + volumeMounts.get(0).subPath == 'work/xyz/config.json' + volumeMounts.get(1).name == 'build-data' + volumeMounts.get(1).mountPath == '/build/work/xyz' + volumeMounts.get(1).subPath == 'work/xyz' + volumeMounts.get(2).name == 'build-data' + volumeMounts.get(2).mountPath == '/root/.cache/' + volumeMounts.get(2).subPath == 'work/.trivy' + } and: result.spec.volumes.get(0).name == 'build-data' result.spec.volumes.get(0).persistentVolumeClaim.claimName == 'build-claim' @@ -560,14 +555,15 @@ class K8sServiceImplTest extends Specification { result.spec.activeDeadlineSeconds == 20 result.spec.serviceAccount == 'foo-sa' and: - result.spec.containers.get(0).name == 'foo' - result.spec.containers.get(0).image == 'my-image:latest' - result.spec.containers.get(0).args == ['this','that'] - result.spec.containers.get(0).getEnv().get(0) == new V1EnvVar().name('FOO').value('one') - result.spec.containers.get(0).getEnv().get(1) == new V1EnvVar().name('BAR').value('two') - and: - result.spec.containers.get(0).getResources().requests.get('cpu') == new Quantity('2') - result.spec.containers.get(0).getResources().requests.get('memory') == new Quantity('8Gi') + verifyAll(result.spec.containers.get(0)) { + name == 'foo' + image == 'my-image:latest' + args == ['this', 'that'] + getEnv().get(0) == new V1EnvVar().name('FOO').value('one') + getEnv().get(1) == new V1EnvVar().name('BAR').value('two') + getResources().requests.get('cpu') == new Quantity('2') + getResources().requests.get('memory') == new Quantity('8Gi') + } and: !result.spec.containers.get(0).getResources().limits diff --git a/src/test/groovy/io/seqera/wave/service/metric/MetricsCounterStoreLocalTest.groovy b/src/test/groovy/io/seqera/wave/service/metric/MetricsCounterStoreLocalTest.groovy index 0593178fb..89b233bd9 100644 --- a/src/test/groovy/io/seqera/wave/service/metric/MetricsCounterStoreLocalTest.groovy +++ b/src/test/groovy/io/seqera/wave/service/metric/MetricsCounterStoreLocalTest.groovy @@ -20,6 +20,9 @@ package io.seqera.wave.service.metric import spock.lang.Specification +import java.time.LocalDate +import java.time.format.DateTimeFormatter + import io.micronaut.test.extensions.spock.annotation.MicronautTest import jakarta.inject.Inject /** @@ -32,6 +35,8 @@ class MetricsCounterStoreLocalTest extends Specification { @Inject MetricsCounterStore metricsCounterStore + DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") + def 'should get correct count value' () { when: metricsCounterStore.inc('foo') @@ -48,9 +53,16 @@ class MetricsCounterStoreLocalTest extends Specification { metricsCounterStore.inc('builds/o/foo.com') metricsCounterStore.inc('builds/o/bar.org') metricsCounterStore.inc('pulls/o/bar.in') + metricsCounterStore.inc('pulls/o/foo.com/d/2024-05-29') + metricsCounterStore.inc('builds/o/bar.org/d/2024-05-30') + metricsCounterStore.inc('fusion/o/bar.in/d/2024-05-30') + metricsCounterStore.inc('pulls/o/bar.in/d/2024-05-31') then: - metricsCounterStore.getAllMatchingEntries('builds/o*') == ['builds/o/foo.com':1, 'builds/o/bar.org':1] - metricsCounterStore.getAllMatchingEntries('pulls/o*') == ['pulls/o/bar.in':1] + metricsCounterStore.getAllMatchingEntries('builds/o/*') == ['builds/o/foo.com':1, 'builds/o/bar.org':1, 'builds/o/bar.org/d/2024-05-30':1] + metricsCounterStore.getAllMatchingEntries('pulls/o/*') == ['pulls/o/bar.in':1, 'pulls/o/foo.com/d/2024-05-29':1, 'pulls/o/bar.in/d/2024-05-31':1] + metricsCounterStore.getAllMatchingEntries('fusion/o/*') == ['fusion/o/bar.in/d/2024-05-30':1] + metricsCounterStore.getAllMatchingEntries('builds/o/*/d/2024-05-30') == ['builds/o/bar.org/d/2024-05-30':1] + metricsCounterStore.getAllMatchingEntries('pulls/o/bar.in/d/2024-05-31') == ['pulls/o/bar.in/d/2024-05-31':1] } } diff --git a/src/test/groovy/io/seqera/wave/service/metric/MetricsCounterStoreRedisTest.groovy b/src/test/groovy/io/seqera/wave/service/metric/MetricsCounterStoreRedisTest.groovy index 5ec22841d..fb2c179d3 100644 --- a/src/test/groovy/io/seqera/wave/service/metric/MetricsCounterStoreRedisTest.groovy +++ b/src/test/groovy/io/seqera/wave/service/metric/MetricsCounterStoreRedisTest.groovy @@ -69,9 +69,16 @@ class MetricsCounterStoreRedisTest extends Specification implements RedisTestCo metricsCounterStore.inc('builds/o/foo.com') metricsCounterStore.inc('builds/o/bar.org') metricsCounterStore.inc('pulls/o/bar.in') + metricsCounterStore.inc('pulls/o/foo.com/d/2024-05-29') + metricsCounterStore.inc('builds/o/bar.org/d/2024-05-30') + metricsCounterStore.inc('fusion/o/bar.in/d/2024-05-30') + metricsCounterStore.inc('pulls/o/bar.in/d/2024-05-31') then: - metricsCounterStore.getAllMatchingEntries('builds/o*') == ['builds/o/foo.com':1, 'builds/o/bar.org':1] - metricsCounterStore.getAllMatchingEntries('pulls/o*') == ['pulls/o/bar.in':1] + metricsCounterStore.getAllMatchingEntries('builds/o/*') == ['builds/o/foo.com':1, 'builds/o/bar.org':1, 'builds/o/bar.org/d/2024-05-30':1] + metricsCounterStore.getAllMatchingEntries('pulls/o/*') == ['pulls/o/bar.in':1, 'pulls/o/foo.com/d/2024-05-29':1, 'pulls/o/bar.in/d/2024-05-31':1] + metricsCounterStore.getAllMatchingEntries('fusion/o/*') == ['fusion/o/bar.in/d/2024-05-30':1] + metricsCounterStore.getAllMatchingEntries('builds/o/*/d/2024-05-30') == ['builds/o/bar.org/d/2024-05-30':1] + metricsCounterStore.getAllMatchingEntries('pulls/o/bar.in/d/2024-05-31') == ['pulls/o/bar.in/d/2024-05-31':1] } } 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 8853fc8df..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,17 @@ import java.time.LocalDate import java.time.format.DateTimeFormatter import io.seqera.wave.service.counter.impl.LocalCounterProvider -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 static io.seqera.wave.service.metric.MetricsConstants.* /** * * @author Munish Chouhan */ -class MetricsServiceImplTest extends Specification { +class MetricsServiceImplTest extends Specification implements RedisTestContainer{ DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") @@ -54,9 +56,21 @@ class MetricsServiceImplTest extends Specification { metricsService.incrementBuildsCounter(null) then: - metricsService.getBuildsMetrics(date, null) == 3 - metricsService.getBuildsMetrics(null, 'org1.com') == 1 - metricsService.getBuildsMetrics(date, 'org2.com') == 1 + def res1 = metricsService.getOrgCount(PREFIX_BUILDS, date, null) + res1.count == 3 + res1.orgs == ['org1.com': 1, 'org2.com': 1] + and: + def res2 = metricsService.getOrgCount(PREFIX_BUILDS, null, 'org1.com') + res2.count == 1 + res2.orgs == ['org1.com': 1] + and: + def res3 = metricsService.getOrgCount(PREFIX_BUILDS, date, 'org2.com') + res3.count == 1 + res3.orgs == ['org2.com': 1] + and: + def res4 = metricsService.getOrgCount(PREFIX_BUILDS, date, null) + res4.count == 3 + res4.orgs == ['org1.com': 1, 'org2.com': 1] } def 'should increment pull count and return the correct count' () { @@ -76,9 +90,17 @@ class MetricsServiceImplTest extends Specification { metricsService.incrementPullsCounter(null) then: - metricsService.getPullsMetrics(null, 'org1.com') == 1 - metricsService.getPullsMetrics(date, 'org2.com') == 1 - metricsService.getPullsMetrics(date, null) == 3 + def res1 = metricsService.getOrgCount(PREFIX_PULLS, null, 'org1.com') + res1.count == 1 + res1.orgs == ['org1.com': 1] + and: + def res2 = metricsService.getOrgCount(PREFIX_PULLS,date, 'org2.com') + res2.count == 1 + res2.orgs == ['org2.com': 1] + and: + def res3 = metricsService.getOrgCount(PREFIX_PULLS,date, null) + res3.count == 3 + res3.orgs == ['org1.com': 1, 'org2.com': 1] } def 'should increment fusion pull count and return the correct count' () { @@ -98,9 +120,17 @@ class MetricsServiceImplTest extends Specification { metricsService.incrementFusionPullsCounter(null) then: - metricsService.getFusionPullsMetrics(null, 'org1.com') == 1 - metricsService.getFusionPullsMetrics(date, 'org2.com') == 1 - metricsService.getFusionPullsMetrics(date, null) == 3 + def res1 = metricsService.getOrgCount(PREFIX_FUSION,null, 'org1.com') + res1.count == 1 + res1.orgs == ['org1.com': 1] + and: + def res2 = metricsService.getOrgCount(PREFIX_FUSION, date, 'org2.com') + res2.count == 1 + res2.orgs == ['org2.com': 1] + and: + def res3 = metricsService.getOrgCount(PREFIX_FUSION, date, null) + res3.count == 3 + res3.orgs == ['org1.com': 1, 'org2.com': 1] } @Unroll @@ -110,18 +140,18 @@ class MetricsServiceImplTest extends Specification { 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 @@ -159,21 +189,21 @@ class MetricsServiceImplTest extends Specification { metricsService.incrementFusionPullsCounter(platformId2) metricsService.incrementFusionPullsCounter(null) and: - def buildOrgCounts = metricsService.getOrgCount(MetricConstants.PREFIX_BUILDS) - def pullOrgCounts = metricsService.getOrgCount(MetricConstants.PREFIX_PULLS) - def fusionOrgCounts = metricsService.getOrgCount(MetricConstants.PREFIX_FUSION) - def emptyOrgCounts = metricsService.getOrgCount(null) + 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: @@ -181,4 +211,61 @@ class MetricsServiceImplTest extends Specification { emptyOrgCounts.count == 0 emptyOrgCounts.orgs == [:] } + + def 'should get correct org count per date'(){ + given: + def date = LocalDate.now().format(dateFormatter) + def localCounterProvider = new LocalCounterProvider() + def metricsCounterStore = new MetricsCounterStore(localCounterProvider) + def metricsService = new MetricsServiceImpl(metricsCounterStore: metricsCounterStore) + def user1 = new User(id: 1, userName: 'foo', email: 'user1@org1.com') + def user2 = new User(id: 2, userName: 'bar', email: 'user2@org2.com') + def platformId1 = new PlatformId(user1, 101) + def platformId2 = new PlatformId(user2, 102) + + when: + metricsService.incrementBuildsCounter(platformId1) + metricsService.incrementBuildsCounter(platformId2) + metricsService.incrementBuildsCounter(null) + metricsService.incrementPullsCounter(platformId1) + metricsService.incrementPullsCounter(platformId2) + metricsService.incrementPullsCounter(null) + metricsService.incrementFusionPullsCounter(platformId1) + metricsService.incrementFusionPullsCounter(platformId2) + metricsService.incrementFusionPullsCounter(null) + and: + 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 == PREFIX_BUILDS + buildOrgCounts.count == 3 + buildOrgCounts.orgs == ['org1.com': 1, 'org2.com': 1] + and: + pullOrgCounts.metric == PREFIX_PULLS + pullOrgCounts.count == 3 + pullOrgCounts.orgs == ['org1.com': 1, 'org2.com': 1] + and: + fusionOrgCounts.metric == PREFIX_FUSION + fusionOrgCounts.count == 3 + fusionOrgCounts.orgs == ['org1.com': 1, 'org2.com': 1] + and: + emptyOrgCounts.metric == null + emptyOrgCounts.count == 0 + emptyOrgCounts.orgs == [:] + } + + @Unroll + def 'extract correct org name from key'(){ + expect: + MetricsServiceImpl.extractOrgFromKey(KEY) == ORG + where: + KEY | ORG + 'builds/o/org1.com/d/2024-05-30' | 'org1.com' + 'pulls/o/org2.com/d/2024-05-29' | 'org2.com' + 'fusion/o/org3.com/d/2024-04-30' | 'org3.com' + 'fusion/d/2024-04-30' | 'unknown' + } } diff --git a/src/test/groovy/io/seqera/wave/service/persistence/impl/SurrealPersistenceServiceTest.groovy b/src/test/groovy/io/seqera/wave/service/persistence/impl/SurrealPersistenceServiceTest.groovy index 8dcca7614..135ef8de0 100644 --- a/src/test/groovy/io/seqera/wave/service/persistence/impl/SurrealPersistenceServiceTest.groovy +++ b/src/test/groovy/io/seqera/wave/service/persistence/impl/SurrealPersistenceServiceTest.groovy @@ -125,84 +125,14 @@ class SurrealPersistenceServiceTest extends Specification implements SurrealDBTe when: storage.initializeDb() and: - storage.createBuild(build) + storage.saveBuild(build) then: + sleep 100 def stored = storage.loadBuild(request.buildId) stored.buildId == request.buildId stored.requestIp == '127.0.0.1' } - void "an event insert a build"() { - given: - def storage = applicationContext.getBean(SurrealPersistenceService) - storage.initializeDb() - and: - def service = applicationContext.getBean(SurrealPersistenceService) - final request = new BuildRequest( - 'container1234', - 'test', - 'test', - 'test', - Path.of("."), - 'docker.io/my/repo:container1234', - PlatformId.NULL, - ContainerPlatform.of('amd64'), - 'docker.io/my/cache', - '127.0.0.1', - '{"config":"json"}', - null, - null, - 'scan12345', - null, - BuildFormat.DOCKER - ).withBuildId('123') - storage.createBuild( WaveBuildRecord.fromEvent(new BuildEvent(request))) - - and: - def result = new BuildResult(request.buildId, 0, "content", Instant.now(), Duration.ofSeconds(1), 'abc123') - def event = new BuildEvent(request, result) - - when: - service.onBuildEvent(event) - sleep 100 //as we are using async, let database a while to store the item - then: - def stored = storage.loadBuild(request.buildId) - stored.buildId == request.buildId - stored.digest == 'abc123' - } - - void "an event is not inserted if no database"() { - given: - surrealContainer.stop() - def service = applicationContext.getBean(SurrealPersistenceService) - final request = new BuildRequest( - 'container1234', - 'test', - 'test', - 'test', - Path.of("."), - 'docker.io/my/repo:container1234', - PlatformId.NULL, - ContainerPlatform.of('amd64'), - 'docker.io/my/cache', - '127.0.0.1', - '{"config":"json"}', - null, - null, - 'scan12345', - null, - BuildFormat.DOCKER - ).withBuildId('123') - def result = new BuildResult(request.buildId, 0, "content", Instant.now(), Duration.ofSeconds(1), null) - def event = new BuildEvent(request, result) - - when: - service.onBuildEvent(event) - sleep 100 //as we are using async, let database a while to store the item - then: - true - } - def 'should load a build record' () { given: def persistence = applicationContext.getBean(SurrealPersistenceService) @@ -229,10 +159,12 @@ class SurrealPersistenceServiceTest extends Specification implements SurrealDBTe def record = WaveBuildRecord.fromEvent(event) and: - persistence.createBuild(record) + persistence.saveBuild(record) when: + sleep 100 def loaded = persistence.loadBuild(record.buildId) + then: loaded == record } @@ -259,29 +191,17 @@ class SurrealPersistenceServiceTest extends Specification implements SurrealDBTe BuildFormat.DOCKER ).withBuildId('123') and: - def build1 = WaveBuildRecord.fromEvent(new BuildEvent(request, null)) + def result = BuildResult.completed(request.buildId, 1, 'Hello', Instant.now().minusSeconds(60), 'xyz') + + and: + def build1 = WaveBuildRecord.fromEvent(new BuildEvent(request, result)) when: - persistence.createBuild(build1) + persistence.saveBuild(build1) + sleep 100 then: persistence.loadBuild(request.buildId) == build1 - when: - def result = BuildResult.completed(request.buildId, 1, 'Hello', Instant.now().minusSeconds(60), 'xyz') - and: - final build2 = WaveBuildRecord.fromEvent(new BuildEvent(request, result)) - persistence.updateBuild(build2) - // short sleep because the update is async - sleep 200 - then: - def result2 = persistence.loadBuild(request.buildId) - and: - result2.buildId == build2.buildId - result2.dockerFile == build2.dockerFile - and: - result2.startTime == build2.startTime - result2.duration == build2.duration - result2.digest == build2.digest } def 'should load a request record' () { diff --git a/src/test/groovy/io/seqera/wave/service/scan/ContainerScanStrategyTest.groovy b/src/test/groovy/io/seqera/wave/service/scan/ContainerScanStrategyTest.groovy index bfaa8b369..d6ebfcfdb 100644 --- a/src/test/groovy/io/seqera/wave/service/scan/ContainerScanStrategyTest.groovy +++ b/src/test/groovy/io/seqera/wave/service/scan/ContainerScanStrategyTest.groovy @@ -33,7 +33,7 @@ class ContainerScanStrategyTest extends Specification { def "should return trivy command"() { given: - def targetImage = "respository/scantool" + def targetImage = "repository/scantool" def containerScanStrategy = Spy(ScanStrategy) def outFile = Path.of('/some/out.json') def config = Mock(ScanConfig) { getTimeout() >> Duration.ofMinutes(100) } @@ -54,7 +54,7 @@ class ContainerScanStrategyTest extends Specification { def "should return trivy command with severity"() { given: - def targetImage = "respository/scantool" + def targetImage = "repository/scantool" def containerScanStrategy = Spy(ScanStrategy) def outFile = Path.of('/some/out.json') def config = Mock(ScanConfig) { 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/auth/JwtAuthTest.groovy b/src/test/groovy/io/seqera/wave/tower/auth/JwtAuthTest.groovy index 11987affd..b3baa55c7 100644 --- a/src/test/groovy/io/seqera/wave/tower/auth/JwtAuthTest.groovy +++ b/src/test/groovy/io/seqera/wave/tower/auth/JwtAuthTest.groovy @@ -45,6 +45,18 @@ class JwtAuthTest extends Specification { now, now ) + when: + def auth0 = auth.withEndpoint('http://bar.com') + then: + auth0 != auth + and: + auth0.key == auth.key + auth0.endpoint == 'http://bar.com' + auth0.bearer == auth.bearer + auth0.refresh == auth.refresh + auth0.createdAt == auth.createdAt + auth0.updatedAt == auth.updatedAt + when: def auth1 = auth.withKey('key-098765') then: 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/src/test/groovy/io/seqera/wave/tower/client/TowerClientUrlCompositionTest.groovy b/src/test/groovy/io/seqera/wave/tower/client/TowerClientUrlCompositionTest.groovy index f4b2f04fd..fe6ce0ae6 100644 --- a/src/test/groovy/io/seqera/wave/tower/client/TowerClientUrlCompositionTest.groovy +++ b/src/test/groovy/io/seqera/wave/tower/client/TowerClientUrlCompositionTest.groovy @@ -49,7 +49,7 @@ class TowerClientUrlCompositionTest extends Specification{ endpoint || _ null || _ "" || _ - "gar bage" || _ + "foo bar" || _ "tower.io" || _ "ftp://xyz" || _ @@ -75,20 +75,19 @@ class TowerClientUrlCompositionTest extends Specification{ def 'fail compose list credentials with invalid towerEndpoint'() { when: - TowerClient.listCredentialsEndpoint(endpoint, workspaceId).toString() + TowerClient.listCredentialsEndpoint(ENDPOINT, WORKSPACE_ID).toString() then: thrown(IllegalArgumentException) where: - - endpoint | workspaceId + ENDPOINT | WORKSPACE_ID null | null null | 1 "" | null "" | 1 - "gar bage" | null - "gar bage" | 1 + "foo bar" | null + "foo bar" | 1 "tower.io" | null "tower.io" | 1 "ftp://xyz" | null @@ -127,8 +126,8 @@ class TowerClientUrlCompositionTest extends Specification{ null | "10" | "10" | null "" | "10" | "10" | null "" | "10" | "10" | null - "gar bage" | "10" | "10" | 1 - "gar bage" | "10" | "10" | null + "foo bar" | "10" | "10" | 1 + "foo bar" | "10" | "10" | null "tower.io" | "10" | "10" | 1 "tower.io" | "10" | "10" | null "ftp://xyz" | "10" | "10" | 1 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"