add nextflow d30e48d

This commit is contained in:
2026-04-29 23:01:54 +02:00
parent d0b12d668d
commit 97cc9058d3
2840 changed files with 730250 additions and 0 deletions

View 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)

View File

@@ -0,0 +1 @@
1.20.0

View 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"
}

View 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]

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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")
}
}

View File

@@ -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://')
}
}

View File

@@ -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)
}
}

View File

@@ -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()
}
}

View File

@@ -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);
}
}

View File

@@ -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$/,'')
}
}

View File

@@ -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'
}
}

View 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)
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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())
}
}

View File

@@ -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')
}
}

View File

@@ -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
}
}

View File

@@ -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 {
}

View File

@@ -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 {
}

View File

@@ -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 )
}
}

View File

@@ -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)
}
}

View File

@@ -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
}
}

View File

@@ -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()
}

View File

@@ -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;
}
}

View File

@@ -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
}
}

View File

@@ -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()
}
}

View File

@@ -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]'
}
}

View File

@@ -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

View File

@@ -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()
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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')
}
}

View File

@@ -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
}
}

View File

@@ -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'
}
}

View File

@@ -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)
}
}

View File

@@ -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()
}
}

View File

@@ -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()
}
}
}

View File

@@ -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
}
}

View File

@@ -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']
}
}

View File

@@ -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']
}
}

View File

@@ -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'
}
}