add nextflow d30e48d
This commit is contained in:
115
nextflow/plugins/nf-wave/README.md
Normal file
115
nextflow/plugins/nf-wave/README.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# Wave containers plugin for Nextflow
|
||||
|
||||
## Summary
|
||||
|
||||
The Wave containers plugin provides integration with the Wave container service for dynamic container building, augmentation, and on-demand software provisioning.
|
||||
|
||||
## Get started
|
||||
|
||||
To use this plugin, add it to your `nextflow.config`:
|
||||
|
||||
```groovy
|
||||
plugins {
|
||||
id 'nf-wave'
|
||||
}
|
||||
```
|
||||
|
||||
Enable Wave in your configuration:
|
||||
|
||||
```groovy
|
||||
wave {
|
||||
enabled = true
|
||||
}
|
||||
```
|
||||
|
||||
Wave automatically builds containers from Conda packages or Dockerfiles defined in your processes.
|
||||
|
||||
## Examples
|
||||
|
||||
### Basic Wave configuration
|
||||
|
||||
```groovy
|
||||
plugins {
|
||||
id 'nf-wave'
|
||||
}
|
||||
|
||||
wave {
|
||||
enabled = true
|
||||
}
|
||||
|
||||
process {
|
||||
conda = 'samtools=1.17'
|
||||
}
|
||||
```
|
||||
|
||||
### Using Wave with Conda packages
|
||||
|
||||
```groovy
|
||||
wave {
|
||||
enabled = true
|
||||
}
|
||||
|
||||
process ALIGN {
|
||||
conda 'bioconda::bwa=0.7.17 bioconda::samtools=1.17'
|
||||
|
||||
script:
|
||||
'''
|
||||
bwa mem ref.fa reads.fq | samtools view -bS - > aligned.bam
|
||||
'''
|
||||
}
|
||||
```
|
||||
|
||||
### Wave with Fusion file system
|
||||
|
||||
```groovy
|
||||
wave {
|
||||
enabled = true
|
||||
}
|
||||
|
||||
fusion {
|
||||
enabled = true
|
||||
}
|
||||
|
||||
process.executor = 'awsbatch'
|
||||
workDir = 's3://my-bucket/work'
|
||||
```
|
||||
|
||||
### Container augmentation
|
||||
|
||||
```groovy
|
||||
wave {
|
||||
enabled = true
|
||||
strategy = 'conda,container'
|
||||
}
|
||||
|
||||
process TOOL {
|
||||
container 'ubuntu:22.04'
|
||||
conda 'bioconda::samtools=1.17'
|
||||
|
||||
script:
|
||||
'''
|
||||
samtools --version
|
||||
'''
|
||||
}
|
||||
```
|
||||
|
||||
### Using private registries
|
||||
|
||||
```groovy
|
||||
wave {
|
||||
enabled = true
|
||||
}
|
||||
|
||||
docker {
|
||||
registry = 'quay.io'
|
||||
}
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- [Wave Documentation](https://docs.seqera.io/wave)
|
||||
- [Wave Integration Guide](https://nextflow.io/docs/latest/wave.html)
|
||||
|
||||
## License
|
||||
|
||||
[Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0)
|
||||
1
nextflow/plugins/nf-wave/VERSION
Normal file
1
nextflow/plugins/nf-wave/VERSION
Normal file
@@ -0,0 +1 @@
|
||||
1.20.0
|
||||
71
nextflow/plugins/nf-wave/build.gradle
Normal file
71
nextflow/plugins/nf-wave/build.gradle
Normal file
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright 2013-2026, Seqera Labs
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
plugins {
|
||||
id 'io.nextflow.nextflow-plugin' version "${nextflowPluginVersion}"
|
||||
id 'java-test-fixtures'
|
||||
}
|
||||
|
||||
nextflowPlugin {
|
||||
nextflowVersion = '26.03.4-edge'
|
||||
|
||||
provider = "${nextflowPluginProvider}"
|
||||
description = 'Provides dynamic container building and augmentation capabilities with on-demand software installation and advanced container management features'
|
||||
className = 'io.seqera.wave.plugin.WavePlugin'
|
||||
useDefaultDependencies = false
|
||||
generateSpec = false
|
||||
extensionPoints = [
|
||||
'io.seqera.wave.plugin.WaveFactory',
|
||||
'io.seqera.wave.plugin.config.WaveConfig',
|
||||
'io.seqera.wave.plugin.resolver.WaveContainerResolver',
|
||||
]
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
main.java.srcDirs = []
|
||||
main.groovy.srcDirs = ['src/main']
|
||||
main.resources.srcDirs = ['src/resources']
|
||||
test.groovy.srcDirs = ['src/test']
|
||||
test.java.srcDirs = []
|
||||
test.resources.srcDirs = []
|
||||
}
|
||||
|
||||
configurations {
|
||||
// see https://docs.gradle.org/4.1/userguide/dependency_management.html#sub:exclude_transitive_dependencies
|
||||
runtimeClasspath.exclude group: 'org.slf4j', module: 'slf4j-api'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compileOnly project(':nextflow')
|
||||
compileOnly 'org.slf4j:slf4j-api:2.0.17'
|
||||
compileOnly 'org.pf4j:pf4j:3.14.1'
|
||||
compileOnly 'io.seqera:lib-trace:0.1.0'
|
||||
compileOnly 'io.seqera:lib-httpx:2.1.0'
|
||||
compileOnly 'org.apache.commons:commons-lang3:3.18.0'
|
||||
|
||||
// Force lib-httpx version to avoid conflicts with older versions from nf-commons
|
||||
testImplementation 'io.seqera:lib-httpx:2.1.0'
|
||||
|
||||
api 'org.apache.commons:commons-compress:1.26.1'
|
||||
api 'com.google.code.gson:gson:2.13.1'
|
||||
api 'org.yaml:snakeyaml:2.2'
|
||||
api 'io.seqera:wave-api:1.31.1'
|
||||
api 'io.seqera:wave-utils:1.31.1'
|
||||
|
||||
testImplementation(testFixtures(project(":nextflow")))
|
||||
testImplementation "org.apache.groovy:groovy:4.0.31"
|
||||
testImplementation "org.apache.groovy:groovy-nio:4.0.31"
|
||||
}
|
||||
262
nextflow/plugins/nf-wave/changelog.txt
Normal file
262
nextflow/plugins/nf-wave/changelog.txt
Normal file
@@ -0,0 +1,262 @@
|
||||
nf-wave changelog
|
||||
==================
|
||||
1.20.0 - 25 Apr 2026
|
||||
- Add Apple container engine support (#7073) [2f7a3c455]
|
||||
|
||||
1.19.1 - 20 Apr 2026
|
||||
- Fix resolution of `-with-tower` with `TOWER_API_ENDPOINT` (#7045) [ce962e882]
|
||||
|
||||
1.19.0 - 17 Mar 2026
|
||||
- Add multi-arch support to arch process directive (#6897) [c7ca36902]
|
||||
- Default Fusion to v2.6 for Seqera executor (#6933) [8a7e53957]
|
||||
|
||||
1.18.0 - 19 Dec 2025
|
||||
- Fix WaveClient sending Bearer token to public S3 URLs (#6672) [ffaef0b6a]
|
||||
- Add wave.build.template config option (#6639) [d08a8952a]
|
||||
|
||||
1.17.0 - 28 Nov 2025
|
||||
- Bump Fusion to version 2.5 (#6557) [ec228f64f]
|
||||
|
||||
1.16.1 - 21 Oct 2025
|
||||
- Rename `config.schema` package to `config.spec` (#6485) [ef0d2d601]
|
||||
|
||||
1.16.0 - 8 Oct 2025
|
||||
- Fix cookie policy for Seqera Platform JWT token refresh (#6411) [c3959cfc3]
|
||||
|
||||
1.14.1 - 15 Aug 2025
|
||||
- Unify nf-lang config scopes with runtime classes (#6271) [bfa67ca3]
|
||||
- Update trace observers to v2 (#6257) [544b8c47]
|
||||
- Bump groovy 4.0.28 (#6304) [ci fast] [a468f8ef]
|
||||
- Bump org.apache.commons:commons-lang3 from 3.12.0 to 3.18.0 (#6272) [b8ccddb5]
|
||||
|
||||
1.14.0 - 6 Jul 2025
|
||||
- Fix http response err message [1f05451f]
|
||||
- Unwrap Failsafe exception cause in Wave client [0cb39df5]
|
||||
- Update wave retryPolicy default values (#6222) [b8069a58]
|
||||
- Bump Slf4j version 2.0.17 [93199e09]
|
||||
- Bump gson version 2.13.1 [ab8e36a2]
|
||||
|
||||
1.13.0 - 2 Jun 2025
|
||||
- Deprecated condaFile attribute [9e52b2ad]
|
||||
- Add support for container build compression (#5964) [daeefa0c]
|
||||
- Minor import change [e0f21f87]
|
||||
- Bump Groovy to version 4.0.27 (#6125) [258e1790]
|
||||
|
||||
1.12.1 - 12 May 2025
|
||||
- Add support for ARM and Snapshots (#6057) [ef4c3865]
|
||||
|
||||
1.12.0 - 23 Apr 2025
|
||||
- Add support for Fusion Snapshots (#5954) [d7f047f8]
|
||||
- Revert Bump Fusion to version 2.5 [49b58d2d]
|
||||
|
||||
1.11.1 - 5 Apr 2025
|
||||
- Fix date format in Wave containers meta (#5903) [59536c0a]
|
||||
|
||||
1.11.0 - 17 Mar 2025
|
||||
- Add Wave container metadata to Platform traces (#5724) [b81178c5]
|
||||
- Align container platform with `arch` directive when using Wave (#5847) [b9d23e22]
|
||||
- Remove Spack from Wave (2nd) [076c8237]
|
||||
- Remove Spack from Wave assets [9c554de9]
|
||||
- Bump groovy 4.0.26 [f740bc56]
|
||||
|
||||
1.10.0 - 12 Jab 2025
|
||||
- Add Fusion token validation (#5614) [1dcb18d6]
|
||||
- Add traceparent header to wave & tower clients (#5725) [7eea9f2d]
|
||||
- Bump Fusion to version 2.5 (2nd attempt) [a7f09ae4]
|
||||
- Bump groovy 4.0.25 [19c40a4a]
|
||||
|
||||
1.9.0 - 20 Jan 2025
|
||||
- Add rate limiter to wave requests (#5608) [ecf68294]
|
||||
- Improve inspect mode (#5605) [8e2056e7]
|
||||
- Bump logback 1.5.13 + slf4j 2.0.16 [cc0163ac]
|
||||
- Bump groovy 4.0.24 missing deps [40670f7e]
|
||||
|
||||
1.8.0 - 3 Dec 2024
|
||||
- Fix missing wave response (#5547) [ee252173]
|
||||
- Update wave deps [09ccd295]
|
||||
- Fix isContainerReady when wave is disabled (#5509) [3215afa8]
|
||||
- Bump groovy 4.0.24 [dd71ad31]
|
||||
|
||||
1.7.2 - 27 Oct 2024
|
||||
- Add wave mirror vs module bundles conflicts warning [b37a8a5b]
|
||||
|
||||
1.7.1 - 14 Oct 2024
|
||||
- Change to scan.levels to scan.allowedLevels (#5401) [88a1b1b5]
|
||||
- Fix inspect concretize option [0ee29a87]
|
||||
|
||||
1.7.0 - 13 Oct 2024
|
||||
- Add support for Wave container status API (#5384) [873703ad] [9ed18a88]
|
||||
- Remove unused reportsOpts (#5379) [e794e868]
|
||||
- Sunsetting Spack support in Wave [3a54cb3b]
|
||||
|
||||
1.6.0 - 2 Oct 2024
|
||||
- Improve Wave build timeout handling (#5304) [05bef7e4]
|
||||
- Bump groovy 4.0.23 (#5303) [fe3e3ac7]
|
||||
|
||||
1.5.1 - 4 Sep 2024
|
||||
- Wave client logs improvement [5a37e617]
|
||||
|
||||
1.5.0 - 5 Aug 2024
|
||||
- Await build completion for all Wave containers [2b8117e9]
|
||||
- Bump pf4j to version 3.12.0 [96117b9a]
|
||||
- Bump wave-api to 0.11.1 [96ec4ded]
|
||||
|
||||
1.4.4 - 8 Jul 2024
|
||||
- Bump groovy 4.0.22 [284a6606]
|
||||
- Bump Fusion 2.4 (#5080) [0a8a484e]
|
||||
|
||||
1.4.3 - 17 Jun 2024
|
||||
- Fix support for s5cmd 2.2.2 (#5069) [7e78bd4d]
|
||||
|
||||
1.4.2-patch1 - 1 Aug 2024
|
||||
- Bump wave-api to 0.11.1 [87828afd]
|
||||
- Bump pf4j to version 3.12.0 [1a8f086a]
|
||||
|
||||
1.4.2 - 20 May 2024
|
||||
- Bump Fusion 2.3 (#5005) [7176c113]
|
||||
- Remove `seqera` and `defaults` from Conda default channels (#5003) [ec5ebd0b]
|
||||
- Fix unexpected container resolution [a5ecf8a4]
|
||||
|
||||
1.4.1 - 13 May 2024
|
||||
- Remove unused const [6e91285d]
|
||||
- Fix Wave container resolution with singularity and ociMode [54ad6241]
|
||||
|
||||
1.4.0 - 15 Apr 2024
|
||||
- Update Wave to API v1alpha2 (#4906) [9c350872]
|
||||
- Update Platform API endpoint (#4855) [4842423a]
|
||||
- Bump groovy 4.0.21 [9e08390b]
|
||||
|
||||
1.3.1 - 10 Mar 2024
|
||||
- Bump snakeyaml 2.2 [07480779]
|
||||
- Bump groovy 4.0.19 [854dc1f0]
|
||||
|
||||
1.3.0 - 5 Feb 2024
|
||||
- Fix handling of wave.s5cmdConfigUrl setting (#4707) [3a19386d]
|
||||
- Fix Wave container replicable checksum [da382ddf]
|
||||
- Bump Groovy 4 (#4443) [9d32503b]
|
||||
|
||||
1.2.0 - 20 Dec 2023
|
||||
- Add support for Singularity/Apptainer auto pull mode for OCI containers [b7f1a192]
|
||||
- Add experimental support for Fargate compute type for AWS Batch (#3474) [47cf335b]
|
||||
- Remove deprecated Wave observer [0e009ef7]
|
||||
- Bump wave-utils@0.8.1 and micromamba@1.5.5 [9cb50035]
|
||||
|
||||
1.1.0 - 24 Nov 2023
|
||||
- Add Retry policy to Google Storage (#4524) [c271bb18]
|
||||
- Add support for Singularity OCI mode (#4440) [f5362a7b]
|
||||
- Fix detection of Conda local path made by Wave client (#4532) [4d5bc216]
|
||||
- Fix security vulnerabilities (#4513) [a310c777]
|
||||
- Fix container hashing for Singularity + Wave containers [4c6f2e85]
|
||||
- Use consistently NXF_TASK_WORKDIR (#4484) [48ee3c64]
|
||||
- Fix Inspect command fails with Singularity [f5bb829f]
|
||||
|
||||
1.0.1-patch1 - 28 May 2024
|
||||
- Bump dependency with Nextflow 23.10.2
|
||||
|
||||
1.0.1 - 12 Jan 2024
|
||||
- Fix Inspect command fails with Singularity [25883df3]
|
||||
|
||||
1.0.0 - 15 Oct 2023
|
||||
- Fix conda channels order [6672c6d7]
|
||||
- Bump nf-wave@1.0.0 [795849d7]
|
||||
|
||||
0.14.0 - 10 Oct 2023
|
||||
- Add bioconda and seqera Conda default channels (#4359) [ff012dcd]
|
||||
- Improve Wave error handling [d47e8b07]
|
||||
- Improve Wave config validation [7d5a21b0]
|
||||
|
||||
0.13.0 - 28 Sep 2023
|
||||
- Wave does not support 'null' container engine [f3eba3d7]
|
||||
- Default Conda basePackages to "conda-forge::procps-ng" [367af52f]
|
||||
- Add procps by default to Conda-based Wave builds [66b2d2d2]
|
||||
- Improve wave container name validation [73eb5a02]
|
||||
- Bump wave-utils@0.7.8 [d0c47d49]
|
||||
|
||||
0.12.0 - 10 Sep 2023
|
||||
- Add support for Spack to Singularity builds [23c4ec1d]
|
||||
- Add inspect command (#4069) [090c31ce]
|
||||
- Add support for Wave native build for singularity [8a434893]
|
||||
- Fix Wave build when Conda package name is quoted [d19cb0b7]
|
||||
- Fix Wave build for Singularity files [a60ef72b]
|
||||
- Improve Wave handing of Conda envs [736ab9bb]
|
||||
- Deprecated Wave report feature [80c5cb27]
|
||||
- Bump groovy 3.0.19 [cb411208]
|
||||
|
||||
0.11.2 - 17 Aug 2023
|
||||
- Use root user in Wave container based on micromamba (#4038) [a3a75ea2]
|
||||
- Add 429 http status code to Wave retriable errors [8eb5f305]
|
||||
|
||||
0.11.1 - 5 Aug 2023
|
||||
- Improve Wave config logging [547fad62]
|
||||
- Increase Wave client max attempts [fe5dd497]
|
||||
- Enable use virtual threads in Wave client [dd32f80a]
|
||||
- Fix Wave disable flag [8579e7a4]
|
||||
|
||||
0.11.0 - 22 Jul 2023
|
||||
- Add support legacy wave retry [73a1e7d4]
|
||||
- Add support for Wave container freeze [9a5903e6]
|
||||
- Add retry logic to wave image await [9fc1d3bd]
|
||||
- Add missing header to wave container await [d39866e6]
|
||||
- Allow disabling the Wave requirement when Fusion is enabled [9180d633]
|
||||
- Improve handling Wave server errors [84f7a61a]
|
||||
- Bump micromamba 1.4.9 [6307f9b5]
|
||||
- Remove default arch from wave request [f0e5c0c1]
|
||||
- Bump Groovy 3.0.18 [207eb535]
|
||||
|
||||
0.10.0 - 14 Jun 2023
|
||||
- Add retry policy to Wave http client [1daebeef]
|
||||
- Add support for arch auto-detection to Wave [7b5fdaf0]
|
||||
- Add Wave containers reports (preview) [9d9e2758]
|
||||
- Add wave.httpClient.connectTimeout config option [dd999a3c]
|
||||
- Consolidate Wave retryPolicy options [7d7464fe]
|
||||
- Enhanced support for Spack + Wave (#3998) [63ac03b3]
|
||||
- Refactor Conda and Spack support for Wave to Java [36b9e226]
|
||||
- Minor change in Wave config [4da0442a]
|
||||
- Fix log typo [f6e4b9ba]
|
||||
|
||||
0.9.0 - 15 Apr 2023
|
||||
- Add support for the Spack recipes to Wave build (#3636) [b03cbe70]
|
||||
- Update logging libraries [d7eae86e]
|
||||
- Bump micromamba:1.4.2 [334df1e0]
|
||||
- Bump fusion 2.2 [f1ebe29a]
|
||||
- Bump groovy 3.0.17 [cfe4ba56]
|
||||
|
||||
0.8.2 - 15 Apr 2023
|
||||
- Security fixes [83e8fd6a]
|
||||
|
||||
0.8.1 - 1 Apr 2023
|
||||
- Bump micromamba 1.4.1 [ec1439e6]
|
||||
- Fix NoSuchMethodError String.stripIndent with Java 11 [308eafe6]
|
||||
|
||||
0.8.0 - 19 Mar 2023
|
||||
- Add workflowId to wave request [025ff9d0]
|
||||
- Add basePackages option to Wave config [7e827810]
|
||||
- Bump groovy 3.0.16 [d3ff5dcb]
|
||||
|
||||
0.7.0 - 14 Jan 2022
|
||||
- Improve container native executor configuration [03126371]
|
||||
- Prevent redirection on Wave client [124cfb3e]
|
||||
- Refactor Fusion package [52f4c5d5]
|
||||
- Update groovy deps [6f3ed6e8]
|
||||
|
||||
0.6.3 - 13 Dic 2022
|
||||
- Fix Wave layer invalid checksum due to not closed stream [e188bbf9]
|
||||
- Fix Fusion test [2245a1c7]
|
||||
- Add support for Fusion ARM64 client [d073c538]
|
||||
- Improve Wave config error reporting [ae502668]
|
||||
- Bump fusion version URLs 0.6 [a160a8b1]
|
||||
- Bump nf-wave@0.6.3 [0cb6ca6a]
|
||||
|
||||
0.6.2 - 8 Dec 2022
|
||||
- Add support for Fusion ARM64 client [d073c538]
|
||||
- Improve Wave config error reporting [ae502668]
|
||||
- Bump fusion version URLs 0.6 [b119a47e]
|
||||
|
||||
0.6.1 - 29 Nov 2022
|
||||
- Add support for custom conda channels (#3435) [0884e80e]
|
||||
|
||||
0.6.0 - 23 Nov 2022
|
||||
- Add support for Wave containerPlatform [10d56ca1]
|
||||
- Add tower endpoint to wave [b725ddc4]
|
||||
- Bump micromamba@1.0.0 [c7fd5d26]
|
||||
- Bump micromamba:0.27.0 [904c9409]
|
||||
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* Copyright 2013-2026, Seqera Labs
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.seqera.wave.plugin
|
||||
|
||||
import groovy.transform.Canonical
|
||||
import groovy.transform.CompileStatic
|
||||
import groovy.transform.ToString
|
||||
import nextflow.util.CacheHelper
|
||||
|
||||
/**
|
||||
* Model a container configuration
|
||||
*
|
||||
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
|
||||
*/
|
||||
@Canonical
|
||||
@CompileStatic
|
||||
@ToString(includePackage = false, includeNames = true, ignoreNulls = true)
|
||||
class ContainerConfig {
|
||||
|
||||
List<String> entrypoint
|
||||
List<String> cmd
|
||||
List<String> env
|
||||
String workingDir
|
||||
|
||||
List<ContainerLayer> layers
|
||||
|
||||
ContainerConfig appendLayer(ContainerLayer it) {
|
||||
if( layers==null && it!=null )
|
||||
layers = new ArrayList<>(10)
|
||||
layers.add(it)
|
||||
return this
|
||||
}
|
||||
|
||||
ContainerConfig prependLayer(ContainerLayer it) {
|
||||
if( layers==null && it!=null )
|
||||
layers = new ArrayList<>(10)
|
||||
layers.add(0, it)
|
||||
return this
|
||||
}
|
||||
|
||||
ContainerConfig plus( ContainerConfig that ) {
|
||||
new ContainerConfig(
|
||||
entrypoint: that.entrypoint ?: this.entrypoint,
|
||||
cmd: that.cmd ?: this.cmd,
|
||||
env: mergeEnv(this.env, that.env),
|
||||
workingDir: that.workingDir ?: this.workingDir,
|
||||
layers: mergeLayers(this.layers, that.layers) )
|
||||
}
|
||||
|
||||
protected List<String> mergeEnv(List<String> left, List<String> right) {
|
||||
if( left==null && right==null )
|
||||
return null
|
||||
final result = new ArrayList<String>(10)
|
||||
if( left )
|
||||
result.addAll(left)
|
||||
if( !right )
|
||||
return result
|
||||
// add the 'right' env to the result
|
||||
for(String it : right) {
|
||||
final pair = it.tokenize('=')
|
||||
// remove existing var because the right list has priority
|
||||
final p = result.findIndexOf { it.startsWith(pair[0]+'=')}
|
||||
if( p!=-1 ) {
|
||||
result.remove(p)
|
||||
result.add(p, it)
|
||||
}
|
||||
else
|
||||
result.add(it)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
protected List<ContainerLayer> mergeLayers(List<ContainerLayer> left, List<ContainerLayer> right) {
|
||||
if( left==null && right==null )
|
||||
return null
|
||||
final result = new ArrayList<ContainerLayer>()
|
||||
if( left ) result.addAll(left)
|
||||
if( right ) result.addAll(right)
|
||||
return result
|
||||
}
|
||||
|
||||
String fingerprint() {
|
||||
final allMeta = new ArrayList()
|
||||
allMeta.add( entrypoint ?: 'no-entry' )
|
||||
allMeta.add( cmd ?: 'no-cmd' )
|
||||
allMeta.add( env ?: 'no-env' )
|
||||
allMeta.add( workingDir ?: 'no-workdir')
|
||||
final layers0 = layers ?: Collections.<ContainerLayer>emptyList()
|
||||
|
||||
for( ContainerLayer it : layers0 ) {
|
||||
if( !it.skipHashing )
|
||||
allMeta.add(it.fingerprint())
|
||||
}
|
||||
return CacheHelper.hasher(allMeta).hash().toString()
|
||||
}
|
||||
|
||||
boolean asBoolean() {
|
||||
return !empty()
|
||||
}
|
||||
|
||||
boolean empty() {
|
||||
return !entrypoint &&
|
||||
!cmd &&
|
||||
!env &&
|
||||
!workingDir &&
|
||||
!layers
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Copyright 2013-2026, Seqera Labs
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.seqera.wave.plugin
|
||||
|
||||
import groovy.transform.Canonical
|
||||
import groovy.transform.CompileStatic
|
||||
import nextflow.util.CacheHelper
|
||||
/**
|
||||
* Model a container layer meta-info
|
||||
*
|
||||
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
|
||||
*/
|
||||
@Canonical
|
||||
@CompileStatic
|
||||
class ContainerLayer {
|
||||
/**
|
||||
* the layer location, it can be either `http:` or `https:` prefixed URI
|
||||
* or a `data:` pseudo-protocol followed by a base64 encoded tar gzipped layer payload
|
||||
*/
|
||||
String location
|
||||
|
||||
/**
|
||||
* The layer gzip sha256 checksum
|
||||
*/
|
||||
String gzipDigest
|
||||
|
||||
/**
|
||||
* The layer gzip size in bytes
|
||||
*/
|
||||
Integer gzipSize
|
||||
|
||||
/**
|
||||
* The layer tar sha256 checksum
|
||||
*/
|
||||
String tarDigest
|
||||
|
||||
/**
|
||||
* When {@code this layer is not added in the final config fingerprint}
|
||||
*/
|
||||
Boolean skipHashing
|
||||
|
||||
void validate() {
|
||||
if( !location ) throw new IllegalArgumentException("Missing layer location")
|
||||
if( !gzipDigest ) throw new IllegalArgumentException("Missing layer gzip digest")
|
||||
if( !gzipSize ) throw new IllegalArgumentException("Missing layer gzip size")
|
||||
if( !tarDigest ) throw new IllegalArgumentException("Missing layer tar digest")
|
||||
}
|
||||
|
||||
String fingerprint() {
|
||||
final allMeta = new ArrayList()
|
||||
allMeta.add( location ?: 'no-location' )
|
||||
allMeta.add( gzipDigest ?: 'no-gzipDigest' )
|
||||
allMeta.add( gzipSize ?: 0 )
|
||||
allMeta.add( tarDigest ?: 'no-tarDigest')
|
||||
return CacheHelper.hasher(allMeta).hash().toString()
|
||||
}
|
||||
|
||||
@Override
|
||||
String toString() {
|
||||
final loc = toStringLocation0(location)
|
||||
return "ContainerLayer[location=${loc}; tarDigest=$tarDigest; gzipDigest=$gzipDigest; gzipSize=$gzipSize]"
|
||||
}
|
||||
|
||||
private String toStringLocation0(String location){
|
||||
if( !location || !location.startsWith('data:') )
|
||||
return location
|
||||
return location.length()>25
|
||||
? location.substring(0,25) + '...'
|
||||
: location
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Copyright 2013-2026, Seqera Labs
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.seqera.wave.plugin
|
||||
|
||||
import java.time.Instant
|
||||
|
||||
import groovy.transform.Canonical
|
||||
import groovy.transform.CompileStatic
|
||||
/**
|
||||
* Model a container request record
|
||||
*
|
||||
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
|
||||
*/
|
||||
@Canonical
|
||||
@CompileStatic
|
||||
class DescribeContainerResponse {
|
||||
|
||||
static class User {
|
||||
Long id
|
||||
String userName
|
||||
String email
|
||||
}
|
||||
|
||||
@Canonical
|
||||
static class RequestInfo {
|
||||
final User user
|
||||
final Long workspaceId
|
||||
final String containerImage
|
||||
final ContainerConfig containerConfig
|
||||
final String platform
|
||||
final String towerEndpoint
|
||||
final String fingerprint
|
||||
final Instant timestamp
|
||||
final String zoneId
|
||||
final String ipAddress
|
||||
}
|
||||
|
||||
@Canonical
|
||||
static class BuildInfo {
|
||||
final String containerFile
|
||||
final String condaFile
|
||||
final String buildRepository
|
||||
final String cacheRepository
|
||||
}
|
||||
|
||||
@Canonical
|
||||
static class ContainerInfo {
|
||||
String image
|
||||
String digest
|
||||
}
|
||||
|
||||
final String token
|
||||
final Instant expiration
|
||||
final RequestInfo request
|
||||
final BuildInfo build
|
||||
final ContainerInfo source
|
||||
final ContainerInfo wave
|
||||
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
* Copyright 2013-2026, Seqera Labs
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.seqera.wave.plugin
|
||||
|
||||
|
||||
import groovy.transform.CompileStatic
|
||||
import groovy.transform.EqualsAndHashCode
|
||||
import groovy.transform.ToString
|
||||
import io.seqera.wave.api.BuildCompression
|
||||
import io.seqera.wave.api.ImageNameStrategy
|
||||
import io.seqera.wave.api.PackagesSpec
|
||||
import io.seqera.wave.api.ScanLevel
|
||||
import io.seqera.wave.api.ScanMode
|
||||
|
||||
/**
|
||||
* Model a request for an augmented container
|
||||
*
|
||||
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
|
||||
*/
|
||||
@EqualsAndHashCode
|
||||
@ToString(includeNames = true, includePackage = false)
|
||||
@CompileStatic
|
||||
class SubmitContainerTokenRequest {
|
||||
|
||||
/**
|
||||
* Tower access token required to enable the service
|
||||
*/
|
||||
String towerAccessToken
|
||||
|
||||
/**
|
||||
* Tower refresh token
|
||||
*/
|
||||
String towerRefreshToken
|
||||
|
||||
/**
|
||||
* Tower workspace id
|
||||
*/
|
||||
Long towerWorkspaceId
|
||||
|
||||
/**
|
||||
* Tower endpoint
|
||||
*/
|
||||
String towerEndpoint
|
||||
|
||||
/**
|
||||
* Container image to be pulled
|
||||
*/
|
||||
String containerImage
|
||||
|
||||
/**
|
||||
* Container build file i.g. Dockerfile of the container to be build
|
||||
*/
|
||||
String containerFile
|
||||
|
||||
/**
|
||||
* List of layers to be added in the pulled image
|
||||
*/
|
||||
ContainerConfig containerConfig
|
||||
|
||||
/**
|
||||
* The request container platform
|
||||
*/
|
||||
String containerPlatform
|
||||
|
||||
/**
|
||||
* The target repository where the built container needs to be stored
|
||||
*/
|
||||
String buildRepository
|
||||
|
||||
/**
|
||||
* The container repository to cache build layers
|
||||
*/
|
||||
String cacheRepository
|
||||
|
||||
/**
|
||||
* Request
|
||||
*/
|
||||
String timestamp
|
||||
|
||||
/**
|
||||
* Request unique fingerprint
|
||||
*/
|
||||
String fingerprint
|
||||
|
||||
/**
|
||||
* Enable freeze container mode
|
||||
*/
|
||||
boolean freeze
|
||||
|
||||
/**
|
||||
* Specify the format of the container file
|
||||
*/
|
||||
String format
|
||||
|
||||
/**
|
||||
* When {@code true} build requests are carried out in dry-run mode.
|
||||
*/
|
||||
Boolean dryRun
|
||||
|
||||
/**
|
||||
* Id of compute workflow environment in tower
|
||||
*/
|
||||
String workflowId
|
||||
|
||||
/**
|
||||
* One or more container should be included in upstream container request
|
||||
*/
|
||||
List<String> containerIncludes
|
||||
|
||||
/**
|
||||
* Defines the packages to be included in this container request
|
||||
*/
|
||||
PackagesSpec packages
|
||||
|
||||
/**
|
||||
* The strategy applied to name a container build by wave when using
|
||||
* the freeze option.
|
||||
*/
|
||||
ImageNameStrategy nameStrategy;
|
||||
|
||||
/**
|
||||
* Whenever use container "mirror" mode
|
||||
*/
|
||||
boolean mirror;
|
||||
|
||||
/**
|
||||
* The request security scan mode
|
||||
*/
|
||||
ScanMode scanMode;
|
||||
|
||||
/**
|
||||
* Define the allows security vulnerabilities in the container request.
|
||||
* Empty or null means no vulnerabilities are allowed.
|
||||
*/
|
||||
List<ScanLevel> scanLevels
|
||||
|
||||
/**
|
||||
* Model build compression option
|
||||
*/
|
||||
BuildCompression buildCompression
|
||||
|
||||
/**
|
||||
* The build template to use for container builds
|
||||
*/
|
||||
String buildTemplate
|
||||
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* Copyright 2013-2026, Seqera Labs
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.seqera.wave.plugin
|
||||
|
||||
import groovy.transform.CompileStatic
|
||||
import groovy.transform.EqualsAndHashCode
|
||||
import groovy.transform.ToString
|
||||
/**
|
||||
* Model a response for an augmented container
|
||||
*
|
||||
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
|
||||
*/
|
||||
@EqualsAndHashCode
|
||||
@ToString(includeNames = true, includePackage = false)
|
||||
@CompileStatic
|
||||
class SubmitContainerTokenResponse {
|
||||
|
||||
/**
|
||||
* Unique Id for this request
|
||||
*/
|
||||
String requestId
|
||||
|
||||
/**
|
||||
* A unique authorization token assigned to this request
|
||||
*/
|
||||
String containerToken
|
||||
|
||||
/**
|
||||
* The fully qualified wave container name to be used
|
||||
*/
|
||||
String targetImage
|
||||
|
||||
/**
|
||||
* The source container image that originated this request
|
||||
*/
|
||||
String containerImage
|
||||
|
||||
/**
|
||||
* The ID of the build associated with this request or null of the image already exists
|
||||
*/
|
||||
String buildId
|
||||
|
||||
/**
|
||||
* Whenever it's a cached build image. Only supported by API version v1alpha2
|
||||
*/
|
||||
Boolean cached
|
||||
|
||||
/**
|
||||
* When the result is a freeze container. Version v1alpha2 as later.
|
||||
*/
|
||||
Boolean freeze;
|
||||
|
||||
/**
|
||||
* When the result is a mirror container. Version v1alpha2 as later.
|
||||
*/
|
||||
Boolean mirror
|
||||
|
||||
/**
|
||||
* The id of the security scan associated with this container
|
||||
*/
|
||||
String scanId
|
||||
|
||||
/**
|
||||
* Whenever the container has been provisioned successfully or not. If false
|
||||
* the current status needs the be check via container status API
|
||||
*/
|
||||
Boolean succeeded
|
||||
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* Copyright 2013-2026, Seqera Labs
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.seqera.wave.plugin
|
||||
|
||||
|
||||
import groovy.transform.Canonical
|
||||
import groovy.transform.CompileStatic
|
||||
import groovy.transform.Memoized
|
||||
import io.seqera.wave.api.PackagesSpec
|
||||
import nextflow.script.bundle.ResourcesBundle
|
||||
import nextflow.util.CacheHelper
|
||||
import nextflow.util.StringUtils
|
||||
/**
|
||||
* Hold assets required to fulfill wave container image build
|
||||
*
|
||||
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
|
||||
*/
|
||||
@Canonical
|
||||
@CompileStatic
|
||||
class WaveAssets {
|
||||
final String containerImage
|
||||
final String containerPlatform
|
||||
final ResourcesBundle moduleResources
|
||||
final ContainerConfig containerConfig
|
||||
final String containerFile
|
||||
final PackagesSpec packagesSpec
|
||||
final ResourcesBundle projectResources
|
||||
final boolean singularity
|
||||
|
||||
static fromImage(String containerImage,String containerPlatform=null) {
|
||||
new WaveAssets(containerImage, containerPlatform)
|
||||
}
|
||||
|
||||
static fromDockerfile(String dockerfile, String containerPlatform=null) {
|
||||
new WaveAssets(null, containerPlatform, null, null, dockerfile)
|
||||
}
|
||||
|
||||
String dockerFileEncoded() {
|
||||
return containerFile
|
||||
? containerFile.bytes.encodeBase64()
|
||||
: null
|
||||
}
|
||||
|
||||
@Memoized
|
||||
String fingerprint() {
|
||||
final allMeta = new ArrayList(10)
|
||||
allMeta.add( this.containerImage )
|
||||
allMeta.add( this.moduleResources?.fingerprint() )
|
||||
allMeta.add( this.containerConfig?.fingerprint() )
|
||||
allMeta.add( this.containerFile )
|
||||
allMeta.add( this.packagesSpec ? fingerprint(this.packagesSpec) : null )
|
||||
allMeta.add( this.projectResources?.fingerprint() )
|
||||
allMeta.add( this.containerPlatform )
|
||||
return CacheHelper.hasher(allMeta).hash().toString()
|
||||
}
|
||||
|
||||
protected String fingerprint(PackagesSpec spec) {
|
||||
final allMeta = new ArrayList(10)
|
||||
allMeta.add( spec.type.toString() )
|
||||
allMeta.add( spec.environment )
|
||||
allMeta.add( spec.entries )
|
||||
allMeta.add( spec.condaOpts?.mambaImage )
|
||||
allMeta.add( spec.condaOpts?.commands )
|
||||
allMeta.add( spec.condaOpts?.basePackages )
|
||||
allMeta.add( spec.channels )
|
||||
return CacheHelper.hasher(allMeta).hash().toString()
|
||||
}
|
||||
|
||||
static void validateContainerName(String name) {
|
||||
if( !name )
|
||||
return
|
||||
final scheme = StringUtils.getUrlProtocol(name)
|
||||
if( scheme )
|
||||
throw new IllegalArgumentException("Wave container request image cannot start with URL like prefix - offending value: $name")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,785 @@
|
||||
/*
|
||||
* Copyright 2013-2026, Seqera Labs
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.seqera.wave.plugin
|
||||
|
||||
import static nextflow.util.SysHelper.*
|
||||
|
||||
import java.net.http.HttpClient
|
||||
import java.net.http.HttpRequest
|
||||
import java.net.http.HttpResponse
|
||||
import java.nio.file.Path
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneId
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
import com.google.common.cache.Cache
|
||||
import com.google.common.cache.CacheBuilder
|
||||
import com.google.common.util.concurrent.RateLimiter
|
||||
import com.google.common.util.concurrent.UncheckedExecutionException
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import groovy.json.JsonOutput
|
||||
import groovy.json.JsonSlurper
|
||||
import groovy.transform.Canonical
|
||||
import groovy.transform.CompileStatic
|
||||
import groovy.transform.Memoized
|
||||
import io.seqera.http.HxClient
|
||||
import io.seqera.util.trace.TraceUtils
|
||||
import io.seqera.wave.api.BuildStatusResponse
|
||||
import io.seqera.wave.api.ContainerStatus
|
||||
import io.seqera.wave.api.ContainerStatusResponse
|
||||
import io.seqera.wave.api.PackagesSpec
|
||||
import io.seqera.wave.plugin.config.TowerConfig
|
||||
import io.seqera.wave.plugin.config.WaveConfig
|
||||
import io.seqera.wave.plugin.exception.BadResponseException
|
||||
import io.seqera.wave.plugin.packer.Packer
|
||||
import io.seqera.wave.util.DockerHelper
|
||||
import nextflow.Session
|
||||
import nextflow.SysEnv
|
||||
import nextflow.container.inspect.ContainerInspectMode
|
||||
import nextflow.container.resolver.ContainerInfo
|
||||
import nextflow.container.resolver.ContainerMeta
|
||||
import nextflow.exception.ProcessUnrecoverableException
|
||||
import nextflow.fusion.FusionConfig
|
||||
import nextflow.processor.Architecture
|
||||
import nextflow.processor.TaskRun
|
||||
import nextflow.script.bundle.ResourcesBundle
|
||||
import nextflow.util.SysHelper
|
||||
import nextflow.util.Threads
|
||||
import org.slf4j.Logger
|
||||
import org.slf4j.LoggerFactory
|
||||
/**
|
||||
* Wave client service
|
||||
*
|
||||
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
|
||||
*/
|
||||
@CompileStatic
|
||||
class WaveClient {
|
||||
|
||||
@Canonical
|
||||
static class Handle {
|
||||
final SubmitContainerTokenResponse response
|
||||
final Instant createdAt
|
||||
int iteration
|
||||
}
|
||||
|
||||
final static public String DEFAULT_S5CMD_AMD64_URL = 'https://nf-xpack.seqera.io/s5cmd/linux_amd64_2.2.2.json'
|
||||
final static public String DEFAULT_S5CMD_ARM64_URL = 'https://nf-xpack.seqera.io/s5cmd/linux_arm64_2.2.2.json'
|
||||
|
||||
private static Logger log = LoggerFactory.getLogger(WaveClient)
|
||||
|
||||
public static final List<String> DEFAULT_CONDA_CHANNELS = ['conda-forge','bioconda']
|
||||
|
||||
final private HxClient httpClient
|
||||
|
||||
final private WaveConfig config
|
||||
|
||||
final private FusionConfig fusion
|
||||
|
||||
final private TowerConfig tower
|
||||
|
||||
final private Packer packer
|
||||
|
||||
final private String endpoint
|
||||
|
||||
private Cache<String, SubmitContainerTokenResponse> cache
|
||||
|
||||
private Map<String,Handle> responses = new ConcurrentHashMap<>()
|
||||
|
||||
private Session session
|
||||
|
||||
private List<String> condaChannels
|
||||
|
||||
final private String waveRegistry
|
||||
|
||||
final private boolean awsFargate
|
||||
|
||||
final private URL s5cmdConfigUrl
|
||||
|
||||
final private RateLimiter limiter
|
||||
|
||||
WaveClient(Session session) {
|
||||
this.session = session
|
||||
this.config = new WaveConfig(session.config.wave as Map ?: Collections.emptyMap(), SysEnv.get())
|
||||
this.fusion = new FusionConfig(session.config.fusion as Map ?: Collections.emptyMap(), SysEnv.get())
|
||||
this.tower = new TowerConfig(session.config.tower as Map ?: Collections.emptyMap(), SysEnv.get())
|
||||
this.awsFargate = WaveFactory.isAwsBatchFargateMode(session.config)
|
||||
this.s5cmdConfigUrl = session.config.navigate('wave.s5cmdConfigUrl') as URL
|
||||
this.endpoint = config.endpoint()
|
||||
this.condaChannels = session.getCondaConfig()?.getChannels() ?: DEFAULT_CONDA_CHANNELS
|
||||
log.debug "Wave config: $config"
|
||||
this.packer = new Packer().withPreserveTimestamp(config.preserveFileTimestamp())
|
||||
this.waveRegistry = new URI(endpoint).getAuthority()
|
||||
this.limiter = RateLimiter.create( config.httpOpts().maxRate().rate )
|
||||
// create cache
|
||||
this.cache = CacheBuilder<String, Handle>
|
||||
.newBuilder()
|
||||
.expireAfterWrite(config.tokensCacheMaxDuration().toSeconds(), TimeUnit.SECONDS)
|
||||
.build()
|
||||
// create http client
|
||||
this.httpClient = newHttpClient()
|
||||
}
|
||||
|
||||
/* only for testing */
|
||||
protected WaveClient() { }
|
||||
|
||||
/**
|
||||
* Creates the main HTTP client for Wave/Tower API communication.
|
||||
* This client includes Bearer token authentication for secure API calls.
|
||||
*
|
||||
* @return An {@link HxClient} configured with authentication tokens
|
||||
*/
|
||||
protected HxClient newHttpClient() {
|
||||
final refreshUrl = tower.refreshToken ? "${tower.endpoint}/oauth/access_token" : null
|
||||
return HxClient.newBuilder()
|
||||
.httpClient(newHttpClient0())
|
||||
.bearerToken(tower.accessToken)
|
||||
.refreshToken(tower.refreshToken)
|
||||
.refreshTokenUrl(refreshUrl)
|
||||
.retryConfig(config.retryOpts())
|
||||
.refreshCookiePolicy(CookiePolicy.ACCEPT_ALL)
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the underlying Java HTTP client with common configuration.
|
||||
*
|
||||
* @return A configured {@link HttpClient} instance
|
||||
*/
|
||||
protected HttpClient newHttpClient0() {
|
||||
final builder = HttpClient.newBuilder()
|
||||
.version(HttpClient.Version.HTTP_1_1)
|
||||
.followRedirects(HttpClient.Redirect.NEVER)
|
||||
.connectTimeout(config.httpOpts().connectTimeout())
|
||||
// use virtual threads executor if enabled
|
||||
if( Threads.useVirtual() )
|
||||
builder.executor(Executors.newVirtualThreadPerTaskExecutor())
|
||||
// build and return the new client
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an HTTP client without authentication for fetching external resources.
|
||||
* <p>
|
||||
* This client is used for requests to external URLs (e.g., public S3 buckets)
|
||||
* that do not require or support Bearer token authentication. Services like
|
||||
* AWS S3 reject requests with unsupported Authorization headers.
|
||||
*
|
||||
* @return An {@link HxClient} without authentication configuration
|
||||
* @see #newHttpClient() for authenticated Wave/Tower API calls
|
||||
*/
|
||||
@Memoized
|
||||
protected HxClient plainHttpClient() {
|
||||
return HxClient.newBuilder()
|
||||
.httpClient(newHttpClient0())
|
||||
.retryConfig(config.retryOpts())
|
||||
.build()
|
||||
}
|
||||
|
||||
WaveConfig config() { return config }
|
||||
|
||||
protected ContainerLayer makeLayer(ResourcesBundle bundle) {
|
||||
final result = packer.layer(bundle.content())
|
||||
return result
|
||||
}
|
||||
|
||||
SubmitContainerTokenRequest makeRequest(WaveAssets assets) {
|
||||
ContainerConfig containerConfig = assets.containerConfig ?: new ContainerConfig()
|
||||
// prepend the bundle layer
|
||||
if( assets.moduleResources!=null && assets.moduleResources.hasEntries() ) {
|
||||
containerConfig.prependLayer(makeLayer(assets.moduleResources))
|
||||
}
|
||||
// prepend project resources bundle
|
||||
if( assets.projectResources!=null && assets.projectResources.hasEntries() ) {
|
||||
containerConfig.prependLayer(makeLayer(assets.projectResources))
|
||||
}
|
||||
|
||||
if( !assets.containerImage && !assets.containerFile && !assets.packagesSpec )
|
||||
throw new IllegalArgumentException("Wave container request requires at least a image or container file or packages spec to build")
|
||||
|
||||
if( assets.containerImage && assets.containerFile )
|
||||
throw new IllegalArgumentException("Wave container image and container file cannot be specified in the same request")
|
||||
|
||||
if( assets.containerImage && assets.packagesSpec )
|
||||
throw new IllegalArgumentException("Wave container image and packages spec cannot be specified in the same request")
|
||||
|
||||
if( assets.containerFile && assets.packagesSpec )
|
||||
throw new IllegalArgumentException("Wave containerFile file and packages spec cannot be specified in the same request")
|
||||
|
||||
if( config.mirrorMode() && config.freezeMode() )
|
||||
throw new IllegalArgumentException("Wave configuration setting 'wave.mirror' and 'wave.freeze' conflicts each other")
|
||||
|
||||
if( config.mirrorMode() && !config.buildRepository() )
|
||||
throw new IllegalArgumentException("Wave configuration setting 'wave.mirror' requires the use of 'wave.build.repository' to define the target registry")
|
||||
|
||||
if( config.mirrorMode() && !assets.containerImage )
|
||||
throw new IllegalArgumentException("Invalid container mirror operation - missing source container")
|
||||
|
||||
if( config.mirrorMode() && containerConfig ) {
|
||||
log.warn1("Wave configuration setting 'wave.mirror' conflicts with the use of module bundles - ignoring custom config for container: $assets.containerImage")
|
||||
containerConfig = null
|
||||
}
|
||||
|
||||
return new SubmitContainerTokenRequest(
|
||||
containerImage: assets.containerImage,
|
||||
containerPlatform: assets.containerPlatform,
|
||||
containerConfig: containerConfig,
|
||||
containerFile: assets.dockerFileEncoded(),
|
||||
packages: assets.packagesSpec,
|
||||
buildRepository: config().buildRepository(),
|
||||
cacheRepository: config.cacheRepository(),
|
||||
timestamp: OffsetDateTime.now().toString(),
|
||||
fingerprint: assets.fingerprint(),
|
||||
freeze: config.freezeMode(),
|
||||
format: assets.singularity ? 'sif' : null,
|
||||
dryRun: ContainerInspectMode.dryRun(),
|
||||
mirror: config.mirrorMode(),
|
||||
scanMode: config.scanMode(),
|
||||
scanLevels: config.scanAllowedLevels(),
|
||||
buildCompression: config.buildCompression(),
|
||||
buildTemplate: config.buildTemplate()
|
||||
)
|
||||
}
|
||||
|
||||
SubmitContainerTokenResponse sendRequest(WaveAssets assets) {
|
||||
final req = makeRequest(assets)
|
||||
req.towerAccessToken = tower.accessToken
|
||||
req.towerRefreshToken = tower.refreshToken
|
||||
req.towerWorkspaceId = tower.workspaceId
|
||||
req.towerEndpoint = tower.endpoint
|
||||
req.workflowId = tower.workflowId
|
||||
return sendRequest(req)
|
||||
}
|
||||
|
||||
SubmitContainerTokenResponse sendRequest(String image) {
|
||||
final ContainerConfig containerConfig = resolveContainerConfig()
|
||||
final request = new SubmitContainerTokenRequest(
|
||||
containerImage: image,
|
||||
containerConfig: containerConfig,
|
||||
towerAccessToken: tower.accessToken,
|
||||
towerWorkspaceId: tower.workspaceId,
|
||||
towerEndpoint: tower.endpoint,
|
||||
workflowId: tower.workflowId,
|
||||
freeze: config.freezeMode(),
|
||||
dryRun: ContainerInspectMode.dryRun(),
|
||||
mirror: config.mirrorMode(),
|
||||
scanMode: config.scanMode(),
|
||||
scanLevels: config.scanAllowedLevels(),
|
||||
buildCompression: config.buildCompression(),
|
||||
buildTemplate: config.buildTemplate()
|
||||
)
|
||||
return sendRequest(request)
|
||||
}
|
||||
|
||||
private void checkLimiter() {
|
||||
final ts = System.currentTimeMillis()
|
||||
try {
|
||||
limiter.acquire()
|
||||
} finally {
|
||||
final delta = System.currentTimeMillis()-ts
|
||||
if( delta>0 )
|
||||
log.debug "Request limiter blocked ${Duration.ofMillis(delta)}"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
SubmitContainerTokenResponse sendRequest(SubmitContainerTokenRequest request) {
|
||||
checkLimiter()
|
||||
return sendRequest0(request, 1)
|
||||
}
|
||||
|
||||
SubmitContainerTokenResponse sendRequest0(SubmitContainerTokenRequest request, int attempt) {
|
||||
assert endpoint, 'Missing wave endpoint'
|
||||
assert !endpoint.endsWith('/'), "Endpoint url must not end with a slash - offending value: $endpoint"
|
||||
|
||||
// set the request access token
|
||||
request.towerAccessToken = httpClient.currentJwtToken
|
||||
request.towerRefreshToken = httpClient.currentRefreshToken
|
||||
|
||||
final trace = TraceUtils.rndTrace()
|
||||
final body = JsonOutput.toJson(request)
|
||||
final uri = URI.create("${endpoint}/v1alpha2/container")
|
||||
log.debug "Wave request: $uri; attempt=$attempt - request: $request"
|
||||
final req = HttpRequest.newBuilder()
|
||||
.uri(uri)
|
||||
.headers('Content-Type','application/json', 'Traceparent', trace)
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||
.build()
|
||||
|
||||
try {
|
||||
final resp = httpClient.sendAsString(req)
|
||||
log.debug "Wave response: statusCode=${resp.statusCode()}; body=${resp.body()}"
|
||||
if( resp.statusCode()==200 )
|
||||
return jsonToSubmitResponse(resp.body())
|
||||
else
|
||||
throw new BadResponseException("Wave invalid response: POST ${uri} [${resp.statusCode()}] ${resp.body()}")
|
||||
}
|
||||
catch (IOException e) {
|
||||
throw new IllegalStateException("Unable to connect Wave service: $endpoint")
|
||||
}
|
||||
}
|
||||
|
||||
protected ContainerStatusResponse jsonToContainerStatusResponse(String body) {
|
||||
final obj = new JsonSlurper().parseText(body) as Map
|
||||
return new ContainerStatusResponse(
|
||||
obj.id as String,
|
||||
obj.status as ContainerStatus,
|
||||
obj.buildId as String,
|
||||
obj.mirrorId as String,
|
||||
obj.scanId as String,
|
||||
obj.vulnerabilities as Map<String,Integer>,
|
||||
obj.succeeded as Boolean,
|
||||
obj.reason as String,
|
||||
obj.detailsUri as String,
|
||||
Instant.parse(obj.creationTime as String),
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
protected BuildStatusResponse jsonToBuildStatusResponse(String body) {
|
||||
final obj = new JsonSlurper().parseText(body) as Map
|
||||
new BuildStatusResponse(
|
||||
obj.id as String,
|
||||
obj.status as BuildStatusResponse.Status,
|
||||
obj.startTime ? Instant.parse(obj.startTime as String) : null,
|
||||
obj.duration ? Duration.ofMillis(obj.duration as double * 1_000 as long) : null,
|
||||
obj.succeeded as Boolean
|
||||
)
|
||||
}
|
||||
|
||||
protected SubmitContainerTokenResponse jsonToSubmitResponse(String body) {
|
||||
final type = new TypeToken<SubmitContainerTokenResponse>(){}.getType()
|
||||
return new Gson().fromJson(body, type)
|
||||
}
|
||||
|
||||
protected ContainerConfig jsonToContainerConfig(String json) {
|
||||
final type = new TypeToken<ContainerConfig>(){}.getType()
|
||||
return new Gson().fromJson(json, type)
|
||||
}
|
||||
|
||||
protected URL defaultFusionUrl(String platform) {
|
||||
final isArm = platform.tokenize('/')?.contains('arm64')
|
||||
return isArm
|
||||
? fusionArm64(fusion.snapshotsEnabled())
|
||||
: fusionAmd64(fusion.snapshotsEnabled())
|
||||
}
|
||||
|
||||
protected URL fusionAmd64(boolean snapshots) {
|
||||
final url = snapshots
|
||||
? FusionConfig.DEFAULT_SNAPSHOT_AMD64_URL
|
||||
: FusionConfig.DEFAULT_FUSION_AMD64_URL
|
||||
return URI.create(fusion.targetFusionUrl(url)).toURL()
|
||||
}
|
||||
|
||||
protected URL fusionArm64(boolean snapshots) {
|
||||
final url = snapshots
|
||||
? FusionConfig.DEFAULT_SNAPSHOT_ARM64_URL
|
||||
: FusionConfig.DEFAULT_FUSION_ARM64_URL
|
||||
return URI.create(fusion.targetFusionUrl(url)).toURL()
|
||||
}
|
||||
|
||||
protected URL defaultS5cmdUrl(String platform) {
|
||||
final isArm = platform.tokenize('/')?.contains('arm64')
|
||||
return isArm
|
||||
? new URL(DEFAULT_S5CMD_ARM64_URL)
|
||||
: new URL(DEFAULT_S5CMD_AMD64_URL)
|
||||
}
|
||||
|
||||
protected static URL replaceFusionArch(URL url, String platform) {
|
||||
final isArm = platform.tokenize('/')?.contains('arm64')
|
||||
final targetArch = isArm ? 'arm64' : 'amd64'
|
||||
final replaced = url.toString().replaceAll(/(?<=[-_])(amd64|arm64)(?=\.)/, targetArch)
|
||||
return replaced != url.toString() ? new URL(replaced) : url
|
||||
}
|
||||
|
||||
ContainerConfig resolveContainerConfig(String platform = DEFAULT_DOCKER_PLATFORM) {
|
||||
final urls = new ArrayList<URL>(config.containerConfigUrl())
|
||||
final platforms = platform ? platform.tokenize(',') : List.of(DEFAULT_DOCKER_PLATFORM)
|
||||
if( fusion.enabled() ) {
|
||||
final customUrl = fusion.containerConfigUrl()
|
||||
for( String p : platforms ) {
|
||||
final fusionUrl = customUrl ? replaceFusionArch(customUrl, p.trim()) : defaultFusionUrl(p.trim())
|
||||
urls.add(fusionUrl)
|
||||
}
|
||||
}
|
||||
if( awsFargate ) {
|
||||
final s5cmdUrl = s5cmdConfigUrl ?: defaultS5cmdUrl(platform)
|
||||
urls.add(s5cmdUrl)
|
||||
}
|
||||
if( !urls )
|
||||
return null
|
||||
def result = new ContainerConfig()
|
||||
for( URL it : urls ) {
|
||||
// append each config to the other - the last has priority
|
||||
result += fetchContainerConfig(it)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@Memoized
|
||||
synchronized protected ContainerConfig fetchContainerConfig(URL configUrl) {
|
||||
log.debug "Wave request container config: $configUrl"
|
||||
final req = HttpRequest.newBuilder()
|
||||
.uri(configUrl.toURI())
|
||||
.headers('Content-Type','application/json')
|
||||
.GET()
|
||||
.build()
|
||||
|
||||
final resp = plainHttpClient().sendAsString(req)
|
||||
final code = resp.statusCode()
|
||||
final body = resp.body()
|
||||
if( code>=200 && code<400 ) {
|
||||
log.debug "Wave container config response: [$code] ${body}"
|
||||
return jsonToContainerConfig(resp.body())
|
||||
}
|
||||
throw new BadResponseException("Unexpected response for containerContainerConfigUrl \'$configUrl\': [${resp.statusCode()}] ${resp.body()}")
|
||||
}
|
||||
|
||||
protected void checkConflicts(Map<String,String> attrs, String name) {
|
||||
if( attrs.container && attrs.conda ) {
|
||||
throw new IllegalArgumentException("Process '${name}' declares both 'container' and 'conda' directives that conflict each other")
|
||||
}
|
||||
checkConflicts0(attrs, name, 'dockerfile')
|
||||
checkConflicts0(attrs, name, 'singularityfile')
|
||||
}
|
||||
|
||||
protected void checkConflicts0(Map<String,String> attrs, String name, String fileType) {
|
||||
if( attrs.get(fileType) && attrs.conda ) {
|
||||
throw new IllegalArgumentException("Process '${name}' declares both a 'conda' directive and a module bundle $fileType that conflict each other")
|
||||
}
|
||||
if( attrs.container && attrs.get(fileType) ) {
|
||||
throw new IllegalArgumentException("Process '${name}' declares both a 'container' directive and a module bundle $fileType that conflict each other")
|
||||
}
|
||||
}
|
||||
|
||||
Map<String,String> resolveConflicts(Map<String,String> attrs, List<String> strategy) {
|
||||
final result = new HashMap<String,String>()
|
||||
for( String it : strategy ) {
|
||||
if( attrs.get(it) ) {
|
||||
return [(it): attrs.get(it)]
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
protected List<String> patchStrategy(List<String> strategy, boolean singularity) {
|
||||
if( !singularity )
|
||||
return strategy
|
||||
// when singularity is enabled, replaces `dockerfile` with `singularityfile`
|
||||
// in the strategy if not specified explicitly
|
||||
final p = strategy.indexOf('dockerfile')
|
||||
if( p!=-1 && !strategy.contains('singularityfile') ) {
|
||||
final result = new ArrayList(strategy)
|
||||
result.remove(p)
|
||||
result.add(p, 'singularityfile')
|
||||
return Collections.<String>unmodifiableList(result)
|
||||
}
|
||||
return strategy
|
||||
}
|
||||
|
||||
static Architecture defaultArch() {
|
||||
try {
|
||||
return new Architecture(SysHelper.getArch())
|
||||
}
|
||||
catch (Exception e) {
|
||||
log.debug "Unable to detect system arch", e
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@Memoized
|
||||
WaveAssets resolveAssets(TaskRun task, String containerImage, boolean singularity) {
|
||||
// get the bundle
|
||||
final bundle = task.getModuleBundle()
|
||||
// get the architecture
|
||||
final dockerArch = task.getContainerPlatform()
|
||||
// compose the request attributes
|
||||
def attrs = new HashMap<String,String>()
|
||||
attrs.container = containerImage
|
||||
attrs.conda = task.config.conda as String
|
||||
if( bundle!=null && bundle.dockerfile ) {
|
||||
attrs.dockerfile = bundle.dockerfile.text
|
||||
}
|
||||
if( bundle!=null && bundle.singularityfile ) {
|
||||
attrs.singularityfile = bundle.singularityfile.text
|
||||
}
|
||||
|
||||
// validate request attributes
|
||||
final strategy = config().strategy()
|
||||
if( strategy )
|
||||
attrs = resolveConflicts(attrs, patchStrategy(strategy, singularity))
|
||||
else
|
||||
checkConflicts(attrs, task.lazyName())
|
||||
|
||||
// resolve the wave assets
|
||||
return resolveAssets0(attrs, bundle, singularity, dockerArch)
|
||||
}
|
||||
|
||||
protected WaveAssets resolveAssets0(Map<String,String> attrs, ResourcesBundle bundle, boolean singularity, String platform) {
|
||||
|
||||
final scriptType = singularity ? 'singularityfile' : 'dockerfile'
|
||||
String containerScript = attrs.get(scriptType)
|
||||
final containerImage = attrs.container
|
||||
PackagesSpec packagesSpec = null
|
||||
|
||||
/*
|
||||
* If 'conda' directive is specified use it to create a container file
|
||||
* to assemble the target container
|
||||
*/
|
||||
if( attrs.conda ) {
|
||||
if( containerScript )
|
||||
throw new IllegalArgumentException("Unexpected conda and $scriptType conflict while resolving wave container")
|
||||
|
||||
// map the recipe to a dockerfile
|
||||
if( isCondaRemoteFile(attrs.conda) ) {
|
||||
packagesSpec = new PackagesSpec()
|
||||
.withType(PackagesSpec.Type.CONDA)
|
||||
.withChannels(condaChannels)
|
||||
.withCondaOpts(config.condaOpts())
|
||||
.withEntries(List.of(attrs.conda))
|
||||
}
|
||||
else {
|
||||
if( isCondaLocalFile(attrs.conda) ) {
|
||||
// 'conda' attribute is the path to the local conda environment
|
||||
// note: ignore the 'channels' attribute because they are supposed to be provided by the conda file
|
||||
final condaFile = DockerHelper.condaFileFromPath(attrs.conda, null)
|
||||
packagesSpec = new PackagesSpec()
|
||||
.withType(PackagesSpec.Type.CONDA)
|
||||
.withCondaOpts(config.condaOpts())
|
||||
.withEnvironment(condaFile.bytes.encodeBase64().toString())
|
||||
}
|
||||
else {
|
||||
// 'conda' attributes is resolved as the conda packages to be used
|
||||
packagesSpec = new PackagesSpec()
|
||||
.withType(PackagesSpec.Type.CONDA)
|
||||
.withChannels(condaChannels)
|
||||
.withCondaOpts(config.condaOpts())
|
||||
.withEntries(DockerHelper.condaPackagesToList(attrs.conda))
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* The process should declare at least a container image name via 'container' directive
|
||||
* or a dockerfile file to build, otherwise there's no job to be done by wave
|
||||
*/
|
||||
if( !containerScript && !containerImage && !packagesSpec ) {
|
||||
return null
|
||||
}
|
||||
|
||||
/*
|
||||
* project level resources i.e. `ROOT/bin/` directory files
|
||||
* are only uploaded when using fusion
|
||||
*/
|
||||
final projectRes = config.bundleProjectResources() && session.binDir
|
||||
? projectResources(session.binDir)
|
||||
: null
|
||||
|
||||
// check is a valid container image
|
||||
WaveAssets.validateContainerName(containerImage)
|
||||
// read the container config and go ahead
|
||||
final containerConfig = this.resolveContainerConfig(platform)
|
||||
return new WaveAssets(
|
||||
containerImage,
|
||||
platform,
|
||||
bundle,
|
||||
containerConfig,
|
||||
containerScript,
|
||||
packagesSpec,
|
||||
projectRes,
|
||||
singularity)
|
||||
}
|
||||
|
||||
@Memoized
|
||||
protected ResourcesBundle projectResources(Path path) {
|
||||
log.debug "Wave assets bundle bin resources: $path"
|
||||
// place project 'bin' resources under '/usr/local'
|
||||
// see https://unix.stackexchange.com/questions/8656/usr-bin-vs-usr-local-bin-on-linux
|
||||
return path && path.parent
|
||||
? ResourcesBundle.scan( path.parent, [filePattern: "$path.name/**", baseDirectory:'usr/local'] )
|
||||
: null
|
||||
}
|
||||
|
||||
ContainerInfo fetchContainerImage(WaveAssets assets) {
|
||||
try {
|
||||
// compute a unique hash for this request assets
|
||||
final key = assets.fingerprint()
|
||||
log.trace "Wave fingerprint: $key; assets: $assets"
|
||||
// get from cache or submit a new request
|
||||
final resp = cache.get(key, () -> {
|
||||
final ret = sendRequest(assets);
|
||||
responses.put(key,new Handle(ret,Instant.now()));
|
||||
return ret
|
||||
})
|
||||
return new ContainerInfo(assets.containerImage, resp.targetImage, key)
|
||||
}
|
||||
catch ( UncheckedExecutionException e ) {
|
||||
throw e.cause
|
||||
}
|
||||
}
|
||||
|
||||
protected boolean checkContainerCompletion(Handle handle) {
|
||||
final long maxAwait = config.buildMaxDuration().toMillis()
|
||||
final startTime = handle.createdAt.toEpochMilli()
|
||||
final containerImage = handle.response.targetImage
|
||||
final requestId = handle.response.requestId
|
||||
final resp = containerStatus(requestId)
|
||||
if( resp.status==ContainerStatus.DONE ) {
|
||||
if( resp.succeeded )
|
||||
return true
|
||||
def msg = "Wave provisioning for container '${containerImage}' did not complete successfully"
|
||||
if( resp.reason )
|
||||
msg += "\n- Reason: ${resp.reason}"
|
||||
if( resp.detailsUri )
|
||||
msg += "\n- Find out more here: ${resp.detailsUri}"
|
||||
throw new ProcessUnrecoverableException(msg)
|
||||
}
|
||||
if( System.currentTimeMillis()-startTime > maxAwait ) {
|
||||
final msg = "Wave provisioning for container '${containerImage}' is exceeding max allowed duration (${config.buildMaxDuration()}) - check details here: ${endpoint}/view/containers/${requestId}"
|
||||
throw new ProcessUnrecoverableException(msg)
|
||||
}
|
||||
// this is expected to be invoked ~ every seconds, therefore
|
||||
// print an info message after 10 seconds or every 200 seconds
|
||||
if( ((handle.iteration++)-10) % 200 == 0 ) {
|
||||
log.info "Awaiting container provisioning: $containerImage"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
protected boolean checkBuildCompletion(Handle handle) {
|
||||
final long maxAwait = config.buildMaxDuration().toMillis()
|
||||
final startTime = handle.createdAt.toEpochMilli()
|
||||
final containerImage = handle.response.targetImage
|
||||
final buildId = handle.response.buildId
|
||||
final resp = buildStatus(buildId)
|
||||
if( resp.status==BuildStatusResponse.Status.COMPLETED ) {
|
||||
if( resp.succeeded )
|
||||
return true
|
||||
final msg = "Wave provisioning for container '${containerImage}' did not complete successfully - check details here: ${endpoint}/view/builds/${buildId}"
|
||||
throw new ProcessUnrecoverableException(msg)
|
||||
}
|
||||
if( System.currentTimeMillis()-startTime > maxAwait ) {
|
||||
final msg = "Wave provisioning for container '${containerImage}' is exceeding max allowed duration (${config.buildMaxDuration()}) - check details here: ${endpoint}/view/builds/${buildId}"
|
||||
throw new ProcessUnrecoverableException(msg)
|
||||
}
|
||||
// this is expected to be invoked ~ every seconds, therefore
|
||||
// print an info message after 10 seconds or every 200 seconds
|
||||
if( ((handle.iteration++)-10) % 200 == 0 ) {
|
||||
log.info "Awaiting container provisioning: $containerImage"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
boolean isContainerReady(String key) {
|
||||
final handle = responses.get(key)
|
||||
if( !handle )
|
||||
throw new IllegalStateException("Unable to find any container with key: $key")
|
||||
final resp = handle.response
|
||||
if( resp.requestId ) {
|
||||
return resp.succeeded
|
||||
? true
|
||||
: checkContainerCompletion(handle)
|
||||
}
|
||||
if( resp.buildId && !resp.cached )
|
||||
return checkBuildCompletion(handle)
|
||||
else
|
||||
return true
|
||||
}
|
||||
|
||||
ContainerMeta getContainerMeta(String key) {
|
||||
final handle = responses.get(key)
|
||||
if( !handle )
|
||||
return null
|
||||
final resp = handle.response
|
||||
final result = new ContainerMeta()
|
||||
result.requestTime = handle.createdAt?.atZone(ZoneId.systemDefault())?.toOffsetDateTime()
|
||||
result.requestId = resp.requestId
|
||||
result.sourceImage = resp.containerImage
|
||||
result.targetImage = resp.targetImage
|
||||
result.buildId = !resp.mirror ? resp.buildId : null
|
||||
result.mirrorId = resp.mirror ? resp.buildId : null
|
||||
result.scanId = resp.scanId
|
||||
result.cached = resp.cached
|
||||
result.freeze = resp.freeze
|
||||
return result
|
||||
}
|
||||
|
||||
protected static int randomRange(int min, int max) {
|
||||
assert min<max
|
||||
Random rand = new Random();
|
||||
return rand.nextInt((max - min) + 1) + min;
|
||||
}
|
||||
|
||||
protected ContainerStatusResponse containerStatus(String requestId) {
|
||||
final String statusEndpoint = endpoint + "/v1alpha2/container/${requestId}/status";
|
||||
final HttpRequest req = HttpRequest.newBuilder()
|
||||
.uri(URI.create(statusEndpoint))
|
||||
.headers("Content-Type","application/json")
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
final HttpResponse<String> resp = httpClient.sendAsString(req);
|
||||
log.debug("Wave container status response: statusCode={}; body={}", resp.statusCode(), resp.body())
|
||||
if( resp.statusCode()==200 ) {
|
||||
return jsonToContainerStatusResponse(resp.body())
|
||||
}
|
||||
else {
|
||||
String msg = String.format("Wave invalid response: GET %s [%s] %s", statusEndpoint, resp.statusCode(), resp.body());
|
||||
throw new BadResponseException(msg)
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated
|
||||
protected BuildStatusResponse buildStatus(String buildId) {
|
||||
final String statusEndpoint = endpoint + "/v1alpha1/builds/${buildId}/status";
|
||||
final HttpRequest req = HttpRequest.newBuilder()
|
||||
.uri(URI.create(statusEndpoint))
|
||||
.headers("Content-Type","application/json")
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
final HttpResponse<String> resp = httpClient.sendAsString(req);
|
||||
log.debug("Wave build status response: statusCode={}; body={}", resp.statusCode(), resp.body())
|
||||
if( resp.statusCode()==200 ) {
|
||||
return jsonToBuildStatusResponse(resp.body())
|
||||
}
|
||||
else {
|
||||
String msg = String.format("Wave invalid response: GET %s [%s] %s", statusEndpoint, resp.statusCode(), resp.body());
|
||||
throw new BadResponseException(msg)
|
||||
}
|
||||
}
|
||||
|
||||
static protected boolean isCondaLocalFile(String value) {
|
||||
if( value.contains('\n') )
|
||||
return false
|
||||
if( value.startsWith('http://') || value.startsWith('https://') )
|
||||
return false
|
||||
if( value.startsWith('/') && !value.contains('\n') )
|
||||
return true
|
||||
return value.endsWith('.yaml') || value.endsWith('.yml') || value.endsWith('.txt')
|
||||
}
|
||||
|
||||
static protected boolean isCondaRemoteFile(String value) {
|
||||
value.startsWith('http://') || value.startsWith('https://')
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* Copyright 2013-2026, Seqera Labs
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.seqera.wave.plugin
|
||||
|
||||
import groovy.transform.CompileStatic
|
||||
import groovy.transform.Memoized
|
||||
import groovy.util.logging.Slf4j
|
||||
import nextflow.Session
|
||||
import nextflow.SysEnv
|
||||
import nextflow.exception.AbortOperationException
|
||||
import nextflow.trace.TraceObserverV2
|
||||
import nextflow.trace.TraceObserverFactoryV2
|
||||
/**
|
||||
* Factory class for wave session
|
||||
*
|
||||
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
|
||||
*/
|
||||
@Slf4j
|
||||
@CompileStatic
|
||||
class WaveFactory implements TraceObserverFactoryV2 {
|
||||
|
||||
@Override
|
||||
Collection<TraceObserverV2> create(Session session) {
|
||||
shouldEnable(session)
|
||||
return Collections.<TraceObserverV2>emptyList()
|
||||
}
|
||||
|
||||
@Memoized // <-- declare as memoized to make it's invoked only once
|
||||
static boolean shouldEnable(Session session) {
|
||||
final config = session.config
|
||||
final wave = (Map)config.wave ?: new HashMap<>(1)
|
||||
final fusion = (Map)config.fusion ?: new HashMap<>(1)
|
||||
|
||||
if( SysEnv.get('NXF_DISABLE_WAVE_SERVICE') ) {
|
||||
log.debug "Detected NXF_DISABLE_WAVE_SERVICE environment variable - Turning off Wave service"
|
||||
wave.enabled = false
|
||||
return false
|
||||
}
|
||||
|
||||
if( fusion.enabled ) {
|
||||
checkWaveRequirement(session, wave, 'Fusion')
|
||||
}
|
||||
if( isAwsBatchFargateMode(config) ) {
|
||||
checkWaveRequirement(session, wave, 'Fargate')
|
||||
}
|
||||
return wave.enabled==true
|
||||
}
|
||||
|
||||
static private void checkWaveRequirement(Session session, Map wave, String feature) {
|
||||
if( !wave.enabled ) {
|
||||
throw new AbortOperationException("$feature feature requires enabling Wave service")
|
||||
}
|
||||
else {
|
||||
log.debug "Detected $feature enabled -- Enabling bundle project resources -- Disabling upload of remote bin directory"
|
||||
wave.bundleProjectResources = true
|
||||
session.disableRemoteBinDir = true
|
||||
}
|
||||
}
|
||||
|
||||
static boolean isAwsBatchFargateMode(Map config) {
|
||||
return 'fargate'.equalsIgnoreCase(config.navigate('aws.batch.platformType') as String)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright 2013-2026, Seqera Labs
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.seqera.wave.plugin
|
||||
|
||||
import io.seqera.wave.plugin.cli.WaveCmdEntry
|
||||
import nextflow.cli.PluginExecAware
|
||||
import nextflow.plugin.BasePlugin
|
||||
import org.pf4j.PluginWrapper
|
||||
/**
|
||||
* Wave plugin entrypoint
|
||||
*
|
||||
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
|
||||
*/
|
||||
class WavePlugin extends BasePlugin implements PluginExecAware {
|
||||
|
||||
@Delegate
|
||||
private WaveCmdEntry cmd
|
||||
|
||||
WavePlugin(PluginWrapper wrapper) {
|
||||
super(wrapper)
|
||||
this.cmd = new WaveCmdEntry()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright 2013-2026, Seqera Labs
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.seqera.wave.plugin.adapter
|
||||
|
||||
import java.lang.reflect.Type
|
||||
import java.time.Instant
|
||||
|
||||
import com.google.gson.JsonDeserializationContext
|
||||
import com.google.gson.JsonDeserializer
|
||||
import com.google.gson.JsonElement
|
||||
import com.google.gson.JsonParseException
|
||||
import com.google.gson.JsonPrimitive
|
||||
import com.google.gson.JsonSerializationContext
|
||||
import com.google.gson.JsonSerializer
|
||||
|
||||
/**
|
||||
* Gson adapter for java Instant class
|
||||
*
|
||||
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
|
||||
*/
|
||||
class InstantAdapter implements JsonSerializer<Instant>, JsonDeserializer<Instant> {
|
||||
|
||||
@Override
|
||||
JsonElement serialize(Instant instant, Type type, JsonSerializationContext context) {
|
||||
return new JsonPrimitive(instant.toString());
|
||||
}
|
||||
|
||||
@Override
|
||||
Instant deserialize(JsonElement json, Type type, JsonDeserializationContext context) throws JsonParseException {
|
||||
String instantString = json.getAsString();
|
||||
return Instant.parse(instantString);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* Copyright 2013-2026, Seqera Labs
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.seqera.wave.plugin.cli
|
||||
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
|
||||
import groovy.json.JsonOutput
|
||||
import groovy.transform.CompileStatic
|
||||
import groovy.util.logging.Slf4j
|
||||
import io.seqera.wave.plugin.ContainerConfig
|
||||
import io.seqera.wave.plugin.packer.Packer
|
||||
import io.seqera.wave.plugin.util.BasicCliOpts
|
||||
import nextflow.cli.PluginAbstractExec
|
||||
import nextflow.exception.AbortOperationException
|
||||
import nextflow.io.BucketParser
|
||||
/**
|
||||
* Implements Wave CLI tool
|
||||
*
|
||||
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
|
||||
*/
|
||||
@Slf4j
|
||||
@CompileStatic
|
||||
class WaveCmdEntry implements PluginAbstractExec {
|
||||
|
||||
List<String> getCommands() { ['get-container','run-container', 'debug-task', 'pack'] }
|
||||
|
||||
@Override
|
||||
int exec(String cmd, List<String> args) {
|
||||
|
||||
switch (cmd) {
|
||||
case 'get-container':
|
||||
new WaveRunCmd(session).getContainer(args)
|
||||
break
|
||||
case 'run-container':
|
||||
new WaveRunCmd(session).runContainer(args)
|
||||
break
|
||||
case 'pack':
|
||||
println packContainer(args)
|
||||
break
|
||||
case 'debug-task':
|
||||
new WaveDebugCmd(session).taskDebug(args)
|
||||
break
|
||||
default:
|
||||
throw new AbortOperationException("Unknown wave command: $cmd")
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
protected String packContainer(List<String> args) {
|
||||
final cli = BasicCliOpts.parse(args)
|
||||
final packer = new Packer()
|
||||
if( !cli.args )
|
||||
throw new AbortOperationException("Missing pack target directory")
|
||||
final root = Path.of(cli.args.pop())
|
||||
if( !Files.exists(root) )
|
||||
throw new AbortOperationException("Pack target path does not exist: $root")
|
||||
if( !Files.isDirectory(root) )
|
||||
throw new AbortOperationException("Pack target path is not a directory: $root")
|
||||
// determine target location form CLI option
|
||||
final location = cli.options.location
|
||||
// pack the layer
|
||||
final containerConfig = new ContainerConfig()
|
||||
final layer = packer.createContainerPack(root, baseName(location))
|
||||
containerConfig.appendLayer(layer)
|
||||
// set the target location
|
||||
if( location )
|
||||
layer.location = location
|
||||
// set entrypoint
|
||||
if( cli.options.entrypoint )
|
||||
containerConfig.entrypoint = [cli.options.entrypoint]
|
||||
// set work dir
|
||||
if( cli.options.workingDir )
|
||||
containerConfig.workingDir = cli.options.workingDir
|
||||
// set entrypoint
|
||||
if( cli.options.cmd )
|
||||
containerConfig.cmd = [cli.options.cmd]
|
||||
// render final object
|
||||
return JsonOutput.prettyPrint(JsonOutput.toJson(containerConfig))
|
||||
}
|
||||
|
||||
static protected String baseName(String path) {
|
||||
if( !path )
|
||||
return null
|
||||
def name = BucketParser.from(path).getPath().getName()
|
||||
if( !name )
|
||||
return null
|
||||
return name
|
||||
.replaceAll(/\.gz$/,'')
|
||||
.replaceAll(/\.gzip$/,'')
|
||||
.replaceAll(/\.tar$/,'')
|
||||
.replaceAll(/\.json$/,'')
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/*
|
||||
* Copyright 2013-2026, Seqera Labs
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.seqera.wave.plugin.cli
|
||||
|
||||
import java.nio.file.Path
|
||||
|
||||
import com.google.common.hash.HashCode
|
||||
import groovy.transform.CompileStatic
|
||||
import groovy.util.logging.Slf4j
|
||||
import nextflow.Session
|
||||
import nextflow.SysEnv
|
||||
import nextflow.cache.CacheDB
|
||||
import nextflow.cache.CacheFactory
|
||||
import nextflow.exception.AbortOperationException
|
||||
import nextflow.fusion.FusionHelper
|
||||
import nextflow.file.FileHelper
|
||||
import nextflow.trace.TraceRecord
|
||||
import nextflow.util.HistoryFile
|
||||
|
||||
import static nextflow.fusion.FusionConfig.FUSION_PATH
|
||||
|
||||
import static nextflow.util.StringUtils.*
|
||||
|
||||
/**
|
||||
* Implement wave container task debug
|
||||
*
|
||||
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
|
||||
*/
|
||||
@Slf4j
|
||||
@CompileStatic
|
||||
class WaveDebugCmd {
|
||||
|
||||
private CacheDB cacheDb
|
||||
|
||||
private Session session
|
||||
|
||||
WaveDebugCmd(Session session) {
|
||||
this.session = session
|
||||
}
|
||||
|
||||
protected void taskDebug(List<String> args) {
|
||||
if( !args )
|
||||
throw new AbortOperationException("Missing id of the task to debug or remote task path")
|
||||
|
||||
final criteria = args.pop()
|
||||
|
||||
if ( isRemotePath(criteria) && args ) {
|
||||
final image = args.pop()
|
||||
runRemoteTask(criteria, image)
|
||||
}
|
||||
else {
|
||||
runCacheTask(criteria)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
protected runRemoteTask(String workDir, String image) {
|
||||
final path = FileHelper.asPath(workDir)
|
||||
final cmd = buildWaveRunCmd(path.scheme)
|
||||
if ( image.startsWith("wave.seqera.io/") ) {
|
||||
// Run a waved container
|
||||
cmd.runContainer(image, buildCommand(path))
|
||||
}
|
||||
else {
|
||||
// Wave it before running
|
||||
List<String> args = [image] + buildCommand(path)
|
||||
cmd.runContainer(args)
|
||||
}
|
||||
}
|
||||
|
||||
protected runCacheTask(String criteria) {
|
||||
HistoryFile history = HistoryFile.DEFAULT
|
||||
HistoryFile.Record runRecord
|
||||
if( !history.exists() || history.empty() || !(runRecord=history.findByIdOrName('last')[0]) )
|
||||
throw new AbortOperationException("It looks no pipeline was executed in this folder (or execution history is empty)")
|
||||
|
||||
this.cacheDb = CacheFactory.create(runRecord.sessionId, runRecord.runName)
|
||||
cacheDb.openForRead()
|
||||
try {
|
||||
final trace = getOrFindTrace(criteria)
|
||||
if( trace==null )
|
||||
throw new AbortOperationException("Cannot find any task with id: '$criteria'")
|
||||
if( !isRemotePath(trace.workDir) )
|
||||
throw new AbortOperationException("Cannot run non-fusion enabled task - Task work dir: $trace.workDir")
|
||||
log.info "Launching debug session for task '${trace.get('name')}' - work directory: ${trace.workDir}"
|
||||
final cmd = buildWaveRunCmd(getUrlProtocol(trace.workDir))
|
||||
cmd.runContainer(trace.get('container')?.toString(), buildCommand(trace.workDir))
|
||||
}
|
||||
finally {
|
||||
cacheDb.close()
|
||||
}
|
||||
}
|
||||
|
||||
protected static List<String> buildCommand(String workDir) {
|
||||
final workPath = FileHelper.asPath(workDir)
|
||||
return buildCommand(workPath)
|
||||
}
|
||||
|
||||
protected static List<String> buildCommand(Path workPath) {
|
||||
final fusionPath = FusionHelper.toContainerMount(workPath, workPath.scheme)
|
||||
return [FUSION_PATH, 'sh', '-c', "\'cd $fusionPath && PS1=\"[fusion] \" exec bash --norc\'".toString()]
|
||||
}
|
||||
|
||||
protected WaveRunCmd buildWaveRunCmd(String scheme) {
|
||||
final result = new WaveRunCmd(session)
|
||||
result.withContainerParams([tty:true, privileged: true])
|
||||
if( scheme=='s3' ) {
|
||||
result.withEnvironment('AWS_ACCESS_KEY_ID')
|
||||
result.withEnvironment('AWS_SECRET_ACCESS_KEY')
|
||||
}
|
||||
else if( scheme=='gs' && SysEnv.containsKey('GOOGLE_APPLICATION_CREDENTIALS') ) {
|
||||
final path = Path.of(SysEnv.get('GOOGLE_APPLICATION_CREDENTIALS'))
|
||||
result.withMounts(List.of(path))
|
||||
result.withEnvironment('GOOGLE_APPLICATION_CREDENTIALS')
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
protected TraceRecord getOrFindTrace(String criteria) {
|
||||
TraceRecord result = null
|
||||
final norm = criteria.replace('/','')
|
||||
if( norm.size()==32 ) try {
|
||||
final hash = HashCode.fromString(norm)
|
||||
result = cacheDb.getTraceRecord(hash)
|
||||
}
|
||||
catch (IllegalArgumentException e) {
|
||||
log.debug "Not a valid task hash: $criteria"
|
||||
}
|
||||
|
||||
if( !result ) {
|
||||
final condition = { TraceRecord trace ->
|
||||
final hash = trace.get('hash') as String
|
||||
final name = trace.get('name') as String
|
||||
return hash?.contains(criteria)
|
||||
|| hash.replace('/','')?.contains(criteria)
|
||||
|| name?.contains(criteria)
|
||||
|| trace.workDir?.contains(criteria)
|
||||
}
|
||||
result = cacheDb.findTraceRecord(condition)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
static boolean isRemotePath(String path) {
|
||||
if (!path) return false
|
||||
final result = getUrlProtocol(path)
|
||||
return result!=null && result!='file'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* Copyright 2013-2026, Seqera Labs
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.seqera.wave.plugin.cli
|
||||
|
||||
import java.nio.file.Path
|
||||
|
||||
import groovy.transform.CompileStatic
|
||||
import groovy.util.logging.Slf4j
|
||||
import io.seqera.wave.plugin.WaveClient
|
||||
import nextflow.Session
|
||||
import nextflow.container.ContainerBuilder
|
||||
import nextflow.exception.AbortOperationException
|
||||
|
||||
/**
|
||||
* Implements Wave debug command
|
||||
*
|
||||
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
|
||||
*/
|
||||
@Slf4j
|
||||
@CompileStatic
|
||||
class WaveRunCmd {
|
||||
|
||||
private Session session
|
||||
|
||||
private Map containerParams
|
||||
|
||||
private List<Path> containerMounts
|
||||
|
||||
private Set<String> environment = new HashSet<>()
|
||||
|
||||
WaveRunCmd(Session session) { this.session=session }
|
||||
|
||||
WaveRunCmd withContainerParams(Map params) {
|
||||
this.containerParams = params
|
||||
return this
|
||||
}
|
||||
|
||||
WaveRunCmd withMounts(List<Path> path) {
|
||||
this.containerMounts = path
|
||||
return this
|
||||
}
|
||||
|
||||
WaveRunCmd withEnvironment(String... envs) {
|
||||
this.environment.addAll(envs)
|
||||
return this
|
||||
}
|
||||
|
||||
void runContainer(List<String> args) {
|
||||
if( !args )
|
||||
throw new AbortOperationException("Missing container image - usage: nextflow plugin nf-wave:run-container <image>")
|
||||
final image = args.pop()
|
||||
final target = resolveTargetImage(image)
|
||||
log.info "Resolved image: '$image' => '$target'"
|
||||
runContainer(target, args)
|
||||
}
|
||||
|
||||
void runContainer(String image, List<String> args=Collections.emptyList()) {
|
||||
final containerConfig = session.getContainerConfig()
|
||||
final containerBuilder = ContainerBuilder.create(containerConfig, image)
|
||||
.addMountWorkDir(false)
|
||||
.addRunOptions('--rm')
|
||||
.addMounts(containerMounts)
|
||||
.params(containerParams)
|
||||
|
||||
// add env variables
|
||||
environment.addAll( containerConfig.getEnvWhitelist() )
|
||||
for( String env : environment )
|
||||
containerBuilder.addEnv(env)
|
||||
|
||||
// assemble the final command
|
||||
final containerCmd = containerBuilder
|
||||
.build()
|
||||
.getRunCommand(args.join(' '))
|
||||
.replaceAll('-w "\\$NXF_TASK_WORKDIR" ','') // <-- hack to remove the PWD work dir
|
||||
|
||||
log.debug "Running: $containerCmd"
|
||||
final process = new ProcessBuilder()
|
||||
.command(['sh','-c',containerCmd])
|
||||
.directory(Path.of(".").toFile())
|
||||
.inheritIO()
|
||||
.start()
|
||||
|
||||
process.waitFor()
|
||||
}
|
||||
|
||||
protected String resolveTargetImage(String image) {
|
||||
final resp = new WaveClient(session).sendRequest(image)
|
||||
return resp?.targetImage
|
||||
}
|
||||
|
||||
void getContainer(List<String> args) {
|
||||
if( !args )
|
||||
throw new AbortOperationException("Missing container image - usage: nextflow plugin nf-wave:get-container <image>")
|
||||
final image = args.pop()
|
||||
final target = resolveTargetImage(image)
|
||||
log.info """\
|
||||
Source container: $image
|
||||
Waved container: $target""".stripIndent(true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright 2013-2026, Seqera Labs
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.seqera.wave.plugin.config
|
||||
|
||||
import groovy.transform.CompileStatic
|
||||
import groovy.transform.ToString
|
||||
import nextflow.config.spec.ConfigOption
|
||||
import nextflow.config.spec.ConfigScope
|
||||
import nextflow.script.dsl.Description
|
||||
import nextflow.util.Duration
|
||||
import nextflow.util.RateUnit
|
||||
|
||||
/**
|
||||
* Model the HTTP client settings to connect the Wave service
|
||||
*
|
||||
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
|
||||
*/
|
||||
@ToString(includeNames = true, includePackage = false)
|
||||
@CompileStatic
|
||||
class HttpOpts implements ConfigScope {
|
||||
|
||||
@ConfigOption
|
||||
@Description("""
|
||||
The connection timeout for the Wave HTTP client (default: `30s`).
|
||||
""")
|
||||
final private Duration connectTimeout
|
||||
|
||||
@ConfigOption
|
||||
@Description("""
|
||||
The maximum request rate for the Wave HTTP client (default: `1/sec`).
|
||||
""")
|
||||
final private RateUnit maxRate
|
||||
|
||||
HttpOpts(Map opts) {
|
||||
connectTimeout = opts.connectTimeout as Duration ?: Duration.of('30s')
|
||||
maxRate = opts.maxRate as RateUnit ?: RateUnit.of('1/sec')
|
||||
}
|
||||
|
||||
java.time.Duration connectTimeout() {
|
||||
return java.time.Duration.ofMillis(connectTimeout.toMillis())
|
||||
}
|
||||
|
||||
RateUnit maxRate() {
|
||||
return maxRate
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright 2013-2026, Seqera Labs
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.seqera.wave.plugin.config
|
||||
|
||||
import groovy.transform.CompileStatic
|
||||
import groovy.transform.ToString
|
||||
import nextflow.trace.TraceHelper
|
||||
|
||||
/**
|
||||
*
|
||||
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
|
||||
*/
|
||||
@ToString(includeNames = true, includePackage = false)
|
||||
@CompileStatic
|
||||
@Deprecated
|
||||
class ReportOpts {
|
||||
|
||||
final private Boolean enabled
|
||||
|
||||
final private String file
|
||||
|
||||
ReportOpts(Map opts) {
|
||||
this.enabled = opts.enabled as Boolean
|
||||
this.file = opts.file
|
||||
}
|
||||
|
||||
boolean enabled() {
|
||||
enabled || file != null
|
||||
}
|
||||
|
||||
String file() {
|
||||
if( file )
|
||||
return file
|
||||
return enabled
|
||||
? "containers-${TraceHelper.launchTimestampFmt()}.config"
|
||||
: null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* Copyright 2013-2026, Seqera Labs
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.seqera.wave.plugin.config
|
||||
|
||||
import groovy.transform.CompileStatic
|
||||
import groovy.transform.ToString
|
||||
import io.seqera.util.retry.Retryable
|
||||
import nextflow.config.spec.ConfigOption
|
||||
import nextflow.config.spec.ConfigScope
|
||||
import nextflow.script.dsl.Description
|
||||
import nextflow.util.Duration
|
||||
|
||||
/**
|
||||
* Model retry options for Wave http requests
|
||||
*/
|
||||
@ToString(includeNames = true, includePackage = false)
|
||||
@CompileStatic
|
||||
class RetryOpts implements ConfigScope, Retryable.Config {
|
||||
|
||||
@ConfigOption
|
||||
@Description("""
|
||||
The initial delay when a failing HTTP request is retried (default: `450ms`).
|
||||
""")
|
||||
Duration delay = Duration.of('450ms')
|
||||
|
||||
@ConfigOption
|
||||
@Description("""
|
||||
The max delay when a failing HTTP request is retried (default: `90s`).
|
||||
""")
|
||||
Duration maxDelay = Duration.of('90s')
|
||||
|
||||
@ConfigOption
|
||||
@Description("""
|
||||
The max number of attempts a failing HTTP request is retried (default: `5`).
|
||||
""")
|
||||
int maxAttempts = 5
|
||||
|
||||
@ConfigOption
|
||||
@Description("""
|
||||
The jitter factor used to randomly vary retry delays (default: `0.25`).
|
||||
""")
|
||||
double jitter = 0.25
|
||||
|
||||
@ConfigOption
|
||||
@Description("""
|
||||
The multiplier used for exponential backoff delay calculations (default: `2.0`)
|
||||
""")
|
||||
double multiplier = 2;
|
||||
|
||||
RetryOpts() {
|
||||
this(Collections.emptyMap())
|
||||
}
|
||||
|
||||
RetryOpts(Map config) {
|
||||
if( config.delay )
|
||||
delay = config.delay as Duration
|
||||
if( config.maxDelay )
|
||||
maxDelay = config.maxDelay as Duration
|
||||
if( config.maxAttempts )
|
||||
maxAttempts = config.maxAttempts as int
|
||||
if( config.jitter )
|
||||
jitter = config.jitter as double
|
||||
if( config.multiplier )
|
||||
multiplier = config.multiplier as double
|
||||
}
|
||||
|
||||
// Methods required by Retryable.Config interface
|
||||
@Override
|
||||
java.time.Duration getDelayAsDuration() {
|
||||
return java.time.Duration.ofMillis(delay.toMillis())
|
||||
}
|
||||
|
||||
@Override
|
||||
java.time.Duration getMaxDelayAsDuration() {
|
||||
return java.time.Duration.ofMillis(maxDelay.toMillis())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright 2013-2026, Seqera Labs
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.seqera.wave.plugin.config
|
||||
|
||||
import groovy.transform.CompileStatic
|
||||
import groovy.transform.ToString
|
||||
import nextflow.platform.PlatformHelper
|
||||
|
||||
/**
|
||||
* Model Tower config accessed by Wave
|
||||
*
|
||||
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
|
||||
*/
|
||||
@ToString(includeNames = true, includePackage = false)
|
||||
@CompileStatic
|
||||
class TowerConfig {
|
||||
|
||||
final String accessToken
|
||||
|
||||
final String refreshToken
|
||||
|
||||
final Long workspaceId
|
||||
|
||||
final String endpoint
|
||||
|
||||
final String workflowId
|
||||
|
||||
TowerConfig(Map opts, Map<String,String> env) {
|
||||
this.accessToken = PlatformHelper.getAccessToken(opts, env)
|
||||
this.refreshToken = PlatformHelper.getRefreshToken(opts, env)
|
||||
this.workspaceId = PlatformHelper.getWorkspaceId(opts, env) as Long
|
||||
this.endpoint = PlatformHelper.getEndpoint(opts, env)
|
||||
this.workflowId = env.get('TOWER_WORKFLOW_ID')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
/*
|
||||
* Copyright 2013-2026, Seqera Labs
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.seqera.wave.plugin.config
|
||||
|
||||
import groovy.transform.CompileStatic
|
||||
import groovy.transform.ToString
|
||||
import groovy.util.logging.Slf4j
|
||||
import io.seqera.wave.api.BuildCompression
|
||||
import io.seqera.wave.api.ScanLevel
|
||||
import io.seqera.wave.api.ScanMode
|
||||
import io.seqera.wave.config.CondaOpts
|
||||
import nextflow.config.spec.ConfigOption
|
||||
import nextflow.config.spec.ConfigScope
|
||||
import nextflow.config.spec.ScopeName
|
||||
import nextflow.script.dsl.Description
|
||||
import nextflow.file.FileHelper
|
||||
import nextflow.util.Duration
|
||||
/**
|
||||
* Model Wave client configuration
|
||||
*
|
||||
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
|
||||
*/
|
||||
@ScopeName("wave")
|
||||
@Description("""
|
||||
The `wave` scope provides advanced configuration for the use of [Wave containers](https://docs.seqera.io/wave).
|
||||
""")
|
||||
@Slf4j
|
||||
@ToString(includeNames = true, includePackage = false, includeFields = true, useGetters = false)
|
||||
@CompileStatic
|
||||
class WaveConfig implements ConfigScope {
|
||||
|
||||
final private static String DEF_ENDPOINT = 'https://wave.seqera.io'
|
||||
|
||||
final private static List<String> DEF_STRATEGIES = List.of('container','dockerfile','conda')
|
||||
|
||||
final BuildOpts build
|
||||
|
||||
@ConfigOption
|
||||
@Description("""
|
||||
Enable the use of Wave containers (default: `false`).
|
||||
""")
|
||||
final boolean enabled
|
||||
|
||||
@ConfigOption
|
||||
@Description("""
|
||||
The Wave service endpoint (default: `https://wave.seqera.io`).
|
||||
""")
|
||||
final String endpoint
|
||||
|
||||
@ConfigOption
|
||||
@Description("""
|
||||
Enable Wave container freezing (default: `false`). Wave will provision a non-ephemeral container image that will be pushed to a container repository of your choice.
|
||||
|
||||
See also: `wave.build.repository` and `wave.build.cacheRepository`
|
||||
""")
|
||||
final boolean freeze
|
||||
|
||||
final HttpOpts httpClient
|
||||
|
||||
@ConfigOption
|
||||
@Description("""
|
||||
Enable Wave container mirroring (default: `false`). Wave will mirror (i.e. copy) the containers in your pipeline to a container registry of your choice, so that pipeline tasks can pull the containers from this registry instead of the original one.
|
||||
|
||||
See also: `wave.build.repository`
|
||||
""")
|
||||
final boolean mirror
|
||||
|
||||
final RetryOpts retryPolicy
|
||||
|
||||
final ScanOpts scan
|
||||
|
||||
@ConfigOption(types=[String])
|
||||
@Description("""
|
||||
The strategy to be used when resolving multiple Wave container requirements (default: `'container,dockerfile,conda'`).
|
||||
""")
|
||||
final List<String> strategy
|
||||
|
||||
final private Boolean bundleProjectResources
|
||||
final private List<URL> containerConfigUrl
|
||||
final private Boolean preserveFileTimestamp
|
||||
final private Duration tokensCacheMaxDuration
|
||||
|
||||
/* required by extension point -- do not remove */
|
||||
WaveConfig() {}
|
||||
|
||||
WaveConfig(Map opts, Map<String,String> env=System.getenv()) {
|
||||
this.build = new BuildOpts(opts.build as Map ?: Collections.emptyMap())
|
||||
this.enabled = opts.enabled as boolean
|
||||
this.endpoint = (opts.endpoint?.toString() ?: env.get('WAVE_API_ENDPOINT') ?: DEF_ENDPOINT)?.stripEnd('/')
|
||||
this.freeze = opts.freeze as boolean
|
||||
this.httpClient = new HttpOpts(opts.httpClient as Map ?: Collections.emptyMap())
|
||||
this.mirror = opts.mirror as boolean
|
||||
this.retryPolicy = retryOpts0(opts)
|
||||
this.scan = new ScanOpts(opts.scan as Map ?: Collections.emptyMap())
|
||||
this.strategy = parseStrategy(opts.strategy)
|
||||
|
||||
this.bundleProjectResources = opts.bundleProjectResources
|
||||
this.containerConfigUrl = parseConfig(opts, env)
|
||||
this.preserveFileTimestamp = opts.preserveFileTimestamp as Boolean
|
||||
this.tokensCacheMaxDuration = opts.navigate('tokens.cache.maxDuration', '30m') as Duration
|
||||
|
||||
validateConfig()
|
||||
}
|
||||
|
||||
Boolean enabled() { this.enabled }
|
||||
|
||||
String endpoint() { this.endpoint }
|
||||
|
||||
CondaOpts condaOpts() { this.build.conda }
|
||||
|
||||
RetryOpts retryOpts() { this.retryPolicy }
|
||||
|
||||
HttpOpts httpOpts() { this.httpClient }
|
||||
|
||||
List<String> strategy() { this.strategy }
|
||||
|
||||
boolean freezeMode() { this.freeze }
|
||||
|
||||
boolean mirrorMode() { this.mirror }
|
||||
|
||||
boolean preserveFileTimestamp() { return this.preserveFileTimestamp }
|
||||
|
||||
boolean bundleProjectResources() { bundleProjectResources }
|
||||
|
||||
String buildRepository() { build.repository }
|
||||
|
||||
String cacheRepository() { build.cacheRepository }
|
||||
|
||||
String buildTemplate() { build.template }
|
||||
|
||||
Duration buildMaxDuration() { build.maxDuration }
|
||||
|
||||
BuildCompression buildCompression() { build.compression }
|
||||
|
||||
private void validateConfig() {
|
||||
def scheme= FileHelper.getUrlProtocol(endpoint)
|
||||
if( scheme !in ['http','https'] )
|
||||
throw new IllegalArgumentException("Endpoint URL should start with 'http:' or 'https:' protocol prefix - offending value: '$endpoint'")
|
||||
if( FileHelper.getUrlProtocol(build.repository) )
|
||||
throw new IllegalArgumentException("Config setting 'wave.build.repository' should not include any protocol prefix - offending value: '$build.repository'")
|
||||
if( FileHelper.getUrlProtocol(build.cacheRepository) )
|
||||
throw new IllegalArgumentException("Config setting 'wave.build.cacheRepository' should not include any protocol prefix - offending value: '$build.cacheRepository'")
|
||||
}
|
||||
|
||||
private RetryOpts retryOpts0(Map opts) {
|
||||
if( opts.retryPolicy )
|
||||
return new RetryOpts(opts.retryPolicy as Map)
|
||||
if( opts.retry ) {
|
||||
log.warn "Configuration options 'wave.retry' has been deprecated - replace it with 'wave.retryPolicy'"
|
||||
return new RetryOpts(opts.retry as Map)
|
||||
}
|
||||
return new RetryOpts(Collections.emptyMap())
|
||||
}
|
||||
|
||||
protected List<String> parseStrategy(value) {
|
||||
if( !value ) {
|
||||
log.debug "Wave strategy not specified - using default: $DEF_STRATEGIES"
|
||||
return DEF_STRATEGIES
|
||||
}
|
||||
List<String> result
|
||||
if( value instanceof CharSequence )
|
||||
result = value.tokenize(',') .collect(it -> it.toString().trim())
|
||||
else if( value instanceof List )
|
||||
result = value.collect(it -> it.toString().trim())
|
||||
else
|
||||
throw new IllegalArgumentException("Invalid value for 'wave.strategy' configuration attribute - offending value: $value")
|
||||
for( String it : result ) {
|
||||
if( it !in DEF_STRATEGIES)
|
||||
throw new IllegalArgumentException("Invalid value for 'wave.strategy' configuration attribute - offending value: $it")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
protected List<URL> parseConfig(Map opts, Map<String,String> env) {
|
||||
List<String> result = new ArrayList<>(10)
|
||||
if( !opts.containerConfigUrl && env.get('WAVE_CONTAINER_CONFIG_URL') ) {
|
||||
result.add(checkUrl(env.get('WAVE_CONTAINER_CONFIG_URL')))
|
||||
}
|
||||
else if( opts.containerConfigUrl instanceof CharSequence ) {
|
||||
result.add(checkUrl(opts.containerConfigUrl.toString()))
|
||||
}
|
||||
else if( opts.containerConfigUrl instanceof List ) {
|
||||
for( def it : opts.containerConfigUrl ) {
|
||||
result.add(checkUrl(it.toString()))
|
||||
}
|
||||
}
|
||||
|
||||
return result.collect(it -> new URL(it))
|
||||
}
|
||||
|
||||
private String checkUrl(String value) {
|
||||
if( value && (!value.startsWith('http://') && !value.startsWith('https://')))
|
||||
throw new IllegalArgumentException("Wave container config URL should start with 'http:' or 'https:' protocol prefix - offending value: $value")
|
||||
return value
|
||||
}
|
||||
|
||||
List<URL> containerConfigUrl() {
|
||||
return containerConfigUrl ?: Collections.<URL>emptyList()
|
||||
}
|
||||
|
||||
Duration tokensCacheMaxDuration() {
|
||||
return tokensCacheMaxDuration
|
||||
}
|
||||
|
||||
ScanMode scanMode() {
|
||||
return scan.mode
|
||||
}
|
||||
|
||||
List<ScanLevel> scanAllowedLevels() {
|
||||
return scan.allowedLevels
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ToString(includeNames = true, includePackage = false, includeFields = true)
|
||||
@CompileStatic
|
||||
class ScanOpts implements ConfigScope {
|
||||
|
||||
@ConfigOption(types=[String])
|
||||
@Description("""
|
||||
Comma-separated list of allowed vulnerability levels when scanning containers for security vulnerabilities in `required` mode.
|
||||
""")
|
||||
final List<ScanLevel> allowedLevels
|
||||
|
||||
@ConfigOption(types=[String])
|
||||
@Description("""
|
||||
Enable Wave container security scanning. Wave will scan the containers in your pipeline for security vulnerabilities.
|
||||
""")
|
||||
final ScanMode mode
|
||||
|
||||
ScanOpts(Map opts) {
|
||||
allowedLevels = parseScanLevels(opts.allowedLevels)
|
||||
mode = opts.mode as ScanMode
|
||||
}
|
||||
|
||||
protected List<ScanLevel> parseScanLevels(value) {
|
||||
if( !value )
|
||||
return null
|
||||
if( value instanceof CharSequence ) {
|
||||
final str = value.toString()
|
||||
value = str.tokenize(',').collect(it->it.trim())
|
||||
}
|
||||
if( value instanceof List ) {
|
||||
return (value as List).collect(it-> ScanLevel.valueOf(it.toString().toUpperCase()))
|
||||
}
|
||||
throw new IllegalArgumentException("Invalid value for 'wave.scan.levels' setting - offending value: $value; type: ${value.getClass().getName()}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ToString(includeNames = true, includePackage = false, includeFields = true)
|
||||
@CompileStatic
|
||||
class BuildOpts implements ConfigScope {
|
||||
|
||||
@ConfigOption
|
||||
@Description("""
|
||||
The container repository where images built by Wave are uploaded.
|
||||
""")
|
||||
final String repository
|
||||
|
||||
@ConfigOption
|
||||
@Description("""
|
||||
The container repository used to cache image layers built by the Wave service.
|
||||
""")
|
||||
final String cacheRepository
|
||||
|
||||
@ConfigOption
|
||||
@Description("""
|
||||
The build template to use for container builds. Supported values: `conda/pixi:v1` (Pixi with multi-stage builds), `conda/micromamba:v2` (Micromamba 2.x with multi-stage builds), `cran/installr:v1` (R/CRAN packages). Default: standard conda/micromamba:v1 template.
|
||||
""")
|
||||
final String template
|
||||
|
||||
final CondaOpts conda
|
||||
|
||||
final BuildCompression compression
|
||||
|
||||
@ConfigOption
|
||||
@Description("""
|
||||
""")
|
||||
final Duration maxDuration
|
||||
|
||||
BuildOpts(Map opts) {
|
||||
repository = opts.repository
|
||||
cacheRepository = opts.cacheRepository
|
||||
template = opts.template
|
||||
conda = new CondaOpts(opts.conda as Map ?: Collections.emptyMap())
|
||||
compression = parseCompression(opts.compression as Map)
|
||||
maxDuration = opts.maxDuration as Duration ?: Duration.of('40m')
|
||||
}
|
||||
|
||||
protected BuildCompression parseCompression(Map opts) {
|
||||
if( !opts )
|
||||
return null
|
||||
final result = new BuildCompression()
|
||||
if( opts.mode )
|
||||
result.mode = BuildCompression.Mode.valueOf(opts.mode.toString().toLowerCase())
|
||||
if( opts.level )
|
||||
result.level = opts.level as Integer
|
||||
if( opts.force )
|
||||
result.force = opts.force as Boolean
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright 2013-2026, Seqera Labs
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.seqera.wave.plugin.exception
|
||||
|
||||
import groovy.transform.InheritConstructors
|
||||
|
||||
/**
|
||||
* Model an invalid HTTP response
|
||||
*
|
||||
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
|
||||
*/
|
||||
@InheritConstructors
|
||||
class BadResponseException extends RuntimeException {
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright 2013-2026, Seqera Labs
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.seqera.wave.plugin.exception
|
||||
|
||||
import groovy.transform.InheritConstructors
|
||||
|
||||
/**
|
||||
* Unauthorized access exception
|
||||
*
|
||||
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
|
||||
*/
|
||||
@InheritConstructors
|
||||
class UnauthorizedException extends RuntimeException {
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
/*
|
||||
* Copyright 2013-2026, Seqera Labs
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.seqera.wave.plugin.packer
|
||||
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.attribute.BasicFileAttributes
|
||||
import java.nio.file.attribute.FileTime
|
||||
|
||||
import groovy.transform.CompileStatic
|
||||
import groovy.util.logging.Slf4j
|
||||
import io.seqera.wave.plugin.ContainerLayer
|
||||
import io.seqera.wave.plugin.util.DigestFunctions
|
||||
import nextflow.file.FileHelper
|
||||
import org.apache.commons.compress.archivers.tar.TarArchiveEntry
|
||||
import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream
|
||||
import org.apache.commons.compress.compressors.gzip.GzipCompressorOutputStream
|
||||
|
||||
/**
|
||||
* Utility class to create container layer packages
|
||||
*
|
||||
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
|
||||
*/
|
||||
@Slf4j
|
||||
@CompileStatic
|
||||
class Packer {
|
||||
|
||||
/**
|
||||
* See {@link TarArchiveEntry#DEFAULT_DIR_MODE}
|
||||
*/
|
||||
private static final int DIR_MODE = 040000;
|
||||
|
||||
/**
|
||||
* See {@link TarArchiveEntry#DEFAULT_FILE_MODE}
|
||||
*/
|
||||
private static final int FILE_MODE = 0100000;
|
||||
|
||||
/**
|
||||
* Timestamp when {@link #preserveFileTimestamp} is false
|
||||
*/
|
||||
private static final FileTime ZERO = FileTime.fromMillis(0)
|
||||
|
||||
/**
|
||||
* Whenever the timestamp of compressed files should be preserved.
|
||||
* By default it is used the timestamp ZERO to guarantee reproducible builds
|
||||
*/
|
||||
private boolean preserveFileTimestamp
|
||||
|
||||
Packer withPreserveTimestamp(boolean value) {
|
||||
this.preserveFileTimestamp = value
|
||||
return this
|
||||
}
|
||||
|
||||
def <T extends OutputStream> T makeTar(Path root, List<Path> files, T target) {
|
||||
final entries = new HashMap<String,Path>()
|
||||
for( Path it : files ) {
|
||||
final name = root.relativize(it).toString()
|
||||
entries.put(name, it)
|
||||
}
|
||||
return makeTar(entries, target)
|
||||
}
|
||||
|
||||
def <T extends OutputStream> T makeTar(Map<String,Path> entries, T target) {
|
||||
try ( final archive = new TarArchiveOutputStream(target) ) {
|
||||
final sorted = new TreeSet<>(entries.keySet())
|
||||
for (String name : sorted ) {
|
||||
final targetPath = entries.get(name)
|
||||
final ftime = preserveFileTimestamp
|
||||
? Files.readAttributes(targetPath, BasicFileAttributes.class).lastModifiedTime()
|
||||
: ZERO
|
||||
final entry = new TarArchiveEntry(targetPath, name)
|
||||
entry.setIds(0,0)
|
||||
entry.setGroupName("root")
|
||||
entry.setUserName("root")
|
||||
entry.setModTime(ftime)
|
||||
entry.setMode(getMode(targetPath))
|
||||
// file permissions
|
||||
archive.putArchiveEntry(entry)
|
||||
if( !targetPath.isDirectory()) {
|
||||
Files.copy(targetPath, archive)
|
||||
}
|
||||
archive.closeArchiveEntry()
|
||||
}
|
||||
archive.finish()
|
||||
}
|
||||
|
||||
return target
|
||||
}
|
||||
|
||||
private int getMode(Path path) {
|
||||
final mode = path.isDirectory() ? DIR_MODE : FILE_MODE
|
||||
return mode + path.getPermissionsMode()
|
||||
}
|
||||
|
||||
protected <T extends OutputStream> T makeGzip(InputStream source, T target) {
|
||||
try (final compressed = new GzipCompressorOutputStream(target)) {
|
||||
source.transferTo(compressed)
|
||||
compressed.flush()
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
ContainerLayer layer(Path root, List<Path> files) {
|
||||
final Map<String,Path> entries = new HashMap<>()
|
||||
for( Path it : files )
|
||||
entries.put(root.relativize(it).toString(), it)
|
||||
return layer(entries)
|
||||
}
|
||||
|
||||
ContainerLayer layer(Map<String,Path> entries) {
|
||||
final tar = makeTar(entries, new ByteArrayOutputStream()).toByteArray()
|
||||
final tarDigest = DigestFunctions.digest(tar)
|
||||
final gzipStream = new ByteArrayOutputStream()
|
||||
makeGzip(new ByteArrayInputStream(tar), gzipStream); gzipStream.close()
|
||||
final gzipBytes = gzipStream.toByteArray()
|
||||
final gzipSize = gzipBytes.length
|
||||
final gzipDigest = DigestFunctions.digest(gzipBytes)
|
||||
final data = 'data:' + gzipBytes.encodeBase64()
|
||||
|
||||
return new ContainerLayer(
|
||||
location: data,
|
||||
tarDigest: tarDigest,
|
||||
gzipSize: gzipSize,
|
||||
gzipDigest: gzipDigest )
|
||||
}
|
||||
|
||||
ContainerLayer createContainerPack(Path root, String targetName) {
|
||||
final opts = [type: 'any', hidden: true, relative: false]
|
||||
final files = new ArrayList(100)
|
||||
FileHelper.visitFiles(opts, root, '**') { files.add(it) }
|
||||
return createContainerPack0(root, files, targetName)
|
||||
}
|
||||
|
||||
ContainerLayer createContainerPack0(Path root, List<Path> files, String targetName=null) {
|
||||
final name = targetName ?: root.getName()
|
||||
// create the tar file
|
||||
final tarFilePath = root.resolveSibling(name + '.tar')
|
||||
makeTar(root, files, new FileOutputStream(tarFilePath.toFile()))
|
||||
.close()
|
||||
|
||||
// compute the tar digest
|
||||
final tarDigest = DigestFunctions.digest(tarFilePath)
|
||||
|
||||
// target gzipped file
|
||||
final gzipFilePath = root.resolveSibling(name + '.tar.gz')
|
||||
makeGzip(tarFilePath.newInputStream(), new FileOutputStream(gzipFilePath.toFile()))
|
||||
.close()
|
||||
final gzipSize = Files.size(gzipFilePath)
|
||||
|
||||
// compute gzip digest
|
||||
final gzipDigest = DigestFunctions.digest(gzipFilePath)
|
||||
|
||||
// remove tar file
|
||||
tarFilePath.delete()
|
||||
|
||||
// finally return the expected
|
||||
return new ContainerLayer(
|
||||
location: gzipFilePath.toUri().toString(),
|
||||
tarDigest: tarDigest,
|
||||
gzipSize: gzipSize,
|
||||
gzipDigest: gzipDigest )
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
* Copyright 2013-2026, Seqera Labs
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.seqera.wave.plugin.resolver
|
||||
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
|
||||
import groovy.transform.CompileStatic
|
||||
import groovy.util.logging.Slf4j
|
||||
import io.seqera.wave.plugin.WaveClient
|
||||
import io.seqera.wave.plugin.WaveFactory
|
||||
import nextflow.Global
|
||||
import nextflow.Session
|
||||
import nextflow.container.ContainerConfig
|
||||
import nextflow.container.resolver.ContainerInfo
|
||||
import nextflow.container.resolver.ContainerMeta
|
||||
import nextflow.container.resolver.ContainerResolver
|
||||
import nextflow.container.resolver.DefaultContainerResolver
|
||||
import nextflow.plugin.Priority
|
||||
import nextflow.processor.TaskRun
|
||||
import nextflow.util.SysHelper
|
||||
|
||||
/**
|
||||
* Implement Wave container resolve logic
|
||||
*
|
||||
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
|
||||
*/
|
||||
@Slf4j
|
||||
@CompileStatic
|
||||
@Priority(-10) // <-- lower is higher, this is needed to override default provider behavior
|
||||
class WaveContainerResolver implements ContainerResolver {
|
||||
|
||||
private DefaultContainerResolver defaultResolver = new DefaultContainerResolver()
|
||||
static final private List<String> DOCKER_LIKE = ['docker','podman','sarus','apple-container']
|
||||
static final private List<String> SINGULARITY_LIKE = ['singularity','apptainer']
|
||||
static final private String DOCKER_PREFIX = 'docker://'
|
||||
private WaveClient client0
|
||||
|
||||
synchronized protected WaveClient client() {
|
||||
if( client0 )
|
||||
return client0
|
||||
return client0 = new WaveClient( Global.session as Session )
|
||||
}
|
||||
|
||||
private String getContainerEngine0(ContainerConfig config) {
|
||||
final result = config.isEnabled() ? config.getEngine() : 'docker'
|
||||
if( result )
|
||||
return result
|
||||
// fallback to docker by default
|
||||
log.warn "Missing engine in container config - offending value: $config"
|
||||
return 'docker'
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
boolean enabled() {
|
||||
return WaveFactory.shouldEnable(Global.session as Session)
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
String defaultContainerPlatform() {
|
||||
return SysHelper.DEFAULT_DOCKER_PLATFORM
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
ContainerInfo resolveImage(TaskRun task, String imageName) {
|
||||
final freeze = client().config().freezeMode()
|
||||
final config = task.getContainerConfig()
|
||||
final engine = getContainerEngine0(config)
|
||||
final singularitySpec = freeze && engine in SINGULARITY_LIKE && !config.canRunOciImage()
|
||||
|
||||
if( engine in DOCKER_LIKE ) {
|
||||
// find out the configured image name applying the default resolver
|
||||
if( imageName )
|
||||
imageName = defaultResolver.resolveImage(task, imageName).getTarget()
|
||||
// fetch the wave container image name
|
||||
return waveContainer(task, imageName, false)
|
||||
}
|
||||
else if( engine in SINGULARITY_LIKE ) {
|
||||
// remove any `docker://` prefix if any
|
||||
if( imageName && imageName.startsWith(DOCKER_PREFIX) )
|
||||
imageName = imageName.substring(DOCKER_PREFIX.length())
|
||||
// singularity file image use the default resolver
|
||||
else if( imageName && (imageName.startsWith('/') || imageName.startsWith('file://') || Files.exists(Path.of(imageName)))) {
|
||||
return defaultResolver.resolveImage(task, imageName)
|
||||
}
|
||||
// fetch the wave container image name
|
||||
final image = waveContainer(task, imageName, singularitySpec)
|
||||
// when wave returns no info, just default to standard behaviour
|
||||
if( !image ) {
|
||||
return defaultResolver.resolveImage(task, imageName)
|
||||
}
|
||||
// oras prefixed container are served directly
|
||||
if( image.target.startsWith("oras://") )
|
||||
return image
|
||||
// otherwise adapt it to singularity format using the target containerInfo to avoid the cache invalidation
|
||||
return defaultResolver.resolveImage(task, image.target, image.hashKey)
|
||||
}
|
||||
else
|
||||
throw new IllegalArgumentException("Wave does not support '$engine' container engine")
|
||||
}
|
||||
|
||||
/**
|
||||
* Given the target {@link TaskRun} and container image name
|
||||
* creates a {@link io.seqera.wave.plugin.WaveAssets} object which holds
|
||||
* the corresponding resources to submit container request to the Wave backend
|
||||
*
|
||||
* @param task
|
||||
* An instance of {@link TaskRun} task representing the current task
|
||||
* @param container
|
||||
* The container image name specified by the task. Can be {@code null} if the task
|
||||
* provides a Dockerfile or a Conda recipe
|
||||
* @return
|
||||
* The container image name returned by the Wave backend or {@code null}
|
||||
* when the task does not request any container or dockerfile to build
|
||||
*/
|
||||
protected ContainerInfo waveContainer(TaskRun task, String container, boolean singularity) {
|
||||
final assets = client().resolveAssets(task, container, singularity)
|
||||
if( assets ) {
|
||||
return client().fetchContainerImage(assets)
|
||||
}
|
||||
// no container and no dockerfile, wave cannot do anything
|
||||
log.trace "No container image or build recipe defined for task ${task.processor.name}"
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
boolean isContainerReady(String key) {
|
||||
return client().isContainerReady(key)
|
||||
}
|
||||
|
||||
@Override
|
||||
ContainerMeta getContainerMeta(String key) {
|
||||
return client().getContainerMeta(key)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright 2013-2026, Seqera Labs
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.seqera.wave.plugin.util
|
||||
|
||||
import groovy.transform.Canonical
|
||||
import groovy.transform.CompileStatic
|
||||
|
||||
/**
|
||||
* Model a CLI option defined a key-value pairs
|
||||
*
|
||||
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
|
||||
*/
|
||||
@Canonical
|
||||
@CompileStatic
|
||||
class BasicCliOpts implements CliOpts {
|
||||
|
||||
Map<String,String> options = new LinkedHashMap<>()
|
||||
List<String> args = new ArrayList<>()
|
||||
|
||||
static BasicCliOpts parse(List<String> cli) {
|
||||
final result = new BasicCliOpts()
|
||||
if( !cli )
|
||||
return result
|
||||
for( String item : cli ) {
|
||||
final p = item.indexOf('=')
|
||||
if( p!=-1 ) {
|
||||
result.options.put( item.substring(0,p), item.substring(p+1) )
|
||||
}
|
||||
else {
|
||||
result.args.add(item)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright 2013-2026, Seqera Labs
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.seqera.wave.plugin.util
|
||||
|
||||
/**
|
||||
* Simple CLI args & options interface
|
||||
*
|
||||
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
|
||||
*/
|
||||
interface CliOpts {
|
||||
|
||||
Map<String,String> getOptions()
|
||||
|
||||
List<String> getArgs()
|
||||
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* Copyright 2013-2026, Seqera Labs
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.seqera.wave.plugin.util;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Random;
|
||||
|
||||
import com.google.common.io.BaseEncoding;
|
||||
|
||||
/**
|
||||
* @author : jorge <jorge.aguilera@seqera.io>
|
||||
**/
|
||||
public class DigestFunctions {
|
||||
|
||||
final private static char PADDING = '_';
|
||||
final private static BaseEncoding BASE32 = BaseEncoding.base32() .withPadChar(PADDING);
|
||||
|
||||
private static MessageDigest getSha256() throws NoSuchAlgorithmException {
|
||||
return MessageDigest.getInstance("SHA-256");
|
||||
}
|
||||
|
||||
public static String bytesToHex(byte[] bytes) {
|
||||
StringBuffer result = new StringBuffer();
|
||||
for (byte byt : bytes) result.append(Integer.toString((byt & 0xff) + 0x100, 16).substring(1));
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
public static String digest(String str) throws NoSuchAlgorithmException {
|
||||
return digest(str.getBytes());
|
||||
}
|
||||
|
||||
public static String digest(byte[] bytes) throws NoSuchAlgorithmException {
|
||||
final byte[] digest = getSha256().digest(bytes);
|
||||
return "sha256:"+bytesToHex(digest);
|
||||
}
|
||||
|
||||
public static String digest(File file) throws IOException, NoSuchAlgorithmException {
|
||||
final byte[] digest = getSha256().digest(Files.readAllBytes(Path.of(file.toURI())));
|
||||
return "sha256:"+bytesToHex(digest);
|
||||
}
|
||||
|
||||
public static String digest(Path path) throws IOException, NoSuchAlgorithmException {
|
||||
final byte[] digest = getSha256().digest(Files.readAllBytes(path));
|
||||
return "sha256:"+bytesToHex(digest);
|
||||
}
|
||||
|
||||
public static String encodeBase32(String str, boolean padding) {
|
||||
final String result = BASE32.encode(str.getBytes()).toLowerCase();
|
||||
if( padding )
|
||||
return result;
|
||||
final int p = result.indexOf(PADDING);
|
||||
return p == -1 ? result : result.substring(0,p);
|
||||
}
|
||||
|
||||
public static String decodeBase32(String encoded) {
|
||||
final byte[] result = BASE32.decode(encoded.toUpperCase());
|
||||
return new String(result);
|
||||
}
|
||||
|
||||
public static String randomString(int len) {
|
||||
byte[] array = new byte[len];
|
||||
new Random().nextBytes(array);
|
||||
return new String(array, Charset.forName("UTF-8"));
|
||||
}
|
||||
|
||||
public static String randomString(int min, int max) {
|
||||
Random random = new Random();
|
||||
final int len = random.nextInt(max - min) + min;
|
||||
return randomString(len);
|
||||
}
|
||||
|
||||
public static String random256Hex() {
|
||||
final SecureRandom secureRandom = new SecureRandom();
|
||||
byte[] token = new byte[32];
|
||||
secureRandom.nextBytes(token);
|
||||
String result = new BigInteger(1, token).toString(16);
|
||||
// pad with extra zeros if necessary
|
||||
while( result.length()<64 )
|
||||
result = '0'+result;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright 2013-2026, Seqera Labs
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.seqera.wave.plugin.util
|
||||
|
||||
import groovy.transform.CompileStatic
|
||||
import groovy.transform.EqualsAndHashCode
|
||||
import groovy.transform.ToString
|
||||
|
||||
/**
|
||||
* Base CLI options parsed
|
||||
*
|
||||
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
|
||||
*/
|
||||
@ToString(includeNames = true, includePackage = false)
|
||||
@EqualsAndHashCode
|
||||
@CompileStatic
|
||||
class GnuCliOpts implements CliOpts {
|
||||
Map<String,String> options = new LinkedHashMap<>()
|
||||
List<String> args = new ArrayList<>()
|
||||
String container
|
||||
|
||||
static GnuCliOpts parse(List<String> cli) {
|
||||
final result = new GnuCliOpts()
|
||||
|
||||
while( cli.size() ) {
|
||||
final name = cli.pop()
|
||||
if( name=='--' ) {
|
||||
result.args.addAll(cli)
|
||||
break
|
||||
}
|
||||
else if( name.startsWith('-') ) {
|
||||
if( cli[0] && !cli[0].startsWith('-') ) {
|
||||
final value = cli.pop()
|
||||
result.options.put(name, value)
|
||||
}
|
||||
else {
|
||||
result.options.put(name, '')
|
||||
}
|
||||
}
|
||||
else {
|
||||
result.container = name
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/*
|
||||
* Copyright 2013-2026, Seqera Labs
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.seqera.wave.plugin
|
||||
|
||||
import spock.lang.Specification
|
||||
import spock.lang.Unroll
|
||||
|
||||
/**
|
||||
*
|
||||
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
|
||||
*/
|
||||
class ContainerConfigTest extends Specification {
|
||||
|
||||
@Unroll
|
||||
def 'should merge env' () {
|
||||
given:
|
||||
def config = new ContainerConfig()
|
||||
|
||||
expect:
|
||||
config.mergeEnv(ENV1, ENV2) == EXPECTED
|
||||
|
||||
where:
|
||||
ENV1 | ENV2 | EXPECTED
|
||||
null | null | null
|
||||
[] | null | []
|
||||
['foo=1'] | [] | ['foo=1']
|
||||
['foo=1'] | ['bar=2'] | ['foo=1','bar=2']
|
||||
['foo=1'] | ['foo=2'] | ['foo=2'] // <-- env2 overrides env1
|
||||
['foo=1','baz=3'] | ['foo=2'] | ['foo=2','baz=3'] // <-- overrides 'foo' in env1 and keep as first entry
|
||||
['foo=1'] | ['baz=3','foo=2'] | ['foo=2','baz=3'] // <-- overrides 'foo' in env1 and keep as first entry
|
||||
}
|
||||
|
||||
def 'should override entry' () {
|
||||
expect:
|
||||
new ContainerConfig(entrypoint: LEFT) + new ContainerConfig(entrypoint: RIGHT)
|
||||
== new ContainerConfig(entrypoint: EXPECTED)
|
||||
|
||||
where:
|
||||
LEFT | RIGHT | EXPECTED
|
||||
null | null | null
|
||||
['entry1.sh'] | null | ['entry1.sh']
|
||||
null | ['entry2.sh'] | ['entry2.sh']
|
||||
['entry1.sh'] | ['entry2.sh'] | ['entry2.sh']
|
||||
}
|
||||
|
||||
def 'should override cmd' () {
|
||||
expect:
|
||||
new ContainerConfig(cmd: LEFT) + new ContainerConfig(cmd: RIGHT)
|
||||
== new ContainerConfig(cmd: EXPECTED)
|
||||
where:
|
||||
LEFT | RIGHT | EXPECTED
|
||||
null | null | null
|
||||
['cmd1.sh'] | null | ['cmd1.sh']
|
||||
null | ['cmd2.sh'] | ['cmd2.sh']
|
||||
['cmd1.sh'] | ['cmd2.sh'] | ['cmd2.sh']
|
||||
|
||||
}
|
||||
|
||||
def 'should override workdir' () {
|
||||
expect:
|
||||
new ContainerConfig(workingDir: LEFT) + new ContainerConfig(workingDir: RIGHT)
|
||||
== new ContainerConfig(workingDir: EXPECTED)
|
||||
|
||||
where:
|
||||
LEFT | RIGHT | EXPECTED
|
||||
null | null | null
|
||||
'/foo' | null | '/foo'
|
||||
null | '/bar' | '/bar'
|
||||
'/foo' | '/bar' | '/bar'
|
||||
|
||||
}
|
||||
|
||||
def 'should merge env config' () {
|
||||
expect:
|
||||
new ContainerConfig(env:LEFT) + new ContainerConfig(env: RIGHT) == new ContainerConfig(env: EXPECTED)
|
||||
|
||||
where:
|
||||
LEFT | RIGHT | EXPECTED
|
||||
null | null | null
|
||||
['alpha=1'] | null | ['alpha=1']
|
||||
null | ['beta=2'] | ['beta=2']
|
||||
['alpha=1','delta=x'] | ['beta=2','delta=z'] | ['alpha=1','delta=z','beta=2']
|
||||
}
|
||||
|
||||
@Unroll
|
||||
def 'should merge layers' () {
|
||||
|
||||
expect:
|
||||
new ContainerConfig(layers:LEFT) + new ContainerConfig(layers: RIGHT) == new ContainerConfig(layers: EXPECTED)
|
||||
|
||||
where:
|
||||
LEFT | RIGHT | EXPECTED
|
||||
null | null | null
|
||||
[] | [] | []
|
||||
[new ContainerLayer(location: 'http://x')] | null | [new ContainerLayer(location: 'http://x')]
|
||||
and:
|
||||
[new ContainerLayer(location: 'http://x')] | [new ContainerLayer(location: 'http://y'),new ContainerLayer(location: 'http://y')] | [new ContainerLayer(location: 'http://x'), new ContainerLayer(location: 'http://y'),new ContainerLayer(location: 'http://y')]
|
||||
}
|
||||
|
||||
def 'should compute config fingerprint' () {
|
||||
given:
|
||||
def config1 = new ContainerConfig(entrypoint: ['entry.sh'], cmd: ['the.cmd'], env: ['x=2'], workingDir: '/foo')
|
||||
def config2 = new ContainerConfig(entrypoint: ['entry.sh'], cmd: ['the.cmd'], env: ['x=2'], workingDir: '/foo')
|
||||
def config3 = new ContainerConfig(entrypoint: ['entry.sh'], cmd: ['the.cmd'], env: ['x=2'], workingDir: '/bar')
|
||||
and:
|
||||
def layer1 = new ContainerLayer('http://this/that', 'abd', 100, 'efg')
|
||||
def layer2 = new ContainerLayer('http://this/that', 'abd', 100, 'efg')
|
||||
def layer3 = new ContainerLayer('http://xxx/yyy', 'abd', 200, 'efg')
|
||||
and:
|
||||
def fusion1 = new ContainerLayer('abc', 'xyz', 300, 'efg', true)
|
||||
def fusion2 = new ContainerLayer('efg', 'pqr', 400, 'efg', true)
|
||||
|
||||
expect:
|
||||
config1.fingerprint() == config2.fingerprint()
|
||||
config1.fingerprint() != config3.fingerprint()
|
||||
|
||||
when:
|
||||
config1.appendLayer(layer1)
|
||||
config2.appendLayer(layer2)
|
||||
then:
|
||||
// layers have the same fingerprint
|
||||
layer1.fingerprint() == layer2.fingerprint()
|
||||
// configs have the same fingerprint
|
||||
config1.fingerprint() == config2.fingerprint()
|
||||
|
||||
when:
|
||||
config1.layers.clear()
|
||||
config2.layers.clear()
|
||||
and:
|
||||
config1.appendLayer(layer1)
|
||||
config2.appendLayer(layer3)
|
||||
then:
|
||||
// different layer fingerprint cause the config to have different fingerprints
|
||||
layer1.fingerprint() != layer3.fingerprint()
|
||||
config1.fingerprint() != config2.fingerprint()
|
||||
|
||||
when:
|
||||
config1.layers.clear()
|
||||
config2.layers.clear()
|
||||
and:
|
||||
config1.appendLayer(fusion1)
|
||||
config2.appendLayer(fusion2)
|
||||
then:
|
||||
// the layer have different fingerprint BUT the `skipHashing`
|
||||
// makes the config to ignore it
|
||||
fusion1.fingerprint() != fusion2.fingerprint()
|
||||
config1.fingerprint() == config2.fingerprint()
|
||||
}
|
||||
|
||||
def 'should validate empty' () {
|
||||
expect:
|
||||
new ContainerConfig().empty()
|
||||
new ContainerConfig([], null, null, null, null).empty()
|
||||
new ContainerConfig(null, [], null, null, null).empty()
|
||||
new ContainerConfig(null, null, [], null, null).empty()
|
||||
new ContainerConfig(null, null, null, '', null).empty()
|
||||
new ContainerConfig(null, null, null, null, []).empty()
|
||||
and:
|
||||
!new ContainerConfig(['x'], null, null, null, null).empty()
|
||||
!new ContainerConfig(null, ['x'], null, null, null).empty()
|
||||
!new ContainerConfig(null, null, ['x'], null, null).empty()
|
||||
!new ContainerConfig(null, null, null, 'x', null).empty()
|
||||
!new ContainerConfig(null, null, null, null, [new ContainerLayer()]).empty()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright 2013-2026, Seqera Labs
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.seqera.wave.plugin
|
||||
|
||||
import spock.lang.Specification
|
||||
|
||||
/**
|
||||
*
|
||||
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
|
||||
*/
|
||||
class ContainerLayerTest extends Specification {
|
||||
|
||||
def 'should convert to string' () {
|
||||
when:
|
||||
def l1 = new ContainerLayer( 'data:ABC1234567890', 'sha256:12345', 100, 'sha256:67890' )
|
||||
then:
|
||||
l1.toString() == 'ContainerLayer[location=data:ABC1234567890; tarDigest=sha256:67890; gzipDigest=sha256:12345; gzipSize=100]'
|
||||
|
||||
when:
|
||||
def l2 = new ContainerLayer( 'data:12345678901234567890', 'sha256:12345', 100, 'sha256:67890' )
|
||||
then:
|
||||
l2.toString() == 'ContainerLayer[location=data:12345678901234567890; tarDigest=sha256:67890; gzipDigest=sha256:12345; gzipSize=100]'
|
||||
|
||||
when:
|
||||
def l3= new ContainerLayer( 'data:12345678901234567890x', 'sha256:12345', 100, 'sha256:67890' )
|
||||
then:
|
||||
l3.toString() == 'ContainerLayer[location=data:12345678901234567890...; tarDigest=sha256:67890; gzipDigest=sha256:12345; gzipSize=100]'
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright 2013-2026, Seqera Labs
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.seqera.wave.plugin
|
||||
|
||||
|
||||
import nextflow.script.bundle.ResourcesBundle
|
||||
import nextflow.util.CacheHelper
|
||||
import spock.lang.Specification
|
||||
/**
|
||||
*
|
||||
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
|
||||
*/
|
||||
class WaveAssetsTest extends Specification {
|
||||
|
||||
def 'should compute hash key' () {
|
||||
given:
|
||||
def IMAGE = 'foo:latest'
|
||||
def BUNDLE = Mock(ResourcesBundle) { fingerprint() >> '12345' }
|
||||
|
||||
expect:
|
||||
new WaveAssets(IMAGE).fingerprint() == CacheHelper.hasher([IMAGE]).hash().toString()
|
||||
|
||||
}
|
||||
|
||||
def 'should validate container name' () {
|
||||
when:
|
||||
WaveAssets.validateContainerName('ubuntu')
|
||||
then:
|
||||
noExceptionThrown()
|
||||
|
||||
when:
|
||||
WaveAssets.validateContainerName('ubuntu:latest')
|
||||
then:
|
||||
noExceptionThrown()
|
||||
|
||||
when:
|
||||
WaveAssets.validateContainerName('quay.io/wtsicgp/nanoseq:3.3.0')
|
||||
then:
|
||||
noExceptionThrown()
|
||||
|
||||
when:
|
||||
WaveAssets.validateContainerName('docker://quay.io/wtsicgp/nanoseq:3.3.0')
|
||||
then:
|
||||
thrown(IllegalArgumentException)
|
||||
}
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* Copyright 2013-2026, Seqera Labs
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.seqera.wave.plugin
|
||||
|
||||
import nextflow.Session
|
||||
import nextflow.SysEnv
|
||||
import nextflow.exception.AbortOperationException
|
||||
import spock.lang.Specification
|
||||
import spock.lang.Unroll
|
||||
|
||||
/**
|
||||
*
|
||||
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
|
||||
*/
|
||||
class WaveFactoryTest extends Specification {
|
||||
|
||||
@Unroll
|
||||
def 'should not change config' () {
|
||||
given:
|
||||
def session = Mock(Session) { getConfig() >> CONFIG }
|
||||
def factory = new WaveFactory()
|
||||
|
||||
when:
|
||||
factory.create(session)
|
||||
then:
|
||||
CONFIG == EXPECTED
|
||||
and:
|
||||
DISABLED * session.setDisableRemoteBinDir(true) >> null
|
||||
|
||||
where:
|
||||
CONFIG | EXPECTED | DISABLED
|
||||
[:] | [:] | 0
|
||||
[wave:[enabled:true]] | [wave:[enabled:true]] | 0
|
||||
[wave:[enabled:true], fusion:[enabled:true]] | [wave:[enabled:true,bundleProjectResources:true], fusion:[enabled:true]] | 1
|
||||
}
|
||||
|
||||
@Unroll
|
||||
def 'should get wave enable status' () {
|
||||
given:
|
||||
SysEnv.push(ENV)
|
||||
def session = Mock(Session) { getConfig() >> CONFIG }
|
||||
|
||||
when:
|
||||
def result = WaveFactory.shouldEnable(session)
|
||||
then:
|
||||
result == ENABLED
|
||||
CONFIG == EXPECTED
|
||||
|
||||
cleanup:
|
||||
SysEnv.pop()
|
||||
|
||||
where:
|
||||
ENABLED | ENV | CONFIG | EXPECTED
|
||||
false | [:] | [:] | [:]
|
||||
true | [:] | [wave:[enabled:true]] | [wave:[enabled:true]]
|
||||
true | [:] | [wave:[enabled:true], fusion:[enabled:true]] | [wave:[enabled:true,bundleProjectResources:true], fusion:[enabled:true]]
|
||||
false | [NXF_DISABLE_WAVE_SERVICE: 'true']| [wave:[enabled:true], fusion:[enabled:true]] | [wave:[enabled:false], fusion:[enabled:true]]
|
||||
}
|
||||
|
||||
@Unroll
|
||||
def 'should check s5cmd is enabled' () {
|
||||
given:
|
||||
def factory = new WaveFactory()
|
||||
|
||||
expect:
|
||||
factory.isAwsBatchFargateMode(CONFIG) == EXPECTED
|
||||
|
||||
where:
|
||||
CONFIG | EXPECTED
|
||||
[:] | false
|
||||
[aws:[batch:[platformType:'foo']]] | false
|
||||
[aws:[batch:[platformType:'fargate']]] | true
|
||||
[aws:[batch:[platformType:'Fargate']]] | true
|
||||
|
||||
}
|
||||
|
||||
def 'should fail when wave is disabled' () {
|
||||
given:
|
||||
def CONFIG = [wave:[:], fusion:[enabled:true]]
|
||||
def session = Mock(Session) { getConfig() >> CONFIG }
|
||||
def factory = new WaveFactory()
|
||||
|
||||
when:
|
||||
factory.create(session)
|
||||
then:
|
||||
def e = thrown(AbortOperationException)
|
||||
e.message == 'Fusion feature requires enabling Wave service'
|
||||
}
|
||||
|
||||
def 'should not fail when wave is disabled' () {
|
||||
given:
|
||||
SysEnv.push(NXF_DISABLE_WAVE_SERVICE: 'true')
|
||||
def CONFIG = [wave:[:], fusion:[enabled:true]]
|
||||
def session = Mock(Session) { getConfig() >> CONFIG }
|
||||
def factory = new WaveFactory()
|
||||
|
||||
when:
|
||||
factory.create(session)
|
||||
then:
|
||||
noExceptionThrown()
|
||||
and:
|
||||
0 * session.setDisableRemoteBinDir(true) >> null
|
||||
and:
|
||||
CONFIG == [wave:[:], fusion:[enabled:true]]
|
||||
|
||||
cleanup:
|
||||
SysEnv.pop()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* Copyright 2013-2026, Seqera Labs
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.seqera.wave.plugin.cli
|
||||
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
|
||||
import groovy.json.JsonSlurper
|
||||
import io.seqera.wave.plugin.packer.TarHelper
|
||||
import nextflow.extension.FilesEx
|
||||
import nextflow.file.FileHelper
|
||||
import org.junit.Rule
|
||||
import spock.lang.Shared
|
||||
import spock.lang.Specification
|
||||
import spock.lang.TempDir
|
||||
import spock.lang.Unroll
|
||||
import test.OutputCapture
|
||||
/**
|
||||
*
|
||||
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
|
||||
*/
|
||||
class WaveCmdEntryTest extends Specification implements TarHelper {
|
||||
|
||||
@Shared
|
||||
@TempDir
|
||||
Path folder
|
||||
|
||||
@Rule
|
||||
OutputCapture capture = new OutputCapture()
|
||||
|
||||
def 'should create create pack' () {
|
||||
given:
|
||||
def rootPath = folder.resolve('bundle'); rootPath.mkdir()
|
||||
def untarPath = folder.resolve('untar'); untarPath.mkdir()
|
||||
rootPath.resolve('main.nf').text = "I'm the main file"
|
||||
rootPath.resolve('this/that').mkdirs()
|
||||
Files.write(rootPath.resolve('this/hola.txt'), "Hola".bytes)
|
||||
Files.write(rootPath.resolve('this/hello.txt'), "Hello".bytes)
|
||||
Files.write(rootPath.resolve('this/that/ciao.txt'), "Ciao".bytes)
|
||||
and:
|
||||
FileHelper.visitFiles([type:'any'], rootPath, '**', {
|
||||
final mode = it.isDirectory() ? 0700 : 0600
|
||||
FilesEx.setPermissionsMode(it, mode)
|
||||
})
|
||||
and:
|
||||
def cmd = new WaveCmdEntry()
|
||||
|
||||
when:
|
||||
def result = cmd.packContainer( [rootPath.toString()] )
|
||||
then:
|
||||
def gzipFile = folder.resolve('bundle.tar.gz')
|
||||
gzipFile.exists()
|
||||
and:
|
||||
def json = new JsonSlurper().parseText(result)
|
||||
and:
|
||||
json.layers[0].gzipSize == Files.size(gzipFile)
|
||||
json.layers[0].location == gzipFile.toUri().toString()
|
||||
json.layers[0].tarDigest == 'sha256:f556b94e9b6f5f72b86e44833614b465df9f65cb4210e3f4416292dca1618360'
|
||||
json.layers[0].gzipDigest == 'sha256:e58685a82452a11faa926843e7861c94bdb93e2c8f098b5c5354ec9b6fee2b68'
|
||||
|
||||
when:
|
||||
def tar = uncompress(Files.readAllBytes(gzipFile))
|
||||
untar( new ByteArrayInputStream(tar), untarPath )
|
||||
then:
|
||||
untarPath.resolve('main.nf').text == rootPath.resolve('main.nf').text
|
||||
untarPath.resolve('this/hola.txt').text == rootPath.resolve('this/hola.txt').text
|
||||
untarPath.resolve('this/hello.txt').text == rootPath.resolve('this/hello.txt').text
|
||||
untarPath.resolve('this/that/ciao.txt').text == rootPath.resolve('this/that/ciao.txt').text
|
||||
}
|
||||
|
||||
@Unroll
|
||||
def 'should find base name' () {
|
||||
expect:
|
||||
WaveCmdEntry.baseName(PATH) == EXPECTED
|
||||
|
||||
where:
|
||||
PATH | EXPECTED
|
||||
null | null
|
||||
'/some/name' | 'name'
|
||||
'http://some/name.tar' | 'name'
|
||||
'/some/name.tar.gz' | 'name'
|
||||
'/some/name.tar.gzip' | 'name'
|
||||
'http://some/name.tar.gz' | 'name'
|
||||
'http://some/' | null
|
||||
'http://some' | null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright 2013-2026, Seqera Labs
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.seqera.wave.plugin.cli
|
||||
|
||||
import spock.lang.Specification
|
||||
import spock.lang.Unroll
|
||||
|
||||
/**
|
||||
*
|
||||
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
|
||||
*/
|
||||
class WaveDebugCmdTest extends Specification {
|
||||
|
||||
@Unroll
|
||||
def 'should check remote path' () {
|
||||
expect:
|
||||
WaveDebugCmd.isRemotePath(PATH) == EXPECTED
|
||||
where:
|
||||
PATH | EXPECTED
|
||||
null | false
|
||||
'foo' | false
|
||||
'/some/file' | false
|
||||
and:
|
||||
's3://foo/bar' | true
|
||||
'gs://foo/bar' | true
|
||||
and:
|
||||
'file:/foo/bar' | false
|
||||
'file://foo/bar' | false
|
||||
'file:///foo/bar' | false
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright 2013-2026, Seqera Labs
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.seqera.wave.plugin.config
|
||||
|
||||
import java.time.Duration
|
||||
|
||||
import nextflow.util.RateUnit
|
||||
import spock.lang.Specification
|
||||
/**
|
||||
*
|
||||
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
|
||||
*/
|
||||
class HttpOptsTest extends Specification {
|
||||
|
||||
def 'should get http options' () {
|
||||
when:
|
||||
def opts = new HttpOpts([:])
|
||||
then:
|
||||
opts.connectTimeout() == Duration.ofSeconds(30)
|
||||
opts.maxRate() == RateUnit.of('1/sec')
|
||||
|
||||
when:
|
||||
opts = new HttpOpts([connectTimeout:'50s', maxRate: '10/s'])
|
||||
then:
|
||||
opts.connectTimeout() == Duration.ofSeconds(50)
|
||||
opts.maxRate() == RateUnit.of('10/s')
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright 2013-2026, Seqera Labs
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.seqera.wave.plugin.config
|
||||
|
||||
import nextflow.util.Duration
|
||||
import spock.lang.Specification
|
||||
|
||||
/**
|
||||
*
|
||||
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
|
||||
*/
|
||||
class RetryOptsTest extends Specification {
|
||||
|
||||
def 'should create retry config' () {
|
||||
|
||||
expect:
|
||||
new RetryOpts().delay == Duration.of('450ms')
|
||||
new RetryOpts().maxDelay == Duration.of('90s')
|
||||
new RetryOpts().maxAttempts == 5
|
||||
new RetryOpts().jitter == 0.25d
|
||||
new RetryOpts().multiplier == 2.0d
|
||||
|
||||
and:
|
||||
new RetryOpts([maxAttempts: 20]).maxAttempts == 20
|
||||
new RetryOpts([delay: '1s']).delay == Duration.of('1s')
|
||||
new RetryOpts([maxDelay: '1m']).maxDelay == Duration.of('1m')
|
||||
new RetryOpts([jitter: '0.5']).jitter == 0.5d
|
||||
new RetryOpts([multiplier: '5.0']).multiplier == 5.0d
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* Copyright 2013-2026, Seqera Labs
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.seqera.wave.plugin.config
|
||||
|
||||
import spock.lang.Specification
|
||||
|
||||
/**
|
||||
*
|
||||
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
|
||||
*/
|
||||
class TowerConfigTest extends Specification {
|
||||
|
||||
def 'should get tower config' () {
|
||||
given:
|
||||
TowerConfig config
|
||||
Map env
|
||||
|
||||
when:
|
||||
config = new TowerConfig([:], [:])
|
||||
then:
|
||||
!config.getAccessToken()
|
||||
!config.getWorkspaceId()
|
||||
|
||||
when:
|
||||
env = [TOWER_ACCESS_TOKEN:'foo', TOWER_WORKSPACE_ID: '123']
|
||||
config = new TowerConfig([:], env)
|
||||
then:
|
||||
config.accessToken == 'foo'
|
||||
config.workspaceId == 123
|
||||
config.workflowId == null
|
||||
|
||||
when:
|
||||
env = [TOWER_ACCESS_TOKEN:'foo', TOWER_WORKSPACE_ID: '123']
|
||||
config = new TowerConfig(accessToken: 'bar', workspaceId: '789', env)
|
||||
then:
|
||||
config.accessToken == 'bar'
|
||||
config.workspaceId == 789
|
||||
config.workflowId == null
|
||||
|
||||
when:
|
||||
env = [TOWER_ACCESS_TOKEN:'foo', TOWER_WORKSPACE_ID: '123']
|
||||
config = new TowerConfig(accessToken: null, workspaceId: '789', env)
|
||||
then:
|
||||
config.accessToken == null
|
||||
config.workspaceId == 789
|
||||
config.workflowId == null
|
||||
|
||||
// when TOWER_WORKFLOW_ID is defined env has priority
|
||||
when:
|
||||
env = [TOWER_ACCESS_TOKEN:'foo', TOWER_WORKSPACE_ID: '123', TOWER_WORKFLOW_ID: 'xyz']
|
||||
config = new TowerConfig(accessToken: 'bar', workspaceId: '789', env)
|
||||
then:
|
||||
config.accessToken == 'foo'
|
||||
config.workspaceId == 123
|
||||
config.workflowId == 'xyz'
|
||||
}
|
||||
|
||||
def 'should get refresh token' () {
|
||||
given:
|
||||
TowerConfig config
|
||||
Map env
|
||||
|
||||
when:
|
||||
config = new TowerConfig([:], [:])
|
||||
then:
|
||||
!config.refreshToken
|
||||
|
||||
when:
|
||||
env = [TOWER_REFRESH_TOKEN:'foo', TOWER_WORKSPACE_ID: '123']
|
||||
config = new TowerConfig([:], env)
|
||||
then:
|
||||
config.refreshToken == 'foo'
|
||||
config.workspaceId == 123
|
||||
|
||||
when:
|
||||
env = [TOWER_REFRESH_TOKEN:'foo', TOWER_WORKSPACE_ID: '123']
|
||||
config = new TowerConfig(refreshToken: 'bar', workspaceId: '789', env)
|
||||
then:
|
||||
config.refreshToken == 'bar'
|
||||
config.workspaceId == 789
|
||||
|
||||
when:
|
||||
env = [TOWER_REFRESH_TOKEN:'foo', TOWER_WORKSPACE_ID: '123']
|
||||
config = new TowerConfig(refreshToken: null, workspaceId: '789', env)
|
||||
then:
|
||||
config.refreshToken == null
|
||||
config.workspaceId == 789
|
||||
|
||||
// when TOWER_WORKFLOW_ID is defined env has priority
|
||||
when:
|
||||
env = [TOWER_REFRESH_TOKEN:'foo', TOWER_WORKSPACE_ID: '123', TOWER_WORKFLOW_ID: 'xyz']
|
||||
config = new TowerConfig(refreshToken: 'bar', workspaceId: '789', env)
|
||||
then:
|
||||
config.refreshToken == 'foo'
|
||||
config.workspaceId == 123
|
||||
}
|
||||
|
||||
def 'should config endpoint' () {
|
||||
given:
|
||||
TowerConfig config
|
||||
Map env
|
||||
|
||||
when:
|
||||
config = new TowerConfig([:], [:])
|
||||
then:
|
||||
config.endpoint == 'https://api.cloud.seqera.io'
|
||||
|
||||
when:
|
||||
config = new TowerConfig([endpoint:'-'], [:])
|
||||
then:
|
||||
config.endpoint == 'https://api.cloud.seqera.io'
|
||||
|
||||
when:
|
||||
config = new TowerConfig([endpoint:'http://foo.com'], [:])
|
||||
then:
|
||||
config.endpoint == 'http://foo.com'
|
||||
|
||||
when:
|
||||
config = new TowerConfig([endpoint:'http://foo.com//'], [:])
|
||||
then:
|
||||
config.endpoint == 'http://foo.com'
|
||||
|
||||
when:
|
||||
config = new TowerConfig([endpoint:'http://foo.com'], [TOWER_API_ENDPOINT:'http://bar.com'])
|
||||
then:
|
||||
config.endpoint == 'http://foo.com'
|
||||
|
||||
when:
|
||||
config = new TowerConfig([endpoint:'-'], [TOWER_API_ENDPOINT:'http://bar.com/'])
|
||||
then:
|
||||
config.endpoint == 'http://bar.com'
|
||||
|
||||
when:
|
||||
config = new TowerConfig([:], [TOWER_API_ENDPOINT:'http://bar.com/'])
|
||||
then:
|
||||
config.endpoint == 'http://bar.com'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
/*
|
||||
* Copyright 2013-2026, Seqera Labs
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.seqera.wave.plugin.config
|
||||
|
||||
import io.seqera.wave.api.BuildCompression
|
||||
import io.seqera.wave.api.ScanLevel
|
||||
import io.seqera.wave.api.ScanMode
|
||||
import nextflow.util.Duration
|
||||
import spock.lang.Specification
|
||||
import spock.lang.Unroll
|
||||
/**
|
||||
*
|
||||
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
|
||||
*/
|
||||
class WaveConfigTest extends Specification {
|
||||
|
||||
def 'should create empty opts' () {
|
||||
when:
|
||||
def opts = new WaveConfig([:])
|
||||
then:
|
||||
!opts.enabled()
|
||||
opts.endpoint() == 'https://wave.seqera.io'
|
||||
}
|
||||
|
||||
def 'should create from env' () {
|
||||
given:
|
||||
def ENV = [WAVE_API_ENDPOINT: 'http://foo']
|
||||
when:
|
||||
def opts = new WaveConfig([:], ENV)
|
||||
then:
|
||||
!opts.enabled()
|
||||
opts.endpoint() == 'http://foo'
|
||||
}
|
||||
|
||||
def 'should create config options' () {
|
||||
given:
|
||||
def ENV = [WAVE_API_ENDPOINT: 'http://foo']
|
||||
when:
|
||||
// config options have priority over sys env
|
||||
def opts = new WaveConfig([enabled:true, endpoint: 'http://localhost'], ENV)
|
||||
then:
|
||||
opts.enabled()
|
||||
opts.endpoint() == 'http://localhost'
|
||||
}
|
||||
|
||||
def 'should remove ending slash' () {
|
||||
when:
|
||||
def opts = new WaveConfig([enabled:true, endpoint: 'http://localhost/v1//'])
|
||||
then:
|
||||
opts.enabled()
|
||||
opts.endpoint() == 'http://localhost/v1'
|
||||
}
|
||||
|
||||
@Unroll
|
||||
def 'should add config urls' () {
|
||||
when:
|
||||
def opts = new WaveConfig(OPTS, ENV)
|
||||
then:
|
||||
opts.containerConfigUrl() == EXPECTED
|
||||
|
||||
where:
|
||||
OPTS | ENV | EXPECTED
|
||||
[:] | [:] | []
|
||||
[containerConfigUrl: 'http://foo.com'] | [:] | [ new URL('http://foo.com')]
|
||||
[containerConfigUrl: 'http://foo.com'] | [WAVE_CONTAINER_CONFIG_URL:'http://something.com'] | [ new URL('http://foo.com')]
|
||||
[:] | [WAVE_CONTAINER_CONFIG_URL:'http://something.com'] | [ new URL('http://something.com')]
|
||||
[containerConfigUrl: ['http://foo.com','https://bar.com']] | [:] | [ new URL('http://foo.com'), new URL('https://bar.com')]
|
||||
[containerConfigUrl: ['http://foo.com','https://bar.com']] | [WAVE_CONTAINER_CONFIG_URL:'http://boo.com'] | [ new URL('http://foo.com'), new URL('https://bar.com')]
|
||||
|
||||
}
|
||||
|
||||
def 'should get conda config' () {
|
||||
when:
|
||||
def opts = new WaveConfig([:])
|
||||
then:
|
||||
opts.condaOpts().mambaImage == 'mambaorg/micromamba:1.5.10-noble'
|
||||
opts.condaOpts().baseImage == 'ubuntu:24.04'
|
||||
opts.condaOpts().commands == null
|
||||
|
||||
when:
|
||||
opts = new WaveConfig([build:[conda:[mambaImage:'mambaorg/foo:1', commands:['USER hola']]]])
|
||||
then:
|
||||
opts.condaOpts().mambaImage == 'mambaorg/foo:1'
|
||||
opts.condaOpts().commands == ['USER hola']
|
||||
|
||||
when:
|
||||
opts = new WaveConfig([build:[conda:[baseImage:'debian:12', mambaImage:'mambaorg/micromamba:2-amazon2023']]])
|
||||
then:
|
||||
opts.condaOpts().baseImage == 'debian:12'
|
||||
opts.condaOpts().mambaImage == 'mambaorg/micromamba:2-amazon2023'
|
||||
|
||||
}
|
||||
|
||||
def 'should get build and cache repos' () {
|
||||
when:
|
||||
def opts = new WaveConfig([:])
|
||||
then:
|
||||
opts.buildRepository() == null
|
||||
opts.cacheRepository() == null
|
||||
|
||||
when:
|
||||
opts = new WaveConfig([build:[repository:'some/repo', cacheRepository:'some/cache']])
|
||||
then:
|
||||
opts.buildRepository() == 'some/repo'
|
||||
opts.cacheRepository() == 'some/cache'
|
||||
}
|
||||
|
||||
def 'should get build template' () {
|
||||
when:
|
||||
def opts = new WaveConfig([:])
|
||||
then:
|
||||
opts.buildTemplate() == null
|
||||
|
||||
when:
|
||||
opts = new WaveConfig([build:[template:'conda/pixi:v1']])
|
||||
then:
|
||||
opts.buildTemplate() == 'conda/pixi:v1'
|
||||
}
|
||||
|
||||
@Unroll
|
||||
def 'should set strategy' () {
|
||||
when:
|
||||
def opts = new WaveConfig([:])
|
||||
then:
|
||||
opts.strategy() == ['container','dockerfile','conda']
|
||||
|
||||
when:
|
||||
opts = new WaveConfig([strategy:STRATEGY])
|
||||
then:
|
||||
opts.strategy() == EXPECTED
|
||||
|
||||
where:
|
||||
STRATEGY | EXPECTED
|
||||
null | ['container','dockerfile','conda']
|
||||
'dockerfile' | ['dockerfile']
|
||||
'conda,container' | ['conda','container']
|
||||
'conda , container' | ['conda','container']
|
||||
['conda','container'] | ['conda','container']
|
||||
[' conda',' container'] | ['conda','container']
|
||||
}
|
||||
|
||||
def 'should fail to set strategy' () {
|
||||
when:
|
||||
new WaveConfig([strategy:['foo']])
|
||||
then:
|
||||
def e = thrown(IllegalArgumentException)
|
||||
e.message == "Invalid value for 'wave.strategy' configuration attribute - offending value: foo"
|
||||
}
|
||||
|
||||
def 'should get retry policy' () {
|
||||
when:
|
||||
def opts = new WaveConfig([:])
|
||||
then:
|
||||
opts.retryOpts().delay == Duration.of('450ms')
|
||||
opts.retryOpts().maxAttempts == 5
|
||||
opts.retryOpts().maxDelay == Duration.of('90s')
|
||||
|
||||
when:
|
||||
opts = new WaveConfig([retryPolicy:[ maxAttempts: 20, jitter: 1.0, delay: '1s', maxDelay: '10s' ]])
|
||||
then:
|
||||
opts.retryOpts().maxAttempts == 20
|
||||
opts.retryOpts().jitter == 1.0d
|
||||
opts.retryOpts().delay == Duration.of('1s')
|
||||
opts.retryOpts().maxDelay == Duration.of('10s')
|
||||
|
||||
// legacy
|
||||
when:
|
||||
opts = new WaveConfig([retry:[ maxAttempts: 10, jitter: 2.0, delay: '3s', maxDelay: '40s' ]])
|
||||
then:
|
||||
opts.retryOpts().maxAttempts == 10
|
||||
opts.retryOpts().jitter == 2.0d
|
||||
opts.retryOpts().delay == Duration.of('3s')
|
||||
opts.retryOpts().maxDelay == Duration.of('40s')
|
||||
}
|
||||
|
||||
def 'should get http config options' () {
|
||||
when:
|
||||
def opts = new WaveConfig([:])
|
||||
then:
|
||||
opts.httpOpts().connectTimeout() == java.time.Duration.ofSeconds(30)
|
||||
|
||||
when:
|
||||
opts = new WaveConfig([httpClient: [connectTimeout: '90s']])
|
||||
then:
|
||||
opts.httpOpts().connectTimeout() == java.time.Duration.ofSeconds(90)
|
||||
}
|
||||
|
||||
def 'should dump config' () {
|
||||
given:
|
||||
def config = new WaveConfig([enabled: true])
|
||||
expect:
|
||||
config.toString() == 'WaveConfig(build:BuildOpts(repository:null, cacheRepository:null, template:null, conda:CondaOpts(mambaImage=mambaorg/micromamba:1.5.10-noble; basePackages=conda-forge::procps-ng, commands=null, baseImage=ubuntu:24.04), compression:null, maxDuration:40m), enabled:true, endpoint:https://wave.seqera.io, freeze:false, httpClient:HttpOpts(), mirror:false, retryPolicy:RetryOpts(delay:450ms, maxDelay:1m 30s, maxAttempts:5, jitter:0.25, multiplier:2.0, delayAsDuration:PT0.45S, maxDelayAsDuration:PT1M30S), scan:ScanOpts(allowedLevels:null, mode:null), strategy:[container, dockerfile, conda], bundleProjectResources:null, containerConfigUrl:[], preserveFileTimestamp:null, tokensCacheMaxDuration:30m)'
|
||||
}
|
||||
|
||||
def 'should not allow invalid setting' () {
|
||||
when:
|
||||
new WaveConfig(endpoint: 'foo')
|
||||
then:
|
||||
def e = thrown(IllegalArgumentException)
|
||||
e.message == "Endpoint URL should start with 'http:' or 'https:' protocol prefix - offending value: 'foo'"
|
||||
|
||||
when:
|
||||
new WaveConfig(endpoint: 'ftp://foo.com')
|
||||
then:
|
||||
e = thrown(IllegalArgumentException)
|
||||
e.message == "Endpoint URL should start with 'http:' or 'https:' protocol prefix - offending value: 'ftp://foo.com'"
|
||||
|
||||
when:
|
||||
new WaveConfig(build: [repository: 'http://foo.com'])
|
||||
then:
|
||||
e = thrown(IllegalArgumentException)
|
||||
e.message == "Config setting 'wave.build.repository' should not include any protocol prefix - offending value: 'http://foo.com'"
|
||||
|
||||
when:
|
||||
new WaveConfig(build: [cacheRepository: 'http://foo.com'])
|
||||
then:
|
||||
e = thrown(IllegalArgumentException)
|
||||
e.message == "Config setting 'wave.build.cacheRepository' should not include any protocol prefix - offending value: 'http://foo.com'"
|
||||
}
|
||||
|
||||
def 'should set preserve timestamp' () {
|
||||
when:
|
||||
def config = new WaveConfig([:])
|
||||
then:
|
||||
!config.preserveFileTimestamp()
|
||||
|
||||
when:
|
||||
config = new WaveConfig(preserveFileTimestamp: true)
|
||||
then:
|
||||
config.preserveFileTimestamp()
|
||||
}
|
||||
|
||||
def 'should enabled mirror mode' () {
|
||||
expect:
|
||||
!new WaveConfig([:]).mirrorMode()
|
||||
and:
|
||||
new WaveConfig([mirror:true]).mirrorMode()
|
||||
}
|
||||
|
||||
@Unroll
|
||||
def 'should validate scan mode' () {
|
||||
expect:
|
||||
new WaveConfig(scan: [mode: MODE]).scanMode() == EXPECTED
|
||||
where:
|
||||
MODE | EXPECTED
|
||||
null | null
|
||||
'none' | ScanMode.none
|
||||
'async' | ScanMode.async
|
||||
'required' | ScanMode.required
|
||||
}
|
||||
|
||||
@Unroll
|
||||
def 'should validate scan levels' () {
|
||||
expect:
|
||||
new WaveConfig(scan: [allowedLevels: LEVEL]).scanAllowedLevels() == EXPECTED
|
||||
where:
|
||||
LEVEL | EXPECTED
|
||||
null | null
|
||||
'low' | List.of(ScanLevel.LOW)
|
||||
'LOW' | List.of(ScanLevel.LOW)
|
||||
'low,high' | List.of(ScanLevel.LOW,ScanLevel.HIGH)
|
||||
'LOW, HIGH' | List.of(ScanLevel.LOW,ScanLevel.HIGH)
|
||||
['medium','high'] | List.of(ScanLevel.MEDIUM,ScanLevel.HIGH)
|
||||
}
|
||||
|
||||
def 'should validate build compression' () {
|
||||
expect:
|
||||
new WaveConfig(build: [compression: COMPRESSION]).buildCompression() == EXPECTED
|
||||
where:
|
||||
COMPRESSION | EXPECTED
|
||||
null | null
|
||||
[mode:'gzip'] | BuildCompression.gzip
|
||||
[mode:'estargz'] | BuildCompression.estargz
|
||||
and:
|
||||
[mode:'gzip', level: 1] | new BuildCompression().withMode(BuildCompression.Mode.gzip).withLevel(1)
|
||||
[mode:'estargz', level: 2]| new BuildCompression().withMode(BuildCompression.Mode.estargz).withLevel(2)
|
||||
[mode:'zstd', level: 3] | new BuildCompression().withMode(BuildCompression.Mode.zstd).withLevel(3)
|
||||
and:
|
||||
[mode:'gzip', level: 1, force:true] | new BuildCompression().withMode(BuildCompression.Mode.gzip).withLevel(1).withForce(true)
|
||||
[mode:'estargz', level: 2, force:true ] | new BuildCompression().withMode(BuildCompression.Mode.estargz).withLevel(2).withForce(true)
|
||||
[mode:'zstd', level: 3,force:true] | new BuildCompression().withMode(BuildCompression.Mode.zstd).withLevel(3).withForce(true)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
* Copyright 2013-2026, Seqera Labs
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.seqera.wave.plugin.packer
|
||||
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.attribute.FileTime
|
||||
|
||||
import nextflow.extension.FilesEx
|
||||
import nextflow.file.FileHelper
|
||||
import spock.lang.Specification
|
||||
/**
|
||||
*
|
||||
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
|
||||
*/
|
||||
class PackerTest extends Specification implements TarHelper {
|
||||
|
||||
def 'should tar bundle' () {
|
||||
given:
|
||||
def folder = Files.createTempDirectory('test')
|
||||
and:
|
||||
def result = folder.resolve('result')
|
||||
def result2 = folder.resolve('result2')
|
||||
and:
|
||||
def rootPath = folder.resolve('bundle'); rootPath.mkdir()
|
||||
rootPath.resolve('main.nf').text = "I'm the main file"
|
||||
rootPath.resolve('this/that').mkdirs()
|
||||
Files.write(rootPath.resolve('this/hola.txt'), "Hola".bytes)
|
||||
Files.write(rootPath.resolve('this/hello.txt'), "Hello".bytes)
|
||||
Files.write(rootPath.resolve('this/that/ciao.txt'), "Ciao".bytes)
|
||||
and:
|
||||
def files = new ArrayList<Path>()
|
||||
FileHelper.visitFiles([type:'any'], rootPath, '**', {
|
||||
final mode = it.isDirectory() ? 0700 : 0600
|
||||
FilesEx.setPermissionsMode(it, mode)
|
||||
//
|
||||
files.add(it)
|
||||
})
|
||||
and:
|
||||
def packer = new Packer()
|
||||
|
||||
when:
|
||||
def buffer = new ByteArrayOutputStream()
|
||||
packer.makeTar(rootPath, files, buffer)
|
||||
and:
|
||||
untar( new ByteArrayInputStream(buffer.toByteArray()), result )
|
||||
then:
|
||||
result.resolve('main.nf').text == rootPath.resolve('main.nf').text
|
||||
result.resolve('this/hola.txt').text == rootPath.resolve('this/hola.txt').text
|
||||
result.resolve('this/hello.txt').text == rootPath.resolve('this/hello.txt').text
|
||||
result.resolve('this/that/ciao.txt').text == rootPath.resolve('this/that/ciao.txt').text
|
||||
and:
|
||||
result.resolve('main.nf').getPermissionsMode() == 0600
|
||||
result.resolve('this/hola.txt').getPermissionsMode() == 0600
|
||||
result.resolve('this/that').getPermissionsMode() == 0700
|
||||
and:
|
||||
Files.getLastModifiedTime(result.resolve('main.nf')) == FileTime.fromMillis(0)
|
||||
|
||||
when:
|
||||
def layer = packer.layer(rootPath, files)
|
||||
then:
|
||||
layer.tarDigest == 'sha256:f556b94e9b6f5f72b86e44833614b465df9f65cb4210e3f4416292dca1618360'
|
||||
layer.gzipDigest == 'sha256:e58685a82452a11faa926843e7861c94bdb93e2c8f098b5c5354ec9b6fee2b68'
|
||||
layer.gzipSize == 251
|
||||
and:
|
||||
def gzip = layer.location.replace('data:','').decodeBase64()
|
||||
def tar = uncompress(gzip)
|
||||
untar( new ByteArrayInputStream(tar), result2)
|
||||
and:
|
||||
result2.resolve('main.nf').text == rootPath.resolve('main.nf').text
|
||||
result2.resolve('this/hola.txt').text == rootPath.resolve('this/hola.txt').text
|
||||
result2.resolve('this/hello.txt').text == rootPath.resolve('this/hello.txt').text
|
||||
result2.resolve('this/that/ciao.txt').text == rootPath.resolve('this/that/ciao.txt').text
|
||||
and:
|
||||
Files.getLastModifiedTime(result2.resolve('main.nf')) == FileTime.fromMillis(0)
|
||||
|
||||
cleanup:
|
||||
folder?.deleteDir()
|
||||
}
|
||||
|
||||
|
||||
def 'should tar bundle and preserve timestamps' () {
|
||||
given:
|
||||
def LAST_MODIFIED = FileTime.fromMillis(1_000_000_000_000)
|
||||
def folder = Files.createTempDirectory('test')
|
||||
and:
|
||||
def result = folder.resolve('result')
|
||||
def result2 = folder.resolve('result2')
|
||||
and:
|
||||
def rootPath = folder.resolve('bundle'); rootPath.mkdir()
|
||||
rootPath.resolve('main.nf').text = "I'm the main file"
|
||||
rootPath.resolve('this/that').mkdirs()
|
||||
Files.write(rootPath.resolve('this/hola.txt'), "Hola".bytes)
|
||||
Files.write(rootPath.resolve('this/hello.txt'), "Hello".bytes)
|
||||
Files.write(rootPath.resolve('this/that/ciao.txt'), "Ciao".bytes)
|
||||
and:
|
||||
def files = new ArrayList<Path>()
|
||||
FileHelper.visitFiles([type:'any'], rootPath, '**', {
|
||||
Files.setLastModifiedTime(it, LAST_MODIFIED)
|
||||
final mode = it.isDirectory() ? 0700 : 0600
|
||||
FilesEx.setPermissionsMode(it, mode)
|
||||
//
|
||||
files.add(it)
|
||||
})
|
||||
and:
|
||||
def packer = new Packer(preserveFileTimestamp: true)
|
||||
|
||||
when:
|
||||
def buffer = new ByteArrayOutputStream()
|
||||
packer.makeTar(rootPath, files, buffer)
|
||||
and:
|
||||
untar( new ByteArrayInputStream(buffer.toByteArray()), result )
|
||||
then:
|
||||
result.resolve('main.nf').text == rootPath.resolve('main.nf').text
|
||||
result.resolve('this/hola.txt').text == rootPath.resolve('this/hola.txt').text
|
||||
result.resolve('this/hello.txt').text == rootPath.resolve('this/hello.txt').text
|
||||
result.resolve('this/that/ciao.txt').text == rootPath.resolve('this/that/ciao.txt').text
|
||||
and:
|
||||
result.resolve('main.nf').getPermissionsMode() == 0600
|
||||
result.resolve('this/hola.txt').getPermissionsMode() == 0600
|
||||
result.resolve('this/that').getPermissionsMode() == 0700
|
||||
and:
|
||||
Files.getLastModifiedTime(result.resolve('main.nf')) == LAST_MODIFIED
|
||||
|
||||
when:
|
||||
def layer = packer.layer(rootPath, files)
|
||||
then:
|
||||
layer.tarDigest == 'sha256:81200f6ad32793567d8070375dc51312a1711fedf6a1c6f5e4a97fa3014f3491'
|
||||
layer.gzipDigest == 'sha256:09a2deca4293245909223db505cf69affa1a8ff8acb745fe3cad38bc0b719110'
|
||||
layer.gzipSize == 254
|
||||
and:
|
||||
def gzip = layer.location.replace('data:','').decodeBase64()
|
||||
def tar = uncompress(gzip)
|
||||
untar( new ByteArrayInputStream(tar), result2)
|
||||
and:
|
||||
result2.resolve('main.nf').text == rootPath.resolve('main.nf').text
|
||||
result2.resolve('this/hola.txt').text == rootPath.resolve('this/hola.txt').text
|
||||
result2.resolve('this/hello.txt').text == rootPath.resolve('this/hello.txt').text
|
||||
result2.resolve('this/that/ciao.txt').text == rootPath.resolve('this/that/ciao.txt').text
|
||||
|
||||
cleanup:
|
||||
folder?.deleteDir()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* Copyright 2013-2026, Seqera Labs
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.seqera.wave.plugin.packer
|
||||
|
||||
import static java.nio.file.StandardOpenOption.*
|
||||
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
|
||||
import org.apache.commons.compress.archivers.ArchiveStreamFactory
|
||||
import org.apache.commons.compress.archivers.tar.TarArchiveEntry
|
||||
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream
|
||||
import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream
|
||||
/**
|
||||
*
|
||||
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
|
||||
*/
|
||||
trait TarHelper {
|
||||
|
||||
static List<Path> untar(final InputStream is, final Path outputDir) {
|
||||
|
||||
final untaredFiles = new LinkedList<Path>();
|
||||
final debInputStream = (TarArchiveInputStream) new ArchiveStreamFactory().createArchiveInputStream("tar", is);
|
||||
TarArchiveEntry entry = null;
|
||||
while ((entry = (TarArchiveEntry)debInputStream.getNextEntry()) != null) {
|
||||
final outputFile = outputDir.resolve(entry.getName())
|
||||
if (entry.isDirectory()) {
|
||||
if (!outputFile.exists()) {
|
||||
if (!outputFile.mkdirs()) {
|
||||
throw new IllegalStateException(String.format("Couldn't create directory %s.", outputFile));
|
||||
}
|
||||
outputFile.setPermissionsMode( entry.getMode() )
|
||||
outputFile.setLastModified( entry.getLastModifiedDate().getTime() )
|
||||
}
|
||||
}
|
||||
else {
|
||||
println "outputFile=$outputFile; mode=${entry.mode}"
|
||||
outputFile.parent.mkdirs()
|
||||
final outputFileStream = Files.newOutputStream(outputFile, CREATE, APPEND)
|
||||
debInputStream.transferTo(outputFileStream)
|
||||
outputFileStream.close()
|
||||
outputFile.setPermissionsMode(entry.getMode())
|
||||
outputFile.setLastModified( entry.getLastModifiedDate().getTime() )
|
||||
}
|
||||
untaredFiles.add(outputFile);
|
||||
}
|
||||
debInputStream.close();
|
||||
|
||||
return untaredFiles
|
||||
}
|
||||
|
||||
byte[] uncompress( byte[] bytes ) {
|
||||
try (def stream = new GzipCompressorInputStream(new ByteArrayInputStream(bytes))) {
|
||||
def buffer = new ByteArrayOutputStream()
|
||||
stream.transferTo(buffer)
|
||||
return buffer.toByteArray()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* Copyright 2013-2026, Seqera Labs
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.seqera.wave.plugin.resolver
|
||||
|
||||
import io.seqera.wave.plugin.WaveClient
|
||||
import io.seqera.wave.plugin.config.WaveConfig
|
||||
import nextflow.container.ContainerConfig
|
||||
import nextflow.container.resolver.ContainerInfo
|
||||
import nextflow.container.resolver.ContainerMeta
|
||||
import nextflow.container.resolver.DefaultContainerResolver
|
||||
import nextflow.executor.Executor
|
||||
import nextflow.processor.TaskProcessor
|
||||
import nextflow.processor.TaskRun
|
||||
import spock.lang.Specification
|
||||
/**
|
||||
*
|
||||
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
|
||||
*/
|
||||
class WaveContainerResolverTest extends Specification {
|
||||
|
||||
def 'should resolve docker container with wave container' () {
|
||||
given:
|
||||
def CONTAINER_NAME = "ubuntu:latest"
|
||||
def WAVE_CONTAINER = new ContainerInfo(CONTAINER_NAME, "wave.io/ubuntu:latest", "12345")
|
||||
def ORAS_CONTAINER = new ContainerInfo(CONTAINER_NAME, "oras://wave.io/ubuntu:latest", "12345")
|
||||
def SINGULARITY_CONTAINER = new ContainerInfo('ubuntu:latest', '/some/singularity/ubuntu.img')
|
||||
and:
|
||||
def defaultResolver = Spy(DefaultContainerResolver)
|
||||
def executor = Mock(Executor)
|
||||
def resolver = Spy(new WaveContainerResolver(defaultResolver: defaultResolver))
|
||||
def task = Mock(TaskRun) {
|
||||
getProcessor() >> Mock(TaskProcessor) {
|
||||
getExecutor() >> executor
|
||||
}
|
||||
}
|
||||
|
||||
// docker images
|
||||
when:
|
||||
def result = resolver.resolveImage(task, CONTAINER_NAME)
|
||||
then:
|
||||
resolver.client() >> Mock(WaveClient) { config()>>Mock(WaveConfig) }
|
||||
_ * task.getContainerConfig() >> Mock(ContainerConfig) { getEngine()>>'docker' }
|
||||
and:
|
||||
1 * resolver.waveContainer(task, CONTAINER_NAME, false) >> WAVE_CONTAINER
|
||||
and:
|
||||
result == WAVE_CONTAINER
|
||||
|
||||
// singularity images
|
||||
when:
|
||||
result = resolver.resolveImage(task, CONTAINER_NAME)
|
||||
then:
|
||||
resolver.client() >> Mock(WaveClient) { config()>>Mock(WaveConfig) }
|
||||
_ * task.getContainerConfig() >> Mock(ContainerConfig) { getEngine()>>'singularity'; isEnabled()>>true }
|
||||
and:
|
||||
1 * resolver.waveContainer(task, CONTAINER_NAME, false) >> WAVE_CONTAINER
|
||||
1 * defaultResolver.resolveImage(task, WAVE_CONTAINER.target, WAVE_CONTAINER.hashKey) >> SINGULARITY_CONTAINER
|
||||
and:
|
||||
result == SINGULARITY_CONTAINER
|
||||
|
||||
// singularity images + oras protocol
|
||||
when:
|
||||
result = resolver.resolveImage(task, CONTAINER_NAME)
|
||||
then:
|
||||
resolver.client() >> Mock(WaveClient) { config()>>Mock(WaveConfig) { freezeMode()>>true } }
|
||||
_ * task.getContainerConfig() >> Mock(ContainerConfig) { getEngine()>>'singularity'; isEnabled()>>true }
|
||||
and:
|
||||
1 * resolver.waveContainer(task, CONTAINER_NAME, true) >> ORAS_CONTAINER
|
||||
0 * defaultResolver.resolveImage(task, WAVE_CONTAINER.target) >> null
|
||||
and:
|
||||
result == ORAS_CONTAINER
|
||||
}
|
||||
|
||||
def 'should return container meta' () {
|
||||
given:
|
||||
def containerKey = 'abc'
|
||||
def client = Mock(WaveClient)
|
||||
def resolver = Spy(new WaveContainerResolver())
|
||||
def meta = Mock(ContainerMeta)
|
||||
|
||||
when:
|
||||
def result = resolver.getContainerMeta(containerKey)
|
||||
then:
|
||||
resolver.client()>>client
|
||||
and:
|
||||
client.enabled()>>true
|
||||
client.getContainerMeta(containerKey)>>meta
|
||||
and:
|
||||
result == meta
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright 2013-2026, Seqera Labs
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.seqera.wave.plugin.util
|
||||
|
||||
|
||||
import spock.lang.Specification
|
||||
import spock.lang.Unroll
|
||||
|
||||
/**
|
||||
*
|
||||
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
|
||||
*/
|
||||
class BasicCliOptsTest extends Specification {
|
||||
|
||||
@Unroll
|
||||
def 'should parse options' () {
|
||||
when:
|
||||
def cli = BasicCliOpts.parse(CLI?.tokenize(' '))
|
||||
then:
|
||||
cli.options == OPTS
|
||||
cli.args == ARGS
|
||||
|
||||
where:
|
||||
CLI | OPTS | ARGS
|
||||
null | [:] | []
|
||||
'' | [:] | []
|
||||
'alpha=1 beta=2 foo delta=3 bar' | [alpha:'1', beta:'2', delta:'3'] | ['foo','bar']
|
||||
'alpha= foo' | [alpha:''] | ['foo']
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright 2013-2026, Seqera Labs
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.seqera.wave.plugin.util
|
||||
|
||||
import spock.lang.Specification
|
||||
|
||||
/**
|
||||
*
|
||||
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
|
||||
*/
|
||||
class GnuCliOptsTest extends Specification {
|
||||
|
||||
def 'should parse cli' () {
|
||||
|
||||
when:
|
||||
def cli = GnuCliOpts.parse(['-i', '-v', 'x=y', 'nextflow', '--', 'this', '--that'])
|
||||
then:
|
||||
cli.options == ['-i':'','-v':'x=y']
|
||||
cli.container == 'nextflow'
|
||||
cli.args == ['this','--that']
|
||||
|
||||
when:
|
||||
cli = GnuCliOpts.parse(['-v', 'x=y', '-w', '$PWD', 'nextflow', '-it', '--', 'this', '--that'])
|
||||
then:
|
||||
cli.options == ['-v':'x=y', '-w':'$PWD', '-it':'']
|
||||
cli.container == 'nextflow'
|
||||
cli.args == ['this','--that']
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright 2013-2026, Seqera Labs
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package nextflow.processor
|
||||
|
||||
import nextflow.Global
|
||||
import nextflow.Session
|
||||
import nextflow.executor.Executor
|
||||
import spock.lang.Specification
|
||||
import spock.lang.Unroll
|
||||
|
||||
/**
|
||||
*
|
||||
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
|
||||
*/
|
||||
class TaskRunTest2 extends Specification {
|
||||
|
||||
@Unroll
|
||||
def 'should get container platform' () {
|
||||
given:
|
||||
def session = Global.session = Mock(Session) { getConfig()>>SESSION }
|
||||
def executor = Mock(Executor) { getSession()>>session }
|
||||
def processor = Mock(TaskProcessor) { getExecutor()>>executor; getSession()>>session }
|
||||
and:
|
||||
def config = new TaskConfig(CONFIG)
|
||||
def task = new TaskRun(config: config, processor: processor)
|
||||
|
||||
expect:
|
||||
task.getContainerPlatform() == EXPECTED
|
||||
|
||||
where:
|
||||
CONFIG | SESSION | EXPECTED
|
||||
[:] | [:] | null
|
||||
[arch:'amd64'] | [:] | 'linux/amd64'
|
||||
[arch:'arm64'] | [:] | 'linux/arm64'
|
||||
and:
|
||||
[:] | [wave:[enabled:true]] | 'linux/amd64'
|
||||
[arch:'amd64'] | [wave:[enabled:true]] | 'linux/amd64'
|
||||
[arch:'arm64'] | [wave:[enabled:true]] | 'linux/arm64'
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user