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,85 @@
# Kubernetes plugin for Nextflow
## Summary
The Kubernetes plugin provides native Kubernetes execution capability for Nextflow pipelines. It supports pod management, volume mounting, and resource allocation.
## Get Started
To use this plugin, add it to your `nextflow.config`:
```groovy
plugins {
id 'nf-k8s'
}
```
Configure the Kubernetes executor:
```groovy
process.executor = 'k8s'
k8s {
namespace = 'default'
serviceAccount = 'nextflow'
storageClaimName = 'nextflow-pvc'
}
```
The plugin automatically detects the Kubernetes configuration from:
- In-cluster configuration (when running inside a pod)
- `~/.kube/config` file
- `KUBECONFIG` environment variable
## Examples
### Basic Kubernetes Configuration
```groovy
plugins {
id 'nf-k8s'
}
process.executor = 'k8s'
k8s {
namespace = 'nextflow'
serviceAccount = 'nextflow-sa'
storageClaimName = 'nf-workdir-pvc'
storageMountPath = '/workspace'
}
workDir = '/workspace/work'
```
### Pod Configuration
```groovy
k8s {
namespace = 'nextflow'
pod = [
[volumeClaim: 'data-pvc', mountPath: '/data'],
[secret: 'aws-credentials', mountPath: '/root/.aws']
]
}
```
### Resource Requests
```groovy
process {
executor = 'k8s'
cpus = 2
memory = '4 GB'
pod = [[label: 'app', value: 'nextflow']]
}
```
## Resources
- [Kubernetes Executor Documentation](https://nextflow.io/docs/latest/kubernetes.html)
## License
[Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0)

View File

@@ -0,0 +1 @@
1.5.2

View File

@@ -0,0 +1,65 @@
/*
* 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 = '25.12.0-edge'
provider = "${nextflowPluginProvider}"
description = 'Provides native Kubernetes execution capability with advanced pod management, volume mounting, and resource allocation features'
className = 'nextflow.k8s.K8sPlugin'
useDefaultDependencies = false
generateSpec = false
extensionPoints = [
'nextflow.k8s.K8sConfig',
'nextflow.k8s.K8sExecutor',
'nextflow.k8s.cli.KubeCommandImpl',
]
}
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'
api 'org.bouncycastle:bcprov-ext-jdk18on:1.78.1'
api 'org.bouncycastle:bcpkix-jdk18on:1.84'
testImplementation(testFixtures(project(":nextflow")))
testImplementation "org.apache.groovy:groovy:4.0.31"
testImplementation "org.apache.groovy:groovy-nio:4.0.31"
}
test {
useJUnitPlatform()
}

View File

@@ -0,0 +1,39 @@
nf-k8s changelog
===================
1.5.2 - 20 Apr 2026
- Bump org.bouncycastle:bcpkix-jdk18on from 1.79 to 1.84 (#7042) [59d847d52]
- Allow running pipeline from URL and main script path (#6602) [83196d4be]
1.5.1 - 17 Mar 2026
- Fix K8s token refresh by caching K8sClient at executor level (#6925) [3d2e4c4c4]
1.5.0 - 8 Feb 2026
- Fix K8s job fallback to not return incorrect zero exit code (#6746) [573067999]
- Add time-based caching for K8sConfig.getClient() (#6742) [73e507558]
1.4.0 - 19 Dec 2025
- Implementation of Git multiple revisions (#6620) [ce9d7b592]
- Add runtimeClassName to the pod options (#6633) [ddcef4f45]
1.3.0 - 28 Nov 2025
- Do not delete K8s jobs when ttlSecondsAfterFinished is set (#6597) [51042dbe2]
- Optimize exit code handling by relying on scheduler status for successful executions (#6484) [454a2ae85]
1.2.2 - 21 Oct 2025
- Add .command.log redirection in K8s container command (#6455) [e6eed7949]
- Rename `config.schema` package to `config.spec` (#6485) [ef0d2d601]
1.2.1 - 8 Oct 2025
- Fix pod log warning with Fusion enabled (#6449) [8c78b3126]
- Get exit code from pod to manage OOM in k8s (#6442) [f258a758e]
1.1.1 - 15 Aug 2025
- Unify nf-lang config scopes with runtime classes (#6271) [bfa67ca3]
- Bump groovy 4.0.28 (#6304) [ci fast] [a468f8ef]
1.1.0 - 2 Jun 2025
- Add Failsafe retry mechanism in K8s (#6083) [ci fast] [9e675c6a]
- Bump Groovy to version 4.0.27 (#6125) [ci fast] [258e1790]
1.0.0 - 13 Apr 2025
- Refactor Kubernetes support as a core plugin

View File

@@ -0,0 +1,454 @@
/*
* 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.k8s
import nextflow.k8s.client.K8sRetryConfig
import javax.annotation.Nullable
import groovy.transform.CompileStatic
import groovy.transform.PackageScope
import groovy.util.logging.Slf4j
import nextflow.BuildInfo
import nextflow.config.spec.ConfigOption
import nextflow.config.spec.ConfigScope
import nextflow.config.spec.ScopeName
import nextflow.container.ContainerHelper
import nextflow.script.dsl.Description
import nextflow.exception.AbortOperationException
import nextflow.k8s.client.ClientConfig
import nextflow.k8s.client.K8sClient
import nextflow.k8s.client.K8sResponseException
import nextflow.k8s.model.PodOptions
import nextflow.k8s.model.PodSecurityContext
import nextflow.k8s.model.PodVolumeClaim
import nextflow.k8s.model.ResourceType
import nextflow.util.Duration
/**
* Model Kubernetes specific settings defined in the nextflow
* configuration file
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
@ScopeName("k8s")
@Description("""
The `k8s` scope controls the deployment and execution of workflow applications in a Kubernetes cluster.
""")
@Slf4j
@CompileStatic
class K8sConfig implements ConfigScope {
static final private Map<String,?> DEFAULT_FUSE_PLUGIN = Map.of('nextflow.io/fuse', 1)
@ConfigOption
@Description("""
Automatically mount host paths into the task pods (default: `false`). Only intended for development purposes when using a single node.
""")
final boolean autoMountHostPaths
@ConfigOption
@Description("""
Whether to use Kubernetes `Pod` or `Job` resource type to carry out Nextflow tasks (default: `Pod`).
""")
final String computeResourceType
@ConfigOption
@Description("""
When `true`, successful pods are automatically deleted (default: `true`).
""")
final private Boolean cleanup
@ConfigOption
@Description("""
Map of options for the K8s client.
If this option is specified, it will be used instead of `.kube/config`.
""")
final Map client
@ConfigOption
@Description("""
The interval after which the Kubernetes client configuration is refreshed (default: `50m`).
""")
final Duration clientRefreshInterval
@ConfigOption
@Description("""
The Kubernetes [configuration context](https://kubernetes.io/docs/tasks/access-application-cluster/configure-access-multiple-clusters/) to use.
""")
final String context
@ConfigOption
@Description("""
When `true`, set both the pod CPUs `request` and `limit` to the value specified by the `cpus` directive, otherwise set only the `request` (default: `false`).
""")
final boolean cpuLimits
final K8sDebug debug
@ConfigOption
@Description("""
Include the hostname of each task in the execution trace (default: `false`).
""")
final boolean fetchNodeName
@ConfigOption
@Description("""
The FUSE device plugin to be used when enabling Fusion in unprivileged mode (default: `['nextflow.io/fuse': 1]`).
""")
final Map fuseDevicePlugin
@ConfigOption
@Description("""
The Kubernetes HTTP client request connection timeout e.g. `'60s'`.
""")
final Duration httpConnectTimeout
@ConfigOption
@Description("""
The Kubernetes HTTP client request connection read timeout e.g. `'60s'`.
""")
final Duration httpReadTimeout
@ConfigOption
@Description("""
The strategy for pulling container images. Can be `IfNotPresent`, `Always`, `Never`.
[Read more](https://kubernetes.io/docs/concepts/containers/images/#image-pull-policy)
""")
final String imagePullPolicy
@ConfigOption
@Description("""
The path where the workflow is launched and the user data is stored (default: `<volume-claim-mount-path>/<user-name>`). Must be a path in a shared K8s persistent volume.
""")
final String launchDir
@ConfigOption
@Description("""
The Kubernetes namespace to use (default: `default`).
""")
final String namespace
@ConfigOption(types=[List, Map])
@Description("""
Additional pod configuration options such as environment variables, config maps, secrets, etc. Allows the same settings as the [pod](https://nextflow.io/docs/latest/process.html#pod) process directive.
""")
final PodOptions pod
@ConfigOption
@Description("""
The path where Nextflow projects are downloaded (default: `<volume-claim-mount-path>/projects`). Must be a path in a shared K8s persistent volume.
""")
final String projectDir
@Deprecated
@ConfigOption
@Description("""
""")
final String pullPolicy
final K8sRetryConfig retryPolicy
@ConfigOption(types=[Integer, String])
@Description("""
The user ID to be used to run the containers. Shortcut for the `securityContext` option.
""")
final Object runAsUser
@ConfigOption
@Description("""
The [security context](https://kubernetes.io/docs/tasks/configure-pod-container/security-context/) to use for all pods.
""")
final Map securityContext
@ConfigOption
@Description("""
The Kubernetes [service account name](https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/) to use.
""")
final String serviceAccount
@ConfigOption
@Description("""
The name of the persistent volume claim where the shared work directory is stored.
""")
final String storageClaimName
@ConfigOption
@Description("""
The mount path for the persistent volume claim (default: `/workspace`).
""")
final String storageMountPath
@ConfigOption
@Description("""
The path in the persistent volume to be mounted (default: `/`).
""")
final String storageSubPath
@ConfigOption
@Description("""
""")
final String userName
@ConfigOption
@Description("""
The path of the shared work directory (default: `<user-dir>/work`). Must be a path in a shared K8s persistent volume.
""")
final String workDir
/* required by extension point -- do not remove */
K8sConfig() {
this(Collections.emptyMap())
}
K8sConfig(Map opts) {
autoMountHostPaths = opts.autoMountHostPaths as boolean
cleanup = opts.cleanup as Boolean
client = opts.client as Map
clientRefreshInterval = opts.clientRefreshInterval as Duration ?: Duration.of('50m')
computeResourceType = opts.computeResourceType
context = opts.context
cpuLimits = opts.cpuLimits as boolean
debug = new K8sDebug(opts.debug as Map ?: Collections.emptyMap())
fetchNodeName = opts.fetchNodeName as boolean
fuseDevicePlugin = parseFuseDevicePlugin(opts.fuseDevicePlugin)
httpConnectTimeout = opts.httpConnectTimeout as Duration
httpReadTimeout = opts.httpReadTimeout as Duration
imagePullPolicy = opts.pullPolicy ?: opts.imagePullPolicy
namespace = opts.namespace
pod = createPodOptions(opts.pod)
retryPolicy = new K8sRetryConfig(opts.retryPolicy as Map ?: Collections.emptyMap())
runAsUser = opts.runAsUser
securityContext = opts.securityContext as Map
serviceAccount = opts.serviceAccount
storageClaimName = opts.storageClaimName
storageMountPath = opts.storageMountPath ?: '/workspace'
storageSubPath = opts.storageSubPath
userName = opts.userName
launchDir = opts.launchDir ?: "${storageMountPath}/${getUserName()}"
projectDir = opts.projectDir ?: "${storageMountPath}/projects"
workDir = opts.workDir ?: "${launchDir}/work"
// -- shortcut to pod image pull-policy
if( imagePullPolicy )
pod.imagePullPolicy = imagePullPolicy
// -- shortcut to pod volume claim
if( storageClaimName ) {
final volumeClaim = new PodVolumeClaim(storageClaimName, storageMountPath, storageSubPath)
pod.volumeClaims.add(volumeClaim)
}
// -- shortcut to pod security context
if( runAsUser )
pod.securityContext = new PodSecurityContext(runAsUser)
else if( securityContext )
pod.securityContext = new PodSecurityContext(securityContext)
}
private PodOptions createPodOptions( value ) {
if( value instanceof List )
return new PodOptions( value as List )
if( value instanceof Map )
return new PodOptions( [(Map)value] )
if( value == null )
return new PodOptions()
throw new IllegalArgumentException("Not a valid pod setting: $value")
}
Map<String,String> getLabels() {
pod.getLabels()
}
Map<String,String> getAnnotations() {
pod.getAnnotations()
}
boolean getCleanup(boolean defValue=true) {
cleanup == null ? defValue : cleanup
}
String getUserName() {
userName ?: System.properties.get('user.name')
}
Map<String,?> fuseDevicePlugin() {
fuseDevicePlugin
}
Map<String,?> parseFuseDevicePlugin(Object value) {
if( value instanceof Map && value.size()==1 )
return value as Map<String,?>
if( value != null )
log.warn1 "Setting 'k8s.fuseDevicePlugin' should be a map containing exactly one entry - offending value: $value"
return DEFAULT_FUSE_PLUGIN
}
/**
* Whenever the pod should honour the entrypoint defined by the image (default: false)
*
* @return When {@code false} the launcher script is run by using pod `command` attributes which
* overrides the entrypoint point defined by the image.
*
* When {@code true} the launcher is run via the pod `args` attribute, without altering the
* container entrypoint (it does however require to have a bash shell as the image entrypoint)
*
*/
boolean entrypointOverride() {
return ContainerHelper.entrypointOverride()
}
boolean useJobResource() { ResourceType.Job.name() == computeResourceType }
String getNextflowImageName() {
return "nextflow/nextflow:${BuildInfo.version}"
}
PodOptions getPodOptions() {
pod
}
boolean fetchNodeName() {
fetchNodeName
}
/**
* @return the collection of defined volume claim names
*/
Collection<String> getClaimNames() {
pod.volumeClaims.collect { it.claimName }
}
Collection<String> getClaimPaths() {
pod.volumeClaims.collect { it.mountPath }
}
boolean cpuLimitsEnabled() {
cpuLimits
}
/**
* Find a volume claim name given the mount path
*
* @param path The volume claim mount path
* @return The volume claim name for the given mount path
*/
String findVolumeClaimByPath(String path) {
final result = pod.volumeClaims.find { path.startsWith(it.mountPath) }
return result ? result.claimName : null
}
ClientConfig getClient() {
final result = client != null
? clientFromNextflow(client, namespace, serviceAccount)
: clientDiscovery(context, namespace, serviceAccount)
if( httpConnectTimeout )
result.httpConnectTimeout = httpConnectTimeout
if( httpReadTimeout )
result.httpReadTimeout = httpReadTimeout
if( retryPolicy )
result.retryConfig = retryPolicy
return result
}
/**
* Get the K8s client config from the declaration made in the Nextflow config file
*
* @param map
* A map representing the clint configuration options define in the nextflow
* config file
* @param namespace
* The K8s namespace to be used. If omitted {@code default} is used.
* @param serviceAccount
* The K8s service account to be used. If omitted {@code default} is used.
* @return
* The Kubernetes {@link ClientConfig} object
*/
@PackageScope ClientConfig clientFromNextflow(Map map, @Nullable String namespace, @Nullable String serviceAccount ) {
ClientConfig.fromNextflowConfig(map,namespace,serviceAccount)
}
/**
* Discover the K8s client config from the execution environment
* that can be either a `.kube/config` file or service meta file
* when running in a pod.
*
* @param contextName
* The name of the configuration context to be used
* @param namespace
* The Kubernetes namespace to be used
* @param serviceAccount
* The Kubernetes serviceAccount to be used
* @return
* The discovered Kube {@link ClientConfig} object
*/
@PackageScope ClientConfig clientDiscovery(String contextName, String namespace, String serviceAccount) {
ClientConfig.discover(contextName, namespace, serviceAccount)
}
void checkStorageAndPaths(K8sClient client) {
if( !storageClaimName )
throw new AbortOperationException("Missing K8s storage volume claim -- The name of a persistence volume claim needs to be provided in the nextflow configuration file")
log.debug "Kubernetes workDir=$workDir; projectDir=$projectDir; volumeClaims=${getClaimNames()}"
for( String name : getClaimNames() ) {
try {
client.volumeClaimRead(name)
}
catch (K8sResponseException e) {
if( e.response.code == 404 ) {
throw new AbortOperationException("Unknown volume claim: $name -- make sure a persistent volume claim with the specified name is defined in your K8s cluster")
}
else throw e
}
}
if( !findVolumeClaimByPath(launchDir) )
throw new AbortOperationException("Kubernetes `launchDir` must be a path mounted as a persistent volume -- launchDir=$launchDir; volumes=${getClaimPaths().join(', ')}")
if( !findVolumeClaimByPath(workDir) )
throw new AbortOperationException("Kubernetes `workDir` must be a path mounted as a persistent volume -- workDir=$workDir; volumes=${getClaimPaths().join(', ')}")
if( !findVolumeClaimByPath(projectDir) )
throw new AbortOperationException("Kubernetes `projectDir` must be a path mounted as a persistent volume -- projectDir=$projectDir; volumes=${getClaimPaths().join(', ')}")
}
static class K8sDebug implements ConfigScope {
@ConfigOption
@Description("""
Save the pod spec for each task to `.command.yaml` in the task directory (default: `false`).
""")
final boolean yaml
K8sDebug(Map opts) {
yaml = opts.yaml as boolean
}
}
}

View File

@@ -0,0 +1,703 @@
/*
* 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.k8s
import groovy.transform.MapConstructor
import java.lang.reflect.Field
import java.nio.file.NoSuchFileException
import java.nio.file.Path
import java.nio.file.Paths
import com.beust.jcommander.DynamicParameter
import com.beust.jcommander.Parameter
import com.google.common.hash.Hashing
import groovy.util.logging.Slf4j
import nextflow.cli.CmdKubeRun
import nextflow.cli.CmdRun
import nextflow.config.ConfigBuilder
import nextflow.exception.AbortOperationException
import nextflow.file.FileHelper
import nextflow.k8s.client.K8sClient
import nextflow.k8s.client.K8sResponseException
import nextflow.k8s.model.PodEnv
import nextflow.k8s.model.PodMountConfig
import nextflow.k8s.model.PodSpecBuilder
import nextflow.k8s.model.ResourceType
import nextflow.scm.AssetManager
import nextflow.scm.ProviderConfig
import nextflow.util.ConfigHelper
import nextflow.util.Escape
import org.codehaus.groovy.runtime.MethodClosure
/**
* Configure and submit the execution of pod running the Nextflow main application
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
@Slf4j
@MapConstructor(includeFields = true)
class K8sDriverLauncher {
/**
* Either a Pod or Job
*/
private ResourceType resourceType = ResourceType.Pod
/**
* Container image to be used for the Nextflow driver pod
*/
private String headImage
/**
* Request CPUs to be used for the Nextflow driver pod
*/
private int headCpus
/**
* Request memory to be used for the Nextflow driver pod
*/
private String headMemory
/**
* Pre-script to run before nextflow
*/
private String headPreScript
/**
* Nextflow execution run name
*/
private String runName
/**
* Workflow project to launch
*/
private String pipelineName
/**
* Command run options
*/
private CmdKubeRun cmd
/**
* Kubernetes client
*/
private K8sClient k8sClient
/**
* Nextflow resolved config object
*/
private ConfigObject config
/**
* Name of the config map used to propagate the nextflow
* setting in the container
*/
private String configMapName
/**
* Kubernetes specific config settings
*/
private K8sConfig k8sConfig
private String paramsFile
private boolean interactive
/**
* Runs in background mode
*/
private boolean background
/**
* Workflow script positional parameters
*/
private List<String> args
/**
* Plugins to run the workflow
*/
private String plugins
/**
* Launcher entry point. Set-up the environment and create a pod that run the Nextflow
* application (which in turns executed each task as a pod)
*
* @param name Workflow project entry name
* @param args Workflow script positional parameters
*/
void run(String name, List<String> args) {
this.args = args
this.pipelineName = name
this.interactive = name == 'login'
if( background && interactive )
throw new AbortOperationException("Option -bg conflicts with interactive mode")
this.config = makeConfig(pipelineName)
this.k8sConfig = makeK8sConfig(config.toMap())
this.k8sClient = makeK8sClient(k8sConfig)
this.k8sConfig.checkStorageAndPaths(k8sClient)
createK8sConfigMap()
createK8sLauncherPod()
waitPodStart()
// login into container session
if( interactive )
launchLogin()
// dump pod output
else if( !background )
printK8sPodOutput()
else
log.debug "Nextflow driver launched in background mode -- pod: $runName"
waitPodEnd()
}
int shutdown() {
if( background )
return 0
// fetch the container exit status
final exitCode = waitPodTermination()
// cleanup the config map if OK
def deleteOnSuccessByDefault = exitCode==0
if( k8sConfig.getCleanup(deleteOnSuccessByDefault) ) {
deleteConfigMap()
}
return exitCode
}
protected void waitPodEnd() {
if( background )
return
final currentState = k8sConfig.useJobResource() ? k8sClient.jobState(runName) : k8sClient.podState(runName)
if (currentState && currentState?.running instanceof Map) {
final name = runName
println "${resourceType} running: $name ... waiting for ${resourceType.lower()} to stop running"
try {
while( true ) {
sleep 10000
final state = k8sConfig.useJobResource() ? k8sClient.jobState(name) : k8sClient.podState(name)
if ( state && !(state?.running instanceof Map) ) {
println "${resourceType} $name has changed from running state $state"
break
}
}
}
catch( Exception e ) {
log.warn "Caught exception while waiting for ${resourceType.lower()} to stop running"
}
}
}
protected boolean isWaitTimedOut(long time) {
System.currentTimeMillis()-time > 90_000
}
protected int waitPodTermination() {
log.debug "Wait for ${resourceType.lower()} termination name=$runName"
final rnd = new Random()
final time = System.currentTimeMillis()
Map state = null
try {
while( true ) {
sleep rnd.nextInt(500)
state = k8sConfig.useJobResource() ? k8sClient.jobState(runName) : k8sClient.podState(runName)
if( state?.terminated instanceof Map )
return state.terminated.exitCode as int
else if( isWaitTimedOut(time) )
throw new IllegalStateException("Timeout waiting for ${resourceType.lower()} terminated state=$state")
}
}
catch( Exception e ) {
log.warn "Unable to fetch ${resourceType.lower()} exit status -- ${resourceType.lower()}=$runName state=$state"
return 127
}
}
protected void deleteConfigMap() {
try {
k8sClient.configDelete(configMapName)
log.debug "Deleted K8s configMap with name: $configMapName"
}
catch ( Exception e ) {
log.warn "Unable to delete configMap: $configMapName", e
}
}
protected void waitPodStart() {
final name = runName
print "${resourceType} submitted: $name .. waiting to start"
while( true ) {
sleep 1000
final state = k8sConfig.useJobResource() ? k8sClient.jobState(name) : k8sClient.podState(name)
if( state && !state.containsKey('waiting') ) {
break
}
}
print "\33[2K\r"
println "${resourceType} started: $name"
}
/**
* Wait for the driver pod creation and prints the log to the
* console standard output
*/
protected void printK8sPodOutput() {
if ( k8sConfig.useJobResource() )
k8sClient.jobLog(runName, follow:true).eachLine { println it }
else
k8sClient.podLog(runName, follow:true).eachLine { println it }
}
protected ConfigObject loadConfig( String pipelineName ) {
// -- load local config if available
final builder = new ConfigBuilder()
.setShowClosures(true)
.setOptions(cmd.launcher.options)
.setProfile(cmd.profile)
.setCmdRun(cmd)
if( !interactive && !pipelineName.startsWith('/') && !cmd.remoteProfile && !cmd.runRemoteConfig ) {
// -- check and parse project remote config
final pipelineConfig = new AssetManager(pipelineName, cmd.revision, cmd.mainScript, cmd).getConfigFile()
builder.setUserConfigFiles(pipelineConfig)
}
return builder.buildConfigObject()
}
protected K8sConfig makeK8sConfig(Map config) {
config.k8s instanceof Map ? new K8sConfig(config.k8s as Map) : new K8sConfig()
}
protected makeK8sClient( K8sConfig k8sConfig ) {
new K8sClient(k8sConfig.getClient())
}
/**
* Retrieve the workflow configuration and merge with the current local one.
*
* @param pipelineName Workflow project name
* @return A {@link Map} modeling the execution configuration settings
*/
protected ConfigObject makeConfig(String pipelineName) {
def file = new File(pipelineName)
if( !interactive && file.exists() ) {
def message = "The k8s executor cannot run local ${file.directory ? 'project' : 'script'}: $pipelineName"
message += " -- provide the absolute path of a project available in the Kubernetes cluster or the URL of a project hosted in a Git repository"
throw new AbortOperationException(message)
}
def config = loadConfig(pipelineName)
// normalize pod entries
def k8s = config.k8s
if( !k8s.isSet('pod') )
k8s.pod = []
else if( k8s.pod instanceof Map ) {
k8s.pod = [ k8s.pod ]
}
else if( !(k8s.pod instanceof List) )
throw new IllegalArgumentException("Illegal k8s.pod configuratun value: ${k8s.pod}")
// -- use the volume claims specified in the command line
// to populate the pod config
for( int i=0; i<cmd.volMounts?.size(); i++ ){
def entry = cmd.volMounts.get(i)
def parts = entry.tokenize(':')
def name = parts[0]
def path = parts[1]
if( i==0 ) {
k8s.storageClaimName = name
k8s.storageMountPath = path
}
else {
k8s.pod.add( [volumeClaim: name, mountPath: path] )
}
}
// -- backward compatibility
if( k8s.isSet('volumeClaims') ) {
log.warn "Config setting k8s.volumeClaims has been deprecated -- Use k8s.storageClaimName and k8s.storageMountPath instead"
k8s.volumeClaims.each { k,v ->
def name = k as String
def path = v instanceof Map ? v.mountPath : v.toString()
if( !k8s.isSet('storageClaimName') ) {
k8s.storageClaimName = name
k8s.storageMountPath = path
}
else if( !cmd.volMounts ) {
k8s.pod.add( [volumeClaim: name, mountPath: path] )
}
}
// remove it
k8s.remove('volumeClaims')
}
// -- set k8s executor
config.process.executor = 'k8s'
// -- strip default work dir
if( config.workDir == 'work' )
config.remove('workDir')
// -- check work dir
if( cmd?.workDir )
k8s.workDir = cmd.workDir
else if( !k8s.isSet('workDir') && config.workDir )
k8s.workDir = config.workDir
if ( plugins ) {
LinkedList<String> plugins = config.plugins ?: []
plugins.addAll( this.plugins.tokenize(',') )
config.plugins = plugins
}
// -- some cleanup
if( !k8s.pod )
k8s.remove('pod')
if( !k8s.storageClaimName )
k8s.remove('storageClaimName')
if( !k8s.storageMountPath )
k8s.remove('storageMountPath')
if( !config.libDir )
config.remove('libDir')
log.trace "K8s config object:\n${ConfigHelper.toCanonicalString(config).indent(' ')}"
return config
}
private Field getField(CmdRun cmd, String name) {
def clazz = cmd.class
while( clazz != CmdRun ) {
clazz = cmd.class.getSuperclass()
}
clazz.getDeclaredField(name)
}
private void checkUnsupportedOption(String name) {
def field = getField(cmd,name)
if( !field ) {
log.warn "Unknown command-line option to check: $name"
return
}
field.setAccessible(true)
if( field.get(cmd) ) {
def param = field.getAnnotation(Parameter)
def opt = param.names() ? param.names()[0] : "-$name"
abort(opt)
}
}
private void abort(String opt) {
throw new AbortOperationException("Option `$opt` not supported with Kubernetes deployment")
}
private void unsupportedCliOptions(MethodClosure... fields) {
unsupportedCliOptions( fields.collect { it.getMethod()} )
}
private void unsupportedCliOptions(List<String> names) {
for( String x : names ) {
checkUnsupportedOption(x)
}
}
private void addOption(List result, MethodClosure m, Closure eval=null) {
def name = m.getMethod()
def field = getField(cmd,name)
field.setAccessible(true)
def val = field.get(cmd)
if( ( eval ? eval(val) : val ) ) {
def param = field.getAnnotation(Parameter)
if( param ) {
result << "${param.names()[0]} ${Escape.wildcards(String.valueOf(val))}"
return
}
param = field.getAnnotation(DynamicParameter)
if( param && val instanceof Map ) {
val.each { k,v ->
result << "${param.names()[0]}$k ${Escape.wildcards(String.valueOf(v))}"
}
}
}
}
/**
* @return The nextflow driver command line
*/
protected String getLaunchCli() {
assert cmd
assert pipelineName
if( interactive ) {
return "tail -f /dev/null"
}
def result = []
// -- configure NF command line
result << "nextflow"
if( cmd.launcher.options.trace )
result << "-trace ${cmd.launcher.options.trace.join(',')}"
if( cmd.launcher.options.debug )
result << "-debug ${cmd.launcher.options.debug.join(',')}"
if( cmd.launcher.options.jvmOpts )
cmd.launcher.options.jvmOpts.each { k,v -> result << "-D$k=$v" }
result << "run"
result << pipelineName
if( runName )
result << '-name' << runName
addOption(result, cmd.&cacheable, { it==false } )
addOption(result, cmd.&resume )
addOption(result, cmd.&poolSize )
addOption(result, cmd.&pollInterval )
addOption(result, cmd.&queueSize)
addOption(result, cmd.&revision )
addOption(result, cmd.&latest )
addOption(result, cmd.&withTrace )
addOption(result, cmd.&withTimeline )
addOption(result, cmd.&withDag )
addOption(result, cmd.&dumpHashes )
addOption(result, cmd.&dumpChannels )
addOption(result, cmd.&env )
addOption(result, cmd.&process )
addOption(result, cmd.&params )
addOption(result, cmd.&entryName )
if( paramsFile ) {
result << "-params-file $paramsFile"
}
if ( cmd.runRemoteConfig )
cmd.runRemoteConfig.forEach { result << "-config $it" }
if ( cmd.remoteProfile )
result << "-profile ${cmd.remoteProfile}"
if( cmd.process?.executor )
abort('process.executor')
unsupportedCliOptions(
cmd.&libPath,
cmd.&test,
cmd.&executorOptions,
cmd.&stdin,
cmd.&withSingularity,
cmd.&withApptainer,
cmd.&withDocker,
cmd.&withoutDocker,
cmd.&withMpi,
cmd.&clusterOptions,
cmd.&exportSysEnv
)
if( args )
result.add(args)
return result.join(' ')
}
/**
* @return A {@link Map} modeling driver pod specification
*/
protected Map makeLauncherSpec() {
assert runName
assert k8sClient
// -- setup config file
String cmd = "source /etc/nextflow/init.sh; ${getLaunchCli()}"
// create the launcher pod
PodSpecBuilder builder = new PodSpecBuilder()
.withPodName(runName)
.withImageName(headImage ?: k8sConfig.getNextflowImageName())
.withCommand(['/bin/bash', '-c', cmd])
.withLabels([ app: 'nextflow', runName: runName ])
.withNamespace(k8sClient.config.namespace)
.withServiceAccount(k8sClient.config.serviceAccount)
.withPodOptions(k8sConfig.getPodOptions())
.withEnv( PodEnv.value('NXF_WORK', k8sConfig.getWorkDir()) )
.withEnv( PodEnv.value('NXF_ASSETS', k8sConfig.getProjectDir()) )
.withEnv( PodEnv.value('NXF_EXECUTOR', 'k8s'))
.withEnv( PodEnv.value('NXF_ANSI_LOG', 'false'))
.withMemory(headMemory?:"")
.withCpus(headCpus)
.withCpuLimits(k8sConfig.cpuLimitsEnabled())
if ( k8sConfig.useJobResource()) {
this.resourceType = ResourceType.Job
return builder.buildAsJob()
}
else {
return builder.build()
}
// note: do *not* set the work directory because it may need to be created by the init script
}
/**
* Creates and executes the nextflow driver pod
* @return A {@link nextflow.k8s.client.K8sResponseJson} response object
*/
protected createK8sLauncherPod() {
final spec = makeLauncherSpec()
if ( k8sConfig.useJobResource() ) {
k8sClient.jobCreate(spec, yamlDebugPath())
} else {
k8sClient.podCreate(spec, yamlDebugPath())
}
}
protected Path yamlDebugPath() {
boolean debug = config.k8s.debug?.yaml?.toString() == 'true'
final result = debug ? Paths.get(".nextflow.${resourceType.lower()}.yaml") : null
if( result )
log.info "Launcher ${resourceType.lower()} spec file: $result"
return result
}
protected Path getScmFile() {
ProviderConfig.getScmConfigPath()
}
String getPodImage() {
return podImage
}
int getHeadCpus() {
return headCpus
}
String getHeadMemory() {
return headMemory
}
String getRunName() {
return runName
}
CmdKubeRun getCmd() {
return cmd
}
protected String getPipelineName() {
return pipelineName
}
protected boolean getInteractive() {
return interactive
}
protected ConfigObject getConfig() {
return config
}
protected K8sConfig getK8sConfig() {
return k8sConfig
}
protected K8sClient getK8sClient() {
return k8sClient
}
/**
* Creates a K8s ConfigMap to share the nextflow configuration in the K8s cluster
*/
protected void createK8sConfigMap() {
Map<String,String> configMap = [:]
final launchDir = k8sConfig.getLaunchDir()
// init file
String initScript = ''
initScript += "mkdir -p '$launchDir'; if [ -d '$launchDir' ]; then cd '$launchDir'; else echo 'Cannot create directory: $launchDir'; exit 1; fi; "
initScript += '[ -f /etc/nextflow/scm ] && ln -s /etc/nextflow/scm $NXF_HOME/scm; '
initScript += '[ -f /etc/nextflow/nextflow.config ] && cp /etc/nextflow/nextflow.config $PWD/nextflow.config; '
if( headPreScript )
initScript += "[ -f '$headPreScript' ] && '$headPreScript'; "
configMap['init.sh'] = initScript
// nextflow config file
if( this.config ) {
configMap['nextflow.config'] = ConfigHelper.toCanonicalString( this.config )
}
// scm config file
final scmFile = getScmFile()
if( scmFile.exists() ) {
configMap['scm'] = scmFile.text
}
// params file
if( cmd.paramsFile ) {
final file = FileHelper.asPath(cmd.paramsFile)
if( !file.exists() ) throw new NoSuchFileException("Params file does not exist: $file")
configMap[ file.getName() ] = file.text
paramsFile = "/etc/nextflow/$file.name"
}
// create the config map
configMapName = makeConfigMapName(configMap)
tryCreateConfigMap(configMapName, configMap)
log.debug "Created K8s configMap with name: $configMapName"
k8sConfig.getPodOptions().getMountConfigMaps().add( new PodMountConfig(configMapName, '/etc/nextflow') )
}
protected void tryCreateConfigMap(String name, Map data) {
try {
k8sClient.configCreate(name, data)
}
catch( K8sResponseException e ) {
if( e.response.reason != 'AlreadyExists' )
throw e
}
}
protected String makeConfigMapName( Map configMap ) {
"nf-config-${hash(configMap.values())}"
}
protected String hash(Collection<String> text) {
def hasher = Hashing .murmur3_32() .newHasher()
def itr = text.iterator()
while( itr.hasNext() ) {
hasher.putUnencodedChars(itr.next())
}
return hasher.hash().toString()
}
protected void launchLogin() {
def launchDir = k8sConfig.getLaunchDir()
def cmd = "kubectl -n ${k8sClient.config.namespace} exec -it $runName -- /bin/bash -c 'cd $launchDir; exec bash --login -i'"
def proc = new ProcessBuilder().command('bash','-c',cmd).inheritIO().start()
def result = proc.waitFor()
if( result == 0 ) {
if ( k8sConfig.useJobResource() )
k8sClient.jobDelete(runName)
else
k8sClient.podDelete(runName)
}
}
}

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 nextflow.k8s
import java.util.concurrent.TimeUnit
import com.google.common.cache.Cache
import com.google.common.cache.CacheBuilder
import groovy.transform.CompileStatic
import groovy.transform.Memoized
import groovy.util.logging.Slf4j
import nextflow.executor.Executor
import nextflow.fusion.FusionHelper
import nextflow.k8s.client.K8sClient
import nextflow.processor.TaskHandler
import nextflow.processor.TaskMonitor
import nextflow.processor.TaskPollingMonitor
import nextflow.processor.TaskRun
import nextflow.util.Duration
import nextflow.util.ServiceName
import org.pf4j.ExtensionPoint
/**
* Implement the Kubernetes executor
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
@Slf4j
@CompileStatic
@ServiceName('k8s')
class K8sExecutor extends Executor implements ExtensionPoint {
/**
* Cache for the Kubernetes HTTP client. The client is refreshed periodically
* so that the service account token is re-read when it expires.
*/
private Cache<String, K8sClient> clientCache
/**
* @return The Kubernetes HTTP client. Delegates to a Guava cache that refreshes
* the client (including the service account token) when the configured interval expires.
*/
protected K8sClient getClient() {
clientCache.get('client', () -> new K8sClient(k8sConfig.getClient()))
}
/**
* @return The `k8s` configuration scope in the nextflow configuration object
*/
@Memoized
protected K8sConfig getK8sConfig() {
new K8sConfig( (Map<String,Object>)session.config.k8s )
}
/**
* Initialise the executor setting-up the kubernetes client configuration
*/
@Override
protected void register() {
super.register()
final k8sConfig = getK8sConfig()
final refreshInterval = k8sConfig.clientRefreshInterval
this.clientCache = CacheBuilder.newBuilder()
.expireAfterWrite(refreshInterval.toMillis(), TimeUnit.MILLISECONDS)
.build()
final client = getClient()
log.debug "[K8s] config=$k8sConfig; API client config=$client.config"
}
/**
* @return {@code true} since containerised execution is managed by Kubernetes
*/
boolean isContainerNative() {
return true
}
@Override
String containerConfigEngine() {
return 'docker'
}
/**
* @return A {@link TaskMonitor} associated to this executor type
*/
@Override
protected TaskMonitor createTaskMonitor() {
TaskPollingMonitor.create(session, config, name, 100, Duration.of('5 sec'))
}
/**
* Creates a {@link TaskHandler} for the given {@link TaskRun} instance
*
* @param task A {@link TaskRun} instance representing a process task to be executed
* @return A {@link K8sTaskHandler} instance modeling the execution in the K8s cluster
*/
@Override
TaskHandler createTaskHandler(TaskRun task) {
assert task
assert task.workDir
log.trace "[K8s] launching process > ${task.name} -- work folder: ${task.workDirStr}"
new K8sTaskHandler(task,this)
}
@Override
boolean isFusionEnabled() {
return FusionHelper.isFusionEnabled(session)
}
}

View File

@@ -0,0 +1,34 @@
/*
* 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.k8s
import groovy.transform.CompileStatic
import nextflow.plugin.BasePlugin
import org.pf4j.PluginWrapper
/**
* Kubernetes plugin entry point
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
@CompileStatic
class K8sPlugin extends BasePlugin {
K8sPlugin(PluginWrapper wrapper) {
super(wrapper)
}
}

View File

@@ -0,0 +1,558 @@
/*
* 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.k8s
import java.nio.file.FileAlreadyExistsException
import java.nio.file.Files
import java.nio.file.Path
import java.time.Instant
import java.time.format.DateTimeFormatter
import groovy.transform.CompileDynamic
import groovy.transform.CompileStatic
import groovy.util.logging.Slf4j
import nextflow.SysEnv
import nextflow.container.ContainerHelper
import nextflow.container.DockerBuilder
import nextflow.exception.NodeTerminationException
import nextflow.k8s.client.PodUnschedulableException
import nextflow.exception.ProcessSubmitException
import nextflow.executor.BashWrapperBuilder
import nextflow.fusion.FusionAwareTask
import nextflow.k8s.client.K8sClient
import nextflow.k8s.client.K8sResponseException
import nextflow.k8s.model.PodEnv
import nextflow.k8s.model.PodOptions
import nextflow.k8s.model.PodSpecBuilder
import nextflow.k8s.model.ResourceType
import nextflow.processor.TaskHandler
import nextflow.processor.TaskRun
import nextflow.processor.TaskStatus
import nextflow.trace.TraceRecord
import nextflow.util.Escape
import nextflow.util.PathTrie
import nextflow.util.TestOnly
/**
* Implements the {@link TaskHandler} interface for Kubernetes pods
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
@Slf4j
@CompileStatic
class K8sTaskHandler extends TaskHandler implements FusionAwareTask {
@Lazy
static private final String OWNER = {
if( System.getenv('NXF_OWNER') ) {
return System.getenv('NXF_OWNER')
}
else {
def p = ['bash','-c','echo -n $(id -u):$(id -g)'].execute();
p.waitFor()
return p.text
}
} ()
private ResourceType resourceType = ResourceType.Pod
private K8sClient client
private String podName
private BashWrapperBuilder builder
private Path outputFile
private Path errorFile
private Path exitFile
private Map state
private long timestamp
private K8sExecutor executor
private String runsOnNode = null
K8sTaskHandler( TaskRun task, K8sExecutor executor ) {
super(task)
this.executor = executor
this.client = executor.getClient()
this.outputFile = task.workDir.resolve(TaskRun.CMD_OUTFILE)
this.errorFile = task.workDir.resolve(TaskRun.CMD_ERRFILE)
this.exitFile = task.workDir.resolve(TaskRun.CMD_EXIT)
this.resourceType = executor.k8sConfig.useJobResource() ? ResourceType.Job : ResourceType.Pod
}
@TestOnly
protected K8sTaskHandler() {}
/**
* @return The workflow execution unique run name
*/
protected String getRunName() {
executor.session.runName
}
protected String getPodName() {
return podName
}
protected K8sConfig getK8sConfig() { executor.getK8sConfig() }
protected boolean useJobResource() { resourceType==ResourceType.Job }
protected List<String> getContainerMounts() {
if( !k8sConfig.getAutoMountHostPaths() ) {
return Collections.<String>emptyList()
}
// get input files paths
final List<Path> paths = DockerBuilder.inputFilesToPaths(builder.getInputFiles())
final List<Path> binDirs = builder.binDirs
final Path workDir = builder.workDir
// add standard paths
if( binDirs )
paths.addAll(binDirs)
if( workDir )
paths << workDir
def trie = new PathTrie()
paths.each { trie.add(it) }
// defines the mounts
trie.longest()
}
protected BashWrapperBuilder createBashWrapper(TaskRun task) {
return fusionEnabled()
? fusionLauncher()
: new K8sWrapperBuilder(task)
}
protected List<String> classicSubmitCli(TaskRun task) {
final workDir = Escape.path(task.workDir)
final result = new ArrayList(BashWrapperBuilder.BASH)
result.add('-o')
result.add('pipefail')
result.add('-c')
result.add("bash ${workDir}/${TaskRun.CMD_RUN} 2>&1 | tee ${workDir}/${TaskRun.CMD_LOG}")
return result
}
protected List<String> getSubmitCommand(TaskRun task) {
return fusionEnabled()
? fusionSubmitCli()
: classicSubmitCli(task)
}
protected String getSyntheticPodName(TaskRun task) {
final suffix = System.currentTimeMillis().toString().md5()[-5..-1]
return "nf-${task.hash}-${suffix}"
}
protected String getOwner() { OWNER }
protected Boolean fixOwnership() {
ContainerHelper.fixOwnership(task.containerConfig)
}
/**
* Creates a Pod specification that executed that specified task
*
* @param task A {@link TaskRun} instance representing the task to execute
* @return A {@link Map} object modeling a Pod specification
*/
protected Map newSubmitRequest(TaskRun task) {
def imageName = task.container
if( !imageName )
throw new ProcessSubmitException("Missing container image for process `$task.processor.name`")
try {
newSubmitRequest0(task, imageName)
}
catch( Throwable e ) {
throw new ProcessSubmitException("Failed to submit K8s ${resourceType.lower()} -- Cause: ${e.message ?: e}", e)
}
}
protected boolean entrypointOverride() {
return executor.getK8sConfig().entrypointOverride()
}
protected boolean cpuLimitsEnabled() {
return executor.getK8sConfig().cpuLimitsEnabled()
}
protected Map newSubmitRequest0(TaskRun task, String imageName) {
final launcher = getSubmitCommand(task)
final taskCfg = task.getConfig()
final clientConfig = client.config
final builder = new PodSpecBuilder()
.withImageName(imageName)
.withPodName(getSyntheticPodName(task))
.withNamespace(clientConfig.namespace)
.withServiceAccount(clientConfig.serviceAccount)
.withLabels(getLabels(task))
.withAnnotations(getAnnotations())
.withPodOptions(getPodOptions())
.withCpuLimits(cpuLimitsEnabled())
// when `entrypointOverride` is false the launcher is run via `args` instead of `command`
// to not override the container entrypoint
if( !entrypointOverride() ) {
builder.withArgs(launcher)
}
else {
builder.withCommand(launcher)
}
// note: task environment is managed by the task bash wrapper
// do not add here -- see also #680
if( fixOwnership() )
builder.withEnv(PodEnv.value('NXF_OWNER', getOwner()))
if( SysEnv.containsKey('NXF_DEBUG') )
builder.withEnv(PodEnv.value('NXF_DEBUG', SysEnv.get('NXF_DEBUG')))
// add computing resources
final cpus = taskCfg.getCpus()
final mem = taskCfg.getMemory()
final disk = taskCfg.getDisk()
final acc = taskCfg.getAccelerator()
if( cpus )
builder.withCpus(cpus)
if( mem )
builder.withMemory(mem)
if( disk )
builder.withDisk(disk)
if( acc )
builder.withAccelerator(acc)
final List<String> hostMounts = getContainerMounts()
for( String mount : hostMounts ) {
builder.withHostMount(mount,mount)
}
if ( taskCfg.time ) {
final duration = taskCfg.getTime()
builder.withActiveDeadline(duration.toSeconds() as int)
}
if ( fusionEnabled() ) {
if( fusionConfig().privileged() )
builder.withPrivileged(true)
else {
final device= k8sConfig.fuseDevicePlugin()
builder.withResourcesLimits(device)
}
final env = fusionLauncher().fusionEnv()
for( Map.Entry<String,String> it : env )
builder.withEnv(PodEnv.value(it.key, it.value))
}
return useJobResource()
? builder.buildAsJob()
: builder.build()
}
protected PodOptions getPodOptions() {
// merge the pod options provided in the k8s config
// with the ones in process config
def opt1 = k8sConfig.getPodOptions()
def opt2 = taskPodOptions()
return opt1 + opt2
}
protected PodOptions taskPodOptions() {
new PodOptions((List)task.getConfig().get('pod'))
}
protected Map<String,String> getLabels(TaskRun task) {
final result = new LinkedHashMap<String,String>(10)
final labels = k8sConfig.getLabels()
if( labels ) {
result.putAll(labels)
}
final resLabels = task.config.getResourceLabels()
if( resLabels )
result.putAll(resLabels)
result.'nextflow.io/app' = 'nextflow'
result.'nextflow.io/runName' = getRunName()
result.'nextflow.io/taskName' = task.getName()
result.'nextflow.io/processName' = task.getProcessor().getName()
result.'nextflow.io/sessionId' = "uuid-${executor.getSession().uniqueId}" as String
if( task.config.queue )
result.'nextflow.io/queue' = task.config.queue
return result
}
protected Map getAnnotations() {
k8sConfig.getAnnotations()
}
/**
* Creates a new K8s pod executing the associated task
*/
@Override
@CompileDynamic
void submit() {
builder = createBashWrapper(task)
builder.build()
final req = newSubmitRequest(task)
final resp = useJobResource()
? client.jobCreate(req, yamlDebugPath())
: client.podCreate(req, yamlDebugPath())
if( !resp.metadata?.name )
throw new K8sResponseException("Missing created ${resourceType.lower()} name", resp)
this.podName = resp.metadata.name
this.status = TaskStatus.SUBMITTED
}
@CompileDynamic
protected Path yamlDebugPath() {
boolean debug = k8sConfig.getDebug().getYaml()
return debug ? task.workDir.resolve('.command.yaml') : null
}
/**
* @return Retrieve the submitted pod state
*/
protected Map getState() {
final now = System.currentTimeMillis()
try {
final delta = now - timestamp;
if( !state || delta >= 1_000) {
def newState = useJobResource()
? client.jobState(podName)
: client.podState(podName)
if( newState ) {
log.trace "[K8s] Get ${resourceType.lower()}=$podName state=$newState"
state = newState
timestamp = now
}
}
return state
}
catch (NodeTerminationException | PodUnschedulableException e) {
// create a synthetic `state` object adding an extra `nodeTermination`
// attribute to return the error to the caller method
final instant = Instant.now()
final result = new HashMap(10)
result.terminated = [startedAt:instant.toString(), finishedAt:instant.toString()]
result.nodeTermination = e
timestamp = now
state = result
return state
}
}
@Override
boolean checkIfRunning() {
if( !podName ) throw new IllegalStateException("Missing K8s ${resourceType.lower()} name -- cannot check if running")
if(isSubmitted()) {
def state = getState()
// include `terminated` state to allow the handler status to progress
if (state && (state.running != null || state.terminated)) {
status = TaskStatus.RUNNING
determineNode()
return true
}
}
return false
}
long getEpochMilli(String timeString) {
final time = DateTimeFormatter.ISO_INSTANT.parse(timeString)
return Instant.from(time).toEpochMilli()
}
/**
* Update task start and end times based on pod timestamps.
* We update timestamps because it's possible for a task to run so quickly
* (less than 1 second) that it skips right over the RUNNING status.
* If this happens, the startTimeMillis never gets set and remains equal to 0.
* To make sure startTimeMillis is non-zero we update it with the pod start time.
* We update completeTimeMillis from the same pod info to be consistent.
*/
void updateTimestamps(Map terminated) {
try {
startTimeMillis = getEpochMilli(terminated.startedAt as String)
completeTimeMillis = getEpochMilli(terminated.finishedAt as String)
} catch( Exception e ) {
log.debug "Failed updating timestamps '${terminated.toString()}'", e
// Only update if startTimeMillis hasn't already been set.
// If startTimeMillis _has_ been set, then both startTimeMillis
// and completeTimeMillis will have been set with the normal
// TaskHandler mechanism, so there's no need to reset them here.
if (!startTimeMillis) {
startTimeMillis = System.currentTimeMillis()
completeTimeMillis = System.currentTimeMillis()
}
}
}
@Override
boolean checkIfCompleted() {
if( !podName )
throw new IllegalStateException("Missing K8s ${resourceType.lower()} name - cannot check if complete")
final state = getState()
if( state && state.terminated ) {
if( state.nodeTermination instanceof NodeTerminationException ||
state.nodeTermination instanceof PodUnschedulableException ) {
// keep track of the node termination error
task.error = (Throwable) state.nodeTermination
// mark the task as ABORTED since thr failure is caused by a node failure
task.aborted = true
}
else {
// finalize the task
// read the exit code from the K8s container terminated state, if missing
// take the exit code from the `.exitcode` file created by nextflow
// the rationale is that in case of error (e.g. OOMKilled, pod eviction), the exit code from
// the K8s API is more reliable because the container may terminate before the exit file is written
// See https://github.com/nextflow-io/nextflow/issues/6436
// https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#containerstateterminated-v1-core
log.trace("[k8s] Container Terminated state ${state.terminated}")
final k8sExitCode = (state.terminated as Map)?.exitCode as Integer
task.exitStatus = k8sExitCode != null ? k8sExitCode : readExitFile()
task.stdout = outputFile
task.stderr = errorFile
}
status = TaskStatus.COMPLETED
saveJobLogOnError(task)
deleteJobIfSuccessful(task)
updateTimestamps(state.terminated as Map)
determineNode()
return true
}
return false
}
protected void saveJobLogOnError(TaskRun task) {
if( task.isSuccess() )
return
if( errorFile && !errorFile.empty() )
return
final session = executor.getSession()
if( session.isAborted() || session.isCancelled() || session.isTerminated() )
return
try {
final stream = useJobResource()
? client.jobLog(podName)
: client.podLog(podName)
Files.copy(stream, task.workDir.resolve(TaskRun.CMD_LOG))
}
catch( FileAlreadyExistsException e ) {
log.debug "Log file already exists for ${resourceType.lower()} $podName", e
}
catch( Exception e ) {
log.warn "Failed to copy log for ${resourceType.lower()} $podName", e
}
}
protected int readExitFile() {
try {
exitFile.text as Integer
}
catch( Exception e ) {
log.debug "[K8s] Cannot read exitstatus for task: `$task.name` | ${e.message}"
return Integer.MAX_VALUE
}
}
/**
* Terminates the current task execution
*/
@Override
protected void killTask() {
if( !podName )
return
if( cleanupDisabled() )
return
log.trace "[K8s] deleting ${resourceType.lower()} name=$podName"
delete0(podName)
}
protected boolean cleanupDisabled() {
!k8sConfig.getCleanup()
}
protected void deleteJobIfSuccessful(TaskRun task) {
if( !podName )
return
if( cleanupDisabled() )
return
// preserve failed pods for debugging purposes
if( !task.isSuccess() )
return
// k8s cluster will cleanup job on its own if TTL is set
if( useJobResource() && getPodOptions().getTtlSecondsAfterFinished() != null )
return
delete0(podName)
}
private void delete0(String podName) {
try {
if ( useJobResource() )
client.jobDelete(podName)
else
client.podDelete(podName)
}
catch( Exception e ) {
log.warn "Unable to delete ${resourceType.lower()}: $podName -- see the log file for details", e
}
}
private void determineNode() {
try {
if ( k8sConfig.fetchNodeName() && !runsOnNode )
runsOnNode = client.getNodeOfPod( podName )
} catch ( Exception e ) {
log.warn ("Unable to get the node name of pod $podName -- see the log file for details", e)
}
}
TraceRecord getTraceRecord() {
final result = super.getTraceRecord()
result.put('native_id', podName)
result.put( 'hostname', runsOnNode )
return result
}
}

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 nextflow.k8s
import groovy.transform.CompileStatic
import nextflow.executor.BashWrapperBuilder
import nextflow.processor.TaskRun
import nextflow.util.Escape
/**
* Implements a BASH wrapper for tasks executed by kubernetes cluster
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
@CompileStatic
class K8sWrapperBuilder extends BashWrapperBuilder {
K8sWrapperBuilder(TaskRun task) {
super(task)
this.headerScript = "NXF_CHDIR=${Escape.path(task.workDir)}"
}
}

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 nextflow.k8s.cli
import groovy.transform.CompileStatic
import nextflow.cli.CmdKubeRun
import nextflow.k8s.K8sDriverLauncher
/**
* Kuberun command implementation logic
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
@CompileStatic
class KubeCommandImpl implements CmdKubeRun.KubeCommand {
@Override
int run(CmdKubeRun cmd, String pipeline, List<String> args) {
// create
final driver = new K8sDriverLauncher(
cmd: cmd,
runName: cmd.runName,
headImage: cmd.headImage,
background: cmd.background(),
headCpus: cmd.headCpus,
headMemory: cmd.headMemory,
headPreScript: cmd.headPreScript,
plugins: cmd.plugins )
// run it
driver.run(pipeline, args)
// return exit code
return driver.shutdown()
}
}

View File

@@ -0,0 +1,177 @@
/*
* 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.k8s.client
import groovy.util.logging.Slf4j
import nextflow.util.Duration
import javax.net.ssl.KeyManager
import java.nio.file.Path
import java.nio.file.Paths
import groovy.transform.CompileStatic
import groovy.transform.EqualsAndHashCode
/**
* Models the kubernetes cluster client configuration settings
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
@EqualsAndHashCode
@CompileStatic
@Slf4j
class ClientConfig {
boolean verifySsl
String server
String namespace
/**
* k8s service account name
* https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/
*/
String serviceAccount
String token
byte[] sslCert
byte[] clientCert
byte[] clientKey
KeyManager[] keyManagers
K8sRetryConfig retryConfig
/**
* Timeout when reading from Input stream when a connection is established to a resource.
* If the timeout expires before there is data available for read, a {@link java.net.SocketTimeoutException} is raised
*/
Duration httpReadTimeout
/**
* Timeout when opening a communications link to the resource referenced by K8sClient request connection
* If the timeout expires before there is data available for read, a {@link java.net.SocketTimeoutException} is raised
*/
Duration httpConnectTimeout
/**
* When true signal that the configuration was retrieved from within a K8s cluster
*/
boolean isFromCluster
String getNamespace() { namespace ?: 'default' }
ClientConfig() {
retryConfig = new K8sRetryConfig()
}
String toString() {
"${this.class.getSimpleName()}[ server=$server, namespace=$namespace, serviceAccount=$serviceAccount, token=${cut(token)}, sslCert=${cut(sslCert)}, clientCert=${cut(clientCert)}, clientKey=${cut(clientKey)}, verifySsl=$verifySsl, fromFile=$isFromCluster, httpReadTimeout=$httpReadTimeout, httpConnectTimeout=$httpConnectTimeout, retryConfig=$retryConfig ]"
}
private String cut(String str) {
if( !str ) return '-'
return str.size()<10 ? str : str[0..10].toString() + '..'
}
private String cut(byte[] bytes) {
if( !bytes ) return '-'
cut(bytes.encodeBase64().toString())
}
static ClientConfig discover(String context, String namespace, String serviceAccount) {
new ConfigDiscovery().discover(context, namespace, serviceAccount)
}
static ClientConfig fromNextflowConfig(Map opts, String namespace, String serviceAccount) {
final result = new ClientConfig()
if( opts.server )
result.server = opts.server
if( opts.token )
result.token = opts.token
else if( opts.tokenFile )
result.token = Paths.get(opts.tokenFile.toString()).getText('UTF-8')
result.namespace = namespace ?: opts.namespace ?: 'default'
result.serviceAccount = serviceAccount ?: 'default'
if( opts.verifySsl )
result.verifySsl = opts.verifySsl as boolean
if( opts.sslCert )
result.sslCert = opts.sslCert.toString().decodeBase64()
else if( opts.sslCertFile )
result.sslCert = Paths.get(opts.sslCertFile.toString()).bytes
if( opts.clientCert )
result.clientCert = opts.clientCert.toString().decodeBase64()
else if( opts.clientCertFile )
result.clientCert = Paths.get(opts.clientCertFile.toString()).bytes
if( opts.clientKey )
result.clientKey = opts.clientKey.toString().decodeBase64()
else if( opts.clientKeyFile )
result.clientKey = Paths.get(opts.clientKeyFile.toString()).bytes
return result
}
static ClientConfig fromUserAndCluster(Map user, Map cluster, Path location) {
final base = location.isDirectory() ? location : location.parent
final result = new ClientConfig()
if( user.token )
result.token = user.token
else if( user.tokenFile ) {
result.token = Paths.get(user.tokenFile.toString()).getText('UTF-8')
}
if( user."client-certificate" )
result.clientCert = base.resolve(user."client-certificate".toString()).bytes
else if( user."client-certificate-data" )
result.clientCert = user."client-certificate-data".toString().decodeBase64()
if( user."client-key" )
result.clientKey = base.resolve(user."client-key".toString()).bytes
else if( user."client-key-data" )
result.clientKey = user."client-key-data".toString().decodeBase64()
// -- cluster settings
if( cluster.server )
result.server = cluster.server
if( cluster."certificate-authority-data" )
result.sslCert = cluster."certificate-authority-data".toString().decodeBase64()
else if( cluster."certificate-authority" )
result.sslCert = base.resolve(cluster."certificate-authority".toString()).bytes
result.verifySsl = cluster."insecure-skip-tls-verify" != true
return result
}
}

View File

@@ -0,0 +1,192 @@
/*
* 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.k8s.client
import javax.net.ssl.KeyManager
import javax.net.ssl.KeyManagerFactory
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.security.KeyStore
import static nextflow.util.StringUtils.formatHostName
import groovy.util.logging.Slf4j
import org.yaml.snakeyaml.Yaml
/**
* Discover Kubernetes configuration from system environment
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
@Slf4j
class ConfigDiscovery {
private Map<String,String> env = System.getenv()
ConfigDiscovery() { }
/**
* Discover Kubernetes client configuration from current environment using
* either the .kube/config file or the pod service service account virtual
* file system when running in a pod.
*
* @param contextName The K8s config context name.
* @param namespace The K8s cluster namespace
* @param serviceAccount The K8s cluster service account
* @return The e
*/
ClientConfig discover(String contextName, String namespace, String serviceAccount) {
// Note: System.getProperty('user.home') may not report the correct home path when
// running in a container. Use env HOME instead.
def home = System.getenv('HOME')
def kubeConfig = env.get('KUBECONFIG') ? env.get('KUBECONFIG') : "$home/.kube/config"
def configFile = Paths.get(kubeConfig)
// determine the Kubernetes client configuration via the `.kube/config` file
if( configFile.exists() ) {
return fromKubeConfig(configFile, contextName, namespace, serviceAccount)
}
else {
log.debug "K8s config file does not exist: $configFile"
}
// determine the Kubernetes client configuration via the pod environment
if( env.get('KUBERNETES_SERVICE_HOST') ) {
return fromCluster(env, namespace, serviceAccount)
}
else {
log.debug "K8s env variable KUBERNETES_SERVICE_HOST is not defined"
}
throw new IllegalStateException("Unable to lookup Kubernetes cluster configuration")
}
protected ClientConfig fromCluster(Map<String,String> env, String cfgNamespace, String serviceAccount) {
// See https://kubernetes.io/docs/tasks/access-application-cluster/access-cluster/#accessing-the-api-from-a-pod
final host = env.get('KUBERNETES_SERVICE_HOST')
final port = env.get('KUBERNETES_SERVICE_PORT')
final server = formatHostName(host, port)
final cert = path('/var/run/secrets/kubernetes.io/serviceaccount/ca.crt').bytes
final token = path('/var/run/secrets/kubernetes.io/serviceaccount/token').text
final namespace = path('/var/run/secrets/kubernetes.io/serviceaccount/namespace').text
return new ClientConfig(
server: server,
token: token,
namespace: cfgNamespace ?: namespace,
serviceAccount: serviceAccount,
sslCert: cert,
isFromCluster: true )
}
protected Path path(String path) {
Paths.get(path)
}
protected ClientConfig fromKubeConfig(Path path, String contextName, String namespace, String serviceAccount) {
def yaml = (Map)new Yaml().load(Files.newInputStream(path))
contextName ?= yaml."current-context" as String
final allContext = yaml.contexts as List
final allClusters = yaml.clusters as List
final allUsers = yaml.users as List
final context = allContext.find { Map it -> it.name == contextName } ?.context
if( !context )
throw new IllegalArgumentException("Unknown Kubernetes context: $contextName -- check config file: $path")
final userName = context?.user
final clusterName = context?.cluster
final user = allUsers.find{ Map it -> it.name == userName } ?.user ?: [:]
final cluster = allClusters.find{ Map it -> it.name == clusterName } ?.cluster ?: [:]
final config = ClientConfig.fromUserAndCluster(user, cluster, path)
// the namespace provided should have priority over the context current namespace
config.namespace = namespace ?: context?.namespace ?: 'default'
config.serviceAccount = serviceAccount ?: 'default'
if( config.clientCert && config.clientKey ) {
config.keyManagers = createKeyManagers(config.clientCert, config.clientKey)
}
else if( !config.token ) {
config.token = discoverAuthToken(contextName, config.namespace, config.serviceAccount)
}
return config
}
protected KeyStore createKeyStore0(byte[] clientCert, byte[] clientKey, char[] passphrase, String alg) {
def cert = new ByteArrayInputStream(clientCert)
def key = new ByteArrayInputStream(clientKey)
return SSLUtils.createKeyStore(cert, key, alg, passphrase, null, null)
}
protected KeyStore createKeyStore(byte[] clientCert, byte[] clientKey, char[] passphrase) {
try {
// try first RSA algorithm
return createKeyStore0(clientCert, clientKey, passphrase, "RSA")
}
catch (Exception e1) {
// fallback to EC algorithm
try {
return createKeyStore0(clientCert, clientKey, passphrase, "EC")
}
catch (Exception e2) {
// if still fails, throws the first exception
throw e1
}
}
}
protected KeyManager[] createKeyManagers(byte[] clientCert, byte[] clientKey) {
final passphrase = "".toCharArray()
final keyStore = createKeyStore(clientCert, clientKey, passphrase)
final kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
kmf.init(keyStore, passphrase);
return kmf.getKeyManagers();
}
String discoverAuthToken(String context, String namespace, String serviceAccount) {
context ?= 'default'
namespace ?= 'default'
serviceAccount ?= 'default'
final cmd = "kubectl --context $context -n ${namespace} get secret -o=jsonpath='{.items[?(@.metadata.annotations.kubernetes\\.io/service-account\\.name==\"$serviceAccount\")].data.token}'"
final proc = new ProcessBuilder('bash','-o','pipefail','-c', cmd).start()
final status = proc.waitFor()
final text = proc.inputStream?.text
if( status==0 && text ) {
try {
return new String(text.trim().decodeBase64())
}
catch( Exception e ) {
log.warn "Unable to decode K8s cluster auth token '$text' -- cause: ${e.message}"
}
}
else {
final cause = proc.errorStream?.text ?: text
final msg = cause ? "\n- cmd : $cmd\n- exit : $status\n- cause:\n${cause.indent(' ')}" : ''
log.warn "[K8s] unable to fetch auth token ${msg}"
}
return null
}
}

View File

@@ -0,0 +1,789 @@
/*
* 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.k8s.client
import dev.failsafe.Failsafe
import dev.failsafe.FailsafeException
import dev.failsafe.RetryPolicy
import dev.failsafe.event.EventListener
import dev.failsafe.event.ExecutionAttemptedEvent
import dev.failsafe.function.CheckedSupplier
import nextflow.exception.K8sOutOfCpuException
import nextflow.exception.K8sOutOfMemoryException
import javax.net.ssl.HostnameVerifier
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLSession
import javax.net.ssl.TrustManager
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager
import java.nio.file.Path
import java.security.KeyStore
import java.security.SecureRandom
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import groovy.json.JsonOutput
import groovy.transform.CompileStatic
import groovy.util.logging.Slf4j
import nextflow.exception.NodeTerminationException
import nextflow.exception.ProcessFailedException
import org.yaml.snakeyaml.Yaml
import java.time.temporal.ChronoUnit
import java.util.concurrent.TimeoutException
import java.util.function.Predicate
/**
* Kubernetes API client
*
* Tip: use the following command to find out your kubernetes master node
* kubectl cluster-info
*
* See
* https://v1-8.docs.kubernetes.io/docs/api-reference/v1.8/#-strong-api-overview-strong-
*
* Useful cheatsheet
* https://kubernetes.io/docs/reference/kubectl/cheatsheet/
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
@Slf4j
@CompileStatic
class K8sClient {
protected ClientConfig config
private TrustManager[] trustManagers
private HostnameVerifier hostnameVerifier
K8sClient() {
this(new ClientConfig())
}
ClientConfig getConfig() { config }
/**
* Creates a kubernetes client using the configuration setting provided by the specified
* {@link ConfigDiscovery} instance
*
* @param config
*/
K8sClient(ClientConfig config) {
this.config = config
setupSslCert()
}
protected setupSslCert() {
if( !config.verifySsl ) {
// -- no SSL is required - use fake trust manager
final trustAll = new X509TrustManager() {
@Override X509Certificate[] getAcceptedIssuers() { return null }
@Override void checkClientTrusted(X509Certificate[] certs, String authType) { }
@Override void checkServerTrusted(X509Certificate[] certs, String authType) { }
}
trustManagers = [trustAll] as TrustManager[]
hostnameVerifier = new HostnameVerifier() {
@Override boolean verify(String hostname, SSLSession session) { return true }
}
}
else if ( config.sslCert != null) {
char[] password = null
final factory = CertificateFactory.getInstance("X.509");
final authority = new ByteArrayInputStream(config.sslCert)
final certificates = factory.generateCertificates(authority)
if (certificates.isEmpty()) {
throw new IllegalArgumentException("Trusted certificates set cannot be empty");
}
final keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
keyStore.load(null, password);
certificates.eachWithIndex{ cert, index ->
String alias = "ca$index"
keyStore.setCertificateEntry(alias, cert);
}
final trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
trustManagerFactory.init(keyStore);
trustManagers = trustManagerFactory.getTrustManagers();
}
}
K8sResponseJson secretesList() {
final action = "/api/v1/namespaces/$config.namespace/secrets"
final resp = get(action)
trace('GET', action, resp.text)
new K8sResponseJson(resp.text)
}
K8sResponseJson secretDescribe(String name) {
assert name
final action = "/api/v1/namespaces/$config.namespace/secrets/$name"
final resp = get(action)
trace('GET', action, resp.text)
new K8sResponseJson(resp.text)
}
/**
* Create a pod
*
* See
* https://v1-8.docs.kubernetes.io/docs/api-reference/v1.8/#create-55
* https://v1-8.docs.kubernetes.io/docs/api-reference/v1.8/#pod-v1-core
*
* @param spec
* @return
*/
K8sResponseJson podCreate(String req) {
assert req
final action = "/api/v1/namespaces/$config.namespace/pods"
final resp = post(action, req)
trace('POST', action, resp.text)
return new K8sResponseJson(resp.text)
}
K8sResponseJson podCreate(Map req, Path saveYamlPath=null) {
if( saveYamlPath ) try {
saveYamlPath.text = new Yaml().dump(req).toString()
}
catch( Exception e ) {
log.debug "WARN: unable to save request yaml -- cause: ${e.message ?: e}"
}
podCreate(JsonOutput.toJson(req))
}
/**
* Delete a pod
*
* See
* https://v1-8.docs.kubernetes.io/docs/api-reference/v1.8/#delete-58
*
* @param name
* @return
*/
K8sResponseJson podDelete(String name) {
assert name
final action = "/api/v1/namespaces/$config.namespace/pods/$name"
final resp = delete(action)
trace('DELETE', action, resp.text)
new K8sResponseJson(resp.text)
}
/**
* Create a job
*
* See
* https://v1-8.docs.kubernetes.io/docs/api-reference/v1.8/#create-55
* https://v1-8.docs.kubernetes.io/docs/api-reference/v1.8/#job-v1-batch
*
* @param spec
* @return
*/
K8sResponseJson jobCreate(String req) {
assert req
final action = "/apis/batch/v1/namespaces/$config.namespace/jobs"
final resp = post(action, req)
trace('POST', action, resp.text)
return new K8sResponseJson(resp.text)
}
K8sResponseJson jobCreate(Map req, Path saveYamlPath=null) {
if( saveYamlPath ) try {
saveYamlPath.text = new Yaml().dump(req).toString()
}
catch( Exception e ) {
log.debug "WARN: unable to save request yaml -- cause: ${e.message ?: e}"
}
jobCreate(JsonOutput.toJson(req))
}
/**
* Delete a job
*
* See
* https://v1-8.docs.kubernetes.io/docs/api-reference/v1.8/#delete-58
*
* @param name
* @return
*/
K8sResponseJson jobDelete(String name) {
assert name
// get podList of a job
final action = "/api/v1/namespaces/$config.namespace/pods?labelSelector=job-name=$name"
final resp = get(action)
trace('GET', action, resp.text)
final podList = new K8sResponseJson(resp.text)
// delete all pods in a job
if (podList.kind == "PodList") {
for (item in podList.items) {
try {
podDelete(((item as Map).metadata as Map).name as String)
}
catch(K8sResponseException err) {
if( err.response.code == 404 )
log.debug("Unable to delete Pod for job $name, pod already gone")
else
throw err
}
}
}
// delete job
final action1 = "/apis/batch/v1/namespaces/$config.namespace/jobs/$name"
final resp1 = delete(action1)
trace('DELETE', action1, resp1.text)
new K8sResponseJson(resp1.text)
}
/*
* https://v1-8.docs.kubernetes.io/docs/api-reference/v1.8/#list-62
* https://v1-8.docs.kubernetes.io/docs/api-reference/v1.8/#list-all-namespaces-63
*/
K8sResponseJson jobList(boolean allNamespaces=false) {
final String action = allNamespaces ? "jobs" : "namespaces/$config.namespace/jobs"
final resp = get("/apis/batch/v1/$action")
trace('GET', action, resp.text)
new K8sResponseJson(resp.text)
}
/*
* https://v1-8.docs.kubernetes.io/docs/api-reference/v1.8/#list-62
* https://v1-8.docs.kubernetes.io/docs/api-reference/v1.8/#list-all-namespaces-63
*/
K8sResponseJson podList(boolean allNamespaces=false) {
final String action = allNamespaces ? "pods" : "namespaces/$config.namespace/pods"
final resp = get("/api/v1/$action")
trace('GET', action, resp.text)
new K8sResponseJson(resp.text)
}
/*
* https://v1-8.docs.kubernetes.io/docs/api-reference/v1.8/#read-status-69
*/
// converts the name of job to the name of the latest created pod
String findPodNameForJob(String name){
final action = "/api/v1/namespaces/$config.namespace/pods?labelSelector=job-name=$name"
final resp = get(action)
trace('GET', action, resp.text)
final podList = new K8sResponseJson(resp.text)
String podName
// find latest created pod
if (podList.kind == "PodList") {
final pods = podList.items
String latestPod = "0000-00-00T00:00:00Z"
for (item in pods) {
final podMetadata = (Map) (item as Map).metadata
final podTimestamp = podMetadata.creationTimestamp
if (podTimestamp.toString() > latestPod) {
latestPod = podTimestamp
podName = podMetadata.name
}
}
}
return podName
}
K8sResponseJson jobStatus(String name) {
assert name
final action = "/apis/batch/v1/namespaces/$config.namespace/jobs/$name/status"
final resp = get(action)
trace('GET', action, resp.text)
return new K8sResponseJson(resp.text)
}
K8sResponseJson podStatus(String name) {
assert name
final action = "/api/v1/namespaces/$config.namespace/pods/$name/status"
final resp = get(action)
trace('GET', action, resp.text)
return new K8sResponseJson(resp.text)
}
/*
* https://v1-8.docs.kubernetes.io/docs/api-reference/v1.8/#read-status-69
*/
protected K8sResponseJson podStatus0(String name) {
try {
return podStatus(name)
}
catch (K8sResponseException err) {
if( err.response.code == 404 && isKindPods(err.response) ) {
// this may happen when K8s node is shutdown and the pod is evicted
// therefore process exception is thrown so that the failure
// can be managed by the nextflow as re-triable execution
throw new NodeTerminationException("Unable to find pod $name - The pod may be evicted by a node shutdown event")
}
throw err
}
}
protected boolean isKindPods(K8sResponseJson resp) {
if( resp.details instanceof Map ) {
final details = (Map) resp.details
return details.kind == 'pods'
}
return false
}
String getNodeOfPod(String podName){
assert podName
final K8sResponseJson resp = podStatus0(podName)
(resp?.spec as Map)?.nodeName as String
}
/**
* Get pod current state object
*
* @param podName The pod name
* @return
* A {@link Map} representing the container state object as shown below
* <code>
* {
* "terminated": {
* "exitCode": 127,
* "reason": "ContainerCannotRun",
* "message": "OCI runtime create failed: container_linux.go:296: starting container process caused \"exec: \\\"bash\\\": executable file not found in $PATH\": unknown",
* "startedAt": "2018-01-12T22:04:25Z",
* "finishedAt": "2018-01-12T22:04:25Z",
* "containerID": "docker://730ef2e05be72ffc354f2682b4e8300610812137b9037b726c21e5c4e41b6dda"
* }
* </code>
* See the following link for details https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.10/#containerstate-v1-core
* An empty map is return if the pod is a `Pending` status and the container state is not
* yet available
*
*
*/
Map jobState( String jobName ) {
assert jobName
final podName = findPodNameForJob(jobName)
if( podName ) {
try {
return podState(podName)
}
/* pod might be deleted by control plane just after findPodNameForJob() call
* so try fallback to jobState
*/
catch (NodeTerminationException err) {
log.warn1("Job $jobName's Pod not found, probably cleaned by controlplane")
return jobStateFallback0(jobName)
}
}
else {
return jobStateFallback0(jobName)
}
}
protected Map jobStateFallback0(String jobName) {
final K8sResponseJson jobResp = jobStatus(jobName)
final jobStatus = jobResp.status as Map
if( jobStatus?.succeeded == 1 && jobStatus.conditions instanceof List ) {
final allConditions = jobStatus.conditions as List<Map>
final cond = allConditions.find { cond -> cond.type == 'Complete' }
if( cond?.status == 'True' ) {
log.warn1("Job $jobName already completed and Pod is gone")
final dummyPodStatus = [
terminated: [
reason: "Completed",
startedAt: jobStatus.startTime,
finishedAt: jobStatus.completionTime,
]
]
return dummyPodStatus
} else {
throw new ProcessFailedException("K8s Job $jobName succeeded but does not have Complete status. $allConditions")
}
}
if( jobStatus?.failed && (int)(jobStatus.failed) > 0 ) {
String message = 'unknown'
if( jobStatus.conditions instanceof List ) {
final allConditions = jobStatus.conditions as List<Map>
final cond = allConditions.find { cond -> cond.type == 'Failed' }
message = cond?.message
}
throw new ProcessFailedException("K8s Job $jobName execution failed: $message")
}
log.debug1("K8s Job $jobName does not have pod - Not yet scheduled?")
return Collections.emptyMap()
}
/**
* Get pod current state object
*
* @param podName The pod name
* @return
* A {@link Map} representing the container state object as shown below
* <code>
* {
* "terminated": {
* "exitCode": 127,
* "reason": "ContainerCannotRun",
* "message": "OCI runtime create failed: container_linux.go:296: starting container process caused \"exec: \\\"bash\\\": executable file not found in $PATH\": unknown",
* "startedAt": "2018-01-12T22:04:25Z",
* "finishedAt": "2018-01-12T22:04:25Z",
* "containerID": "docker://730ef2e05be72ffc354f2682b4e8300610812137b9037b726c21e5c4e41b6dda"
* }
* </code>
* See the following link for details https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.10/#containerstate-v1-core
* An empty map is return if the pod is a `Pending` status and the container state is not
* yet available
*
*
*/
Map podState( String podName ) {
assert podName
final K8sResponseJson resp = podStatus0(podName)
final status = resp.status as Map
final containerStatuses = status?.containerStatuses as List<Map>
if( containerStatuses?.size()>0 ) {
final container = containerStatuses.get(0)
// note: when the pod is created by a Job submission
// the `podName` does not match the container name because it
// contains a suffix random generated by K8s pod scheduler
if( !container.name || !podName.startsWith(container.name.toString()) )
throw new K8sResponseException("K8s invalid status for pod: ${podName} (unexpected container name: ${container.name})", resp)
if( !container.state )
throw new K8sResponseException("K8s invalid status for pod: ${podName} (missing state object)", resp)
final state = container.state as Map
if( state.waiting instanceof Map ) {
def waiting = state.waiting as Map
checkInvalidWaitingState(waiting, resp)
}
return state
}
if( status?.phase == 'Pending' ){
if( status.conditions instanceof List ) {
final allConditions = status.conditions as List<Map>
final cond = allConditions.find { cond -> cond.type == 'PodScheduled' }
if( cond?.reason == 'Unschedulable' ) {
def message = "K8s pod cannot be scheduled"
if( cond.message ) message += " -- $cond.message"
//def cause = new K8sResponseException(resp)
log.warn1(message)
}
}
// undetermined status -- return an empty response
return Collections.emptyMap()
}
if( status?.phase == 'Failed' ) {
def msg = "K8s pod '$podName' execution failed"
if( status.reason ) msg += " - reason: ${status.reason}"
if( status.message ) msg += " - message: ${status.message}"
switch ( status.reason ) {
case 'OutOfcpu': throw new K8sOutOfCpuException(msg)
case 'OutOfmemory': throw new K8sOutOfMemoryException(msg)
case 'Shutdown': throw new NodeTerminationException(msg)
default: throw new ProcessFailedException(msg)
}
}
throw new K8sResponseException("K8s undetermined status conditions for pod $podName", resp)
}
protected void checkInvalidWaitingState( Map waiting, K8sResponseJson resp ) {
if( waiting.reason == 'ErrImagePull' || waiting.reason == 'ImagePullBackOff') {
def message = "K8s pod image cannot be pulled"
if( waiting.message ) message += " -- $waiting.message"
final cause = new K8sResponseException(resp)
throw new PodUnschedulableException(message, cause)
}
if( waiting.reason == 'CreateContainerConfigError' ) {
def message = "K8s pod configuration failed"
if( waiting.message ) message += " -- $waiting.message"
final cause = new K8sResponseException(resp)
throw new PodUnschedulableException(message, cause)
}
if( waiting.reason =~ /.+Error$/ ) {
def message = "K8s pod waiting on unknown error state"
if( waiting.message ) message += " -- $waiting.message"
final cause = new K8sResponseException(resp)
throw new PodUnschedulableException(message, cause)
}
final status = resp.status as Map
if( status?.phase == 'Failed' ) {
def message = "K8s pod in Failed state"
final cause = new K8sResponseException(resp)
throw new PodUnschedulableException(message, cause)
}
}
/*
* https://v1-8.docs.kubernetes.io/docs/api-reference/v1.8/#read-log
*/
InputStream jobLog(String name) {
jobLog( Collections.emptyMap(), name )
}
InputStream jobLog(Map params, String name) {
assert name
// -- compose the request action uri
String podName = findPodNameForJob(name)
def action = "/api/v1/namespaces/$config.namespace/pods/$podName/log"
int count=0
for( String key : (params.keySet()) ) {
action += "${count++==0 ? '?' : '&'}${key}=${params.get(key)}"
}
// -- submit request
def resp = get(action)
resp.stream
}
/*
* https://v1-8.docs.kubernetes.io/docs/api-reference/v1.8/#read-log
*/
InputStream podLog(String name) {
podLog( Collections.emptyMap(), name )
}
InputStream podLog(Map params, String name) {
assert name
// -- compose the request action uri
def action = "/api/v1/namespaces/$config.namespace/pods/$name/log"
int count=0
for( String key : (params.keySet()) ) {
action += "${count++==0 ? '?' : '&'}${key}=${params.get(key)}"
}
// -- submit request
def resp = get(action)
resp.stream
}
protected K8sResponseApi post(String path, String spec) {
makeRequest('POST', path, spec)
}
protected K8sResponseApi delete(String path, String body=null) {
makeRequest('DELETE', path, body)
}
protected HttpURLConnection createConnection0(String url) {
new URL(url).openConnection() as HttpURLConnection
}
protected void setupHttpsConn( HttpsURLConnection conn ) {
if (config.httpReadTimeout != null) {
conn.setReadTimeout(config.httpReadTimeout.toMillis() as int)
}
if (config.httpConnectTimeout != null) {
conn.setConnectTimeout(config.httpConnectTimeout.toMillis() as int)
}
if (config.keyManagers != null || trustManagers != null) {
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(config.keyManagers, trustManagers, new SecureRandom());
conn.setSSLSocketFactory(sslContext.getSocketFactory())
}
if( hostnameVerifier )
conn.setHostnameVerifier(hostnameVerifier)
}
/**
* Makes a HTTP(S) request the kubernetes master
*
* @param method The HTTP verb to use eg. {@code GET}, {@code POST}, etc
* @param path The API action path
* @param body The request payload
* @return
* A two elements list in which the first entry is an integer representing the HTTP response code,
* the second element is the text (json) response
*/
protected K8sResponseApi makeRequest(String method, String path, String body=null) throws K8sResponseException {
return apply(() -> makeRequestCall( method, path, body ) )
}
private K8sResponseApi makeRequestCall(String method, String path, String body=null) throws K8sResponseException {
assert config.server, 'Missing Kubernetes server name'
assert path.startsWith('/'), 'Kubernetes API request path must starts with a `/` character'
final prefix = config.server.contains("://") ? config.server : "https://$config.server"
final conn = createConnection0(prefix + path)
conn.setRequestProperty("Content-Type", "application/json")
if( config.token ) {
conn.setRequestProperty("Authorization", "Bearer $config.token")
}
if( conn instanceof HttpsURLConnection ) {
setupHttpsConn(conn)
}
if( !method ) method = body ? 'POST' : 'GET'
conn.setRequestMethod(method)
log.trace "[K8s] API request $method $path ${body ? '\n'+prettyPrint(body).indent() : ''}"
if( body ) {
conn.setDoOutput(true);
conn.setDoInput(true);
conn.getOutputStream() << body
conn.getOutputStream().flush()
}
final code = conn.getResponseCode()
final isError = code >= 400
final stream = isError ? conn.getErrorStream() : conn.getInputStream()
if( isError )
throw new K8sResponseException("Request $method $path returned an error code=$code", stream)
return new K8sResponseApi(code, stream)
}
static private void trace(String method, String path, String text) {
log.trace "[K8s] API response $method $path \n${prettyPrint(text).indent()}"
}
protected K8sResponseApi get(String path) {
makeRequest('GET',path)
}
static protected String prettyPrint(String json) {
try {
JsonOutput.prettyPrint(json)
}
catch( Exception e ) {
return json
}
}
K8sResponseJson configCreate(String name, Map data) {
final spec = [
apiVersion: 'v1',
kind: 'ConfigMap',
metadata: [ name: name, namespace: config.namespace ],
data: data
]
configCreate0(spec)
}
protected K8sResponseJson configCreate0(Map spec) {
final action = "/api/v1/namespaces/${config.namespace}/configmaps"
final body = JsonOutput.toJson(spec)
def resp = post(action, body)
trace('POST', action, resp.text)
return new K8sResponseJson(resp.text)
}
K8sResponseJson configDelete(String name) {
final action = "/api/v1/namespaces/${config.namespace}/configmaps/$name"
def resp = delete(action)
trace('DELETE', action, resp.text)
return new K8sResponseJson(resp.text)
}
K8sResponseJson configDeleteAll() {
final action = "/api/v1/namespaces/${config.namespace}/configmaps"
def resp = delete(action)
trace('DELETE', action, resp.text)
return new K8sResponseJson(resp.text)
}
K8sResponseJson volumeClaimRead(String name) {
final action = "/api/v1/namespaces/${config.namespace}/persistentvolumeclaims/${name}"
def resp = get(action)
trace('GET', action, resp.text)
return new K8sResponseJson(resp.text)
}
/**
* Creates a retry policy using the configuration specified by {@link nextflow.k8s.client.K8sRetryConfig}
*
* @param cond A predicate that determines when a retry should be triggered
* @return The {@link dev.failsafe.RetryPolicy} instance
*/
protected <T> RetryPolicy<T> retryPolicy(Predicate<? extends Throwable> cond) {
final cfg = config.retryConfig
final listener = new EventListener<ExecutionAttemptedEvent<T>>() {
@Override
void accept(ExecutionAttemptedEvent<T> event) throws Throwable {
log.debug("K8s response error - attempt: ${event.attemptCount}; reason: ${event.lastFailure.message}")
}
}
return RetryPolicy.<T>builder()
.handleIf(cond)
.withBackoff(cfg.delay.toMillis(), cfg.maxDelay.toMillis(), ChronoUnit.MILLIS)
.withMaxAttempts(cfg.maxAttempts)
.withJitter(cfg.jitter)
.onRetry(listener)
.build()
}
final private static List<Integer> RETRY_CODES = List.of(408, 429, 500, 502, 503, 504)
/**
* Carry out the invocation of the specified action using a retry policy.
*
* @param action A {@link dev.failsafe.function.CheckedSupplier} instance modeling the action to be performed in a safe manner
* @return The result of the supplied action
*/
protected <T> T apply(CheckedSupplier<T> action) {
// define the retry condition
final cond = new Predicate<? extends Throwable>() {
@Override
boolean test(Throwable t) {
if ( t instanceof K8sResponseException && t.response.code in RETRY_CODES )
return true
if( t instanceof SocketException || t.cause instanceof SocketException )
return true
if( t instanceof SocketTimeoutException || t.cause instanceof SocketTimeoutException )
return true
return false
}
}
// create the retry policy object
final policy = retryPolicy(cond)
// apply the action with and throw the original cause
try {
return Failsafe.with(policy).get(action)
}catch(FailsafeException e){
throw e.getCause()
}
}
}

View File

@@ -0,0 +1,54 @@
/*
* 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.k8s.client
import groovy.transform.CompileStatic
/**
* Model a Kubernetes API response
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
@CompileStatic
class K8sResponseApi {
private int code
private InputStream stream
private String text
K8sResponseApi(int code, InputStream stream) {
this.code = code
this.stream = stream
}
String toString() {
"code=$code; stream=$stream"
}
int getCode() { code }
InputStream getStream() { stream }
String getText() {
if( text == null ) {
text = stream?.text
}
return text
}
}

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 nextflow.k8s.client
import groovy.transform.CompileStatic
import groovy.util.logging.Slf4j
/**
* Model a kubernetes invalid response
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
@Slf4j
@CompileStatic
class K8sResponseException extends Exception {
K8sResponseJson response
K8sResponseException(K8sResponseJson response) {
super(msg0(response))
this.response = response
}
K8sResponseException(String message, K8sResponseJson response) {
super(msg1(message,response))
this.response = response
}
K8sResponseException(String message, InputStream response) {
this(message, new K8sResponseJson(fetch(response)))
}
static private String msg1( String msg, K8sResponseJson resp ) {
if( !msg && resp==null )
return null
if( msg && resp != null ) {
def sep = resp.isRawText() ? ' -- ' : '\n'
return "${msg}${sep}${msg0(resp)}"
}
else if( msg ) {
return msg
}
else {
return msg0(resp)
}
}
static private String msg0( K8sResponseJson response ) {
if( response == null )
return null
if( response.isRawText() )
response.getRawText()
else
"\n${response.toString().indent(' ')}"
}
static private String fetch(InputStream stream) {
try {
return stream?.text
}
catch( Exception e ) {
log.debug "Unable to fetch response text -- Cause: ${e.message ?: e}"
return null
}
}
}

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 nextflow.k8s.client
import groovy.json.JsonOutput
import groovy.json.JsonSlurper
import groovy.transform.CompileStatic
import groovy.util.logging.Slf4j
/**
* Model the response of a kubernetes api request
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
@Slf4j
@CompileStatic
class K8sResponseJson implements Map {
@Delegate
private Map response
private String rawText
K8sResponseJson(Map response) {
this.response = response
}
K8sResponseJson(String response) {
this.response = toJson(response)
this.rawText = response
}
boolean isRawText() { !response && rawText }
String getRawText() { rawText }
static private Map toJson(String raw) {
try {
return (Map)new JsonSlurper().parseText(raw)
}
catch( Exception e ) {
log.trace "[K8s] cannot parse response to json -- raw: ${raw? '\n'+raw.indent(' ') :'null'}"
return Collections.emptyMap()
}
}
static private String prettyPrint(String json) {
try {
JsonOutput.prettyPrint(json)
}
catch( Exception e ) {
return json
}
}
String toString() {
response ? prettyPrint(JsonOutput.toJson(response)) : rawText
}
}

View File

@@ -0,0 +1,75 @@
/*
* 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.k8s.client
import groovy.transform.CompileStatic
import groovy.transform.EqualsAndHashCode
import groovy.transform.ToString
import nextflow.config.spec.ConfigOption
import nextflow.config.spec.ConfigScope
import nextflow.script.dsl.Description
import nextflow.util.Duration
/**
* Model retry policy configuration
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
@ToString(includePackage = false, includeNames = true)
@EqualsAndHashCode
@CompileStatic
class K8sRetryConfig implements ConfigScope {
@ConfigOption
@Description("""
Delay when retrying failed API requests (default: `250ms`).
""")
Duration delay = Duration.of('250ms')
@ConfigOption
@Description("""
Max delay when retrying failed API requests (default: `90s`).
""")
Duration maxDelay = Duration.of('90s')
@ConfigOption
@Description("""
Max attempts when retrying failed API requests (default: `4`).
""")
int maxAttempts = 4
@ConfigOption
@Description("""
Jitter value when retrying failed API requests (default: `0.25`).
""")
double jitter = 0.25
K8sRetryConfig() {
this(Collections.emptyMap())
}
K8sRetryConfig(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
}
}

View File

@@ -0,0 +1,37 @@
/*
* 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.k8s.client
import groovy.transform.CompileStatic
import nextflow.exception.ProcessException
import nextflow.exception.ShowOnlyExceptionMessage
/**
* Exception raised when a pod cannot be scheduled because
* e.g. the container image cannot be pulled, required resources
* cannot be fulfilled, etc.
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
@CompileStatic
class PodUnschedulableException extends ProcessException implements ShowOnlyExceptionMessage {
PodUnschedulableException(String message, Throwable cause) {
super(message,cause)
}
}

View File

@@ -0,0 +1,318 @@
/*
* 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.k8s.client;
/**
* This file is derived from
* https://github.com/kubernetes-client/java/blob/master/util/src/main/java/io/kubernetes/client/util/SSLUtils.java
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.math.BigInteger;
import java.security.KeyFactory;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.Security;
import java.security.UnrecoverableKeyException;
import java.security.cert.Certificate;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.RSAPrivateCrtKeySpec;
import javax.net.ssl.KeyManager;
import javax.net.ssl.KeyManagerFactory;
import org.apache.commons.codec.binary.Base64;
import org.bouncycastle.asn1.pkcs.PrivateKeyInfo;
import org.bouncycastle.openssl.PEMKeyPair;
import org.bouncycastle.openssl.PEMParser;
import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter;
public class SSLUtils {
public static boolean isNotNullOrEmpty(String val) {
return val != null && val.length() > 0;
}
public static KeyManager[] keyManagers(String certData, String certFile, String keyData, String keyFile,
String algo, String passphrase, String keyStoreFile, String keyStorePassphrase)
throws NoSuchAlgorithmException, UnrecoverableKeyException, KeyStoreException, CertificateException,
InvalidKeySpecException, IOException {
KeyManager[] keyManagers = null;
if ((isNotNullOrEmpty(certData) || isNotNullOrEmpty(certFile))
&& (isNotNullOrEmpty(keyData) || isNotNullOrEmpty(keyFile))) {
KeyStore keyStore = createKeyStore(certData, certFile, keyData, keyFile, algo, passphrase, keyStoreFile,
keyStorePassphrase);
KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
kmf.init(keyStore, passphrase.toCharArray());
keyManagers = kmf.getKeyManagers();
}
return keyManagers;
}
public static KeyStore createKeyStore(String clientCertData, String clientCertFile, String clientKeyData,
String clientKeyFile, String clientKeyAlgo, String clientKeyPassphrase, String keyStoreFile,
String keyStorePassphrase) throws IOException, CertificateException, NoSuchAlgorithmException,
InvalidKeySpecException, KeyStoreException {
try (InputStream certInputStream = getInputStreamFromDataOrFile(clientCertData, clientCertFile);
InputStream keyInputStream = getInputStreamFromDataOrFile(clientKeyData, clientKeyFile)) {
return createKeyStore(certInputStream, keyInputStream, clientKeyAlgo,
clientKeyPassphrase != null ? clientKeyPassphrase.toCharArray() : null,
keyStoreFile, getKeyStorePassphrase(keyStorePassphrase));
}
}
static private PrivateKey generateEcKey(InputStream keyInputStream) throws IOException {
PrivateKey privateKey=null;
Security.addProvider(new org.bouncycastle.jce.provider.BouncyCastleProvider());
Object object = new PEMParser(new InputStreamReader(keyInputStream)).readObject();
if (object instanceof PEMKeyPair) {
PEMKeyPair keys = (PEMKeyPair) object;
privateKey = new JcaPEMKeyConverter().getKeyPair(keys).getPrivate();
}
if( object instanceof PrivateKeyInfo) {
PrivateKeyInfo privateKeyInfo = (PrivateKeyInfo)object;
privateKey = new JcaPEMKeyConverter().getPrivateKey(privateKeyInfo);
}
if( privateKey == null) {
throw new IOException("Unsupported EC algorithm");
}
return privateKey;
}
static private PrivateKey generateStdKey(InputStream keyInputStream, String clientKeyAlgo) throws IOException, NoSuchAlgorithmException, InvalidKeySpecException {
byte[] keyBytes = decodePem(keyInputStream);
KeyFactory keyFactory = KeyFactory.getInstance(clientKeyAlgo);
try {
// First let's try PKCS8
return keyFactory.generatePrivate(new PKCS8EncodedKeySpec(keyBytes));
}
catch (InvalidKeySpecException e) {
// Otherwise try PKCS1
RSAPrivateCrtKeySpec keySpec = decodePKCS1(keyBytes);
return keyFactory.generatePrivate(keySpec);
}
}
public static KeyStore createKeyStore(InputStream certInputStream, InputStream keyInputStream, String clientKeyAlgo,
char[] clientKeyPassphrase, String keyStoreFile, char[] keyStorePassphrase) throws IOException,
CertificateException, NoSuchAlgorithmException, InvalidKeySpecException, KeyStoreException {
CertificateFactory certFactory = CertificateFactory.getInstance("X509");
X509Certificate cert = (X509Certificate) certFactory.generateCertificate(certInputStream);
PrivateKey privateKey = clientKeyAlgo.equals("EC")
? generateEcKey(keyInputStream)
: generateStdKey(keyInputStream, clientKeyAlgo);
KeyStore keyStore = KeyStore.getInstance("JKS");
if (keyStoreFile != null && keyStoreFile.length() > 0) {
keyStore.load(new FileInputStream(keyStoreFile), keyStorePassphrase);
} else {
loadDefaultKeyStoreFile(keyStore, keyStorePassphrase);
}
String alias = cert.getSubjectX500Principal().getName();
keyStore.setKeyEntry(alias, privateKey, clientKeyPassphrase, new Certificate[] { cert });
return keyStore;
}
// This method is inspired and partly taken over from
// http://oauth.googlecode.com/svn/code/java/
// All credits to belong to them.
private static byte[] decodePem(InputStream keyInputStream) throws IOException {
BufferedReader reader = new BufferedReader(new InputStreamReader(keyInputStream));
try {
String line;
while ((line = reader.readLine()) != null) {
if (line.contains("-----BEGIN ")) {
return readBytes(reader, line.trim().replace("BEGIN", "END"));
}
}
throw new IOException("PEM is invalid: no begin marker");
} finally {
reader.close();
}
}
private static byte[] readBytes(BufferedReader reader, String endMarker) throws IOException {
String line;
StringBuffer buf = new StringBuffer();
while ((line = reader.readLine()) != null) {
if (line.indexOf(endMarker) != -1) {
return Base64.decodeBase64(buf.toString());
}
buf.append(line.trim());
}
throw new IOException("PEM is invalid : No end marker");
}
public static RSAPrivateCrtKeySpec decodePKCS1(byte[] keyBytes) throws IOException {
DerParser parser = new DerParser(keyBytes);
Asn1Object sequence = parser.read();
sequence.validateSequence();
parser = new DerParser(sequence.getValue());
parser.read();
return new RSAPrivateCrtKeySpec(next(parser), next(parser), next(parser), next(parser), next(parser),
next(parser), next(parser), next(parser));
}
private static BigInteger next(DerParser parser) throws IOException {
return parser.read().getInteger();
}
static class DerParser {
private InputStream in;
DerParser(byte[] bytes) throws IOException {
this.in = new ByteArrayInputStream(bytes);
}
Asn1Object read() throws IOException {
int tag = in.read();
if (tag == -1) {
throw new IOException("Invalid DER: stream too short, missing tag");
}
int length = getLength();
byte[] value = new byte[length];
if (in.read(value) < length) {
throw new IOException("Invalid DER: stream too short, missing value");
}
return new Asn1Object(tag, value);
}
private int getLength() throws IOException {
int i = in.read();
if (i == -1) {
throw new IOException("Invalid DER: length missing");
}
if ((i & ~0x7F) == 0) {
return i;
}
int num = i & 0x7F;
if (i >= 0xFF || num > 4) {
throw new IOException("Invalid DER: length field too big (" + i + ")");
}
byte[] bytes = new byte[num];
if (in.read(bytes) < num) {
throw new IOException("Invalid DER: length too short");
}
return new BigInteger(1, bytes).intValue();
}
}
static class Asn1Object {
private final int type;
private final byte[] value;
private final int tag;
public Asn1Object(int tag, byte[] value) {
this.tag = tag;
this.type = tag & 0x1F;
this.value = value;
}
public byte[] getValue() {
return value;
}
BigInteger getInteger() throws IOException {
if (type != 0x02) {
throw new IOException("Invalid DER: object is not integer"); //$NON-NLS-1$
}
return new BigInteger(value);
}
void validateSequence() throws IOException {
if (type != 0x10) {
throw new IOException("Invalid DER: not a sequence");
}
if ((tag & 0x20) != 0x20) {
throw new IOException("Invalid DER: can't parse primitive entity");
}
}
}
private static void loadDefaultKeyStoreFile(KeyStore keyStore, char[] keyStorePassphrase)
throws CertificateException, NoSuchAlgorithmException, IOException {
String keyStorePath = System.getProperty("javax.net.ssl.keyStore");
if (keyStorePath != null && keyStorePath.length() > 0) {
File keyStoreFile = new File(keyStorePath);
if (loadDefaultStoreFile(keyStore, keyStoreFile, keyStorePassphrase)) {
return;
}
}
keyStore.load(null);
}
private static boolean loadDefaultStoreFile(KeyStore keyStore, File fileToLoad, char[] passphrase)
throws CertificateException, NoSuchAlgorithmException, IOException {
if (fileToLoad.exists() && fileToLoad.isFile() && fileToLoad.length() > 0) {
keyStore.load(new FileInputStream(fileToLoad), passphrase);
return true;
}
return false;
}
public static InputStream getInputStreamFromDataOrFile(String data, String file) throws FileNotFoundException {
if (data != null) {
byte[] bytes = Base64.decodeBase64(data);
// TODO handle non-base64 here?
return new ByteArrayInputStream(bytes);
}
if (file != null) {
return new FileInputStream(file);
}
return null;
}
private static char[] getKeyStorePassphrase(String keyStorePassphrase) {
if (keyStorePassphrase == null || keyStorePassphrase.length() == 0) {
return System.getProperty("javax.net.ssl.keyStorePassword", "changeit").toCharArray();
}
return keyStorePassphrase.toCharArray();
}
}

View File

@@ -0,0 +1,81 @@
/*
* 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.k8s.model
import groovy.transform.CompileStatic
import groovy.transform.EqualsAndHashCode
import groovy.transform.ToString
/**
* Model a K8s pod environment variable definition
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
@CompileStatic
@ToString(includeNames = true)
@EqualsAndHashCode(includeFields = true)
class PodEnv {
private Map spec
private PodEnv(Map spec) {
this.spec = spec
}
static PodEnv value(String env, String value) {
new PodEnv([name:env, value:value])
}
static PodEnv fieldPath(String env, String fieldPath) {
new PodEnv([ name: env, valueFrom: [fieldRef:[fieldPath: fieldPath]]])
}
static PodEnv config(String env, String config) {
final tokens = config.tokenize('/')
if( tokens.size() > 2 )
throw new IllegalArgumentException("K8s invalid pod env file: $config -- Secret must be specified as <config-name>/<config-key>")
final name = tokens[0]
final key = tokens[1]
assert env, 'Missing pod env variable name'
assert name, 'Missing pod env config name'
final ref = [ name: name, key: (key ?: env) ]
new PodEnv([ name: env, valueFrom: [configMapKeyRef: ref]])
}
static PodEnv secret(String env, String secret) {
final tokens = secret.tokenize('/')
if( tokens.size() > 2 )
throw new IllegalArgumentException("K8s invalid pod env secret: $secret -- Secret must be specified as <secret-name>/<secret-key>")
final name = tokens[0]
final key = tokens[1]
final ref = [ name: name, key: (key ?: env) ]
new PodEnv([ name: env, valueFrom: [secretKeyRef: ref]])
}
Map toSpec() { spec }
String toString() {
"PodEnv[ ${spec?.toString()} ]"
}
}

View File

@@ -0,0 +1,41 @@
/*
* 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.k8s.model
import groovy.transform.CompileStatic
import groovy.transform.EqualsAndHashCode
import groovy.transform.ToString
/**
* Model a K8s pod host mount definition
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
@EqualsAndHashCode
@ToString(includeNames = true)
@CompileStatic
class PodHostMount {
String hostPath
String mountPath
PodHostMount(String host, String container) {
this.hostPath = host
this.mountPath = container
}
}

View File

@@ -0,0 +1,66 @@
/*
* 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.k8s.model
import java.nio.file.Paths
import groovy.transform.CompileStatic
import groovy.transform.EqualsAndHashCode
import groovy.transform.ToString
/**
* Model a K8s pod ConfigMap mount
*
* See also https://kubernetes.io/docs/tasks/configure-pod-container/configure-pod-configmap/
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
@CompileStatic
@ToString(includeNames = true)
@EqualsAndHashCode
class PodMountConfig {
String mountPath
String fileName
String configName
String configKey
PodMountConfig( String config, String mount ) {
assert config
assert mount
final path = Paths.get(mount)
final tokens = config.tokenize('/')
configName = tokens[0].trim()
configKey = tokens.size()>1 ? tokens[1].trim() : null
if( configKey ) {
mountPath = path.parent.toString()
fileName = path.fileName.toString()
}
else {
mountPath = path.toString()
}
}
PodMountConfig( Map entry ) {
this(entry.config as String, entry.mountPath as String)
}
}

View File

@@ -0,0 +1,53 @@
/*
* 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.k8s.model
import java.nio.file.Paths
import groovy.transform.CompileStatic
import groovy.transform.EqualsAndHashCode
import groovy.transform.ToString
/**
* Model a K8s pod CSI ephemeral volume mount
*
* See also https://kubernetes.io/docs/concepts/storage/ephemeral-volumes/#csi-ephemeral-volumes
*
* @author Ben Sherman <bentshermann@gmail.com>
*/
@CompileStatic
@ToString(includeNames = true)
@EqualsAndHashCode
class PodMountCsiEphemeral {
String mountPath
Map csi
PodMountCsiEphemeral( Map csi, String mountPath ) {
assert csi
assert mountPath
this.csi = csi
this.mountPath = mountPath
}
PodMountCsiEphemeral( Map entry ) {
this(entry.csi as Map, entry.mountPath as String)
}
}

View File

@@ -0,0 +1,50 @@
/*
* 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.k8s.model
import groovy.transform.CompileStatic
import groovy.transform.EqualsAndHashCode
import groovy.transform.ToString
/**
* Model a K8s pod emptyDir mount
*
* See also https://kubernetes.io/docs/concepts/storage/volumes/#emptydir
*
* @author Ben Sherman <bentshermann@gmail.com>
*/
@CompileStatic
@ToString(includeNames = true)
@EqualsAndHashCode
class PodMountEmptyDir {
String mountPath
Map emptyDir
PodMountEmptyDir( Map emptyDir, String mountPath ) {
assert mountPath
this.emptyDir = emptyDir
this.mountPath = mountPath
}
PodMountEmptyDir( Map entry ) {
this(entry.emptyDir as Map, entry.mountPath as String)
}
}

View File

@@ -0,0 +1,66 @@
/*
* 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.k8s.model
import java.nio.file.Paths
import groovy.transform.CompileStatic
import groovy.transform.EqualsAndHashCode
import groovy.transform.ToString
/**
* Model a K8s Secret file mount
*
* https://kubernetes.io/docs/concepts/configuration/secret/
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
@CompileStatic
@ToString(includeNames = true)
@EqualsAndHashCode
class PodMountSecret {
String mountPath
String fileName
String secretName
String secretKey
PodMountSecret(String secret, String mount) {
assert secret
assert mount
final path = Paths.get(mount)
final tokens = secret.tokenize('/')
secretName = tokens[0].trim()
secretKey = tokens.size()>1 ? tokens[1].trim() : null
if( secretKey ) {
mountPath = path.parent.toString()
fileName = path.fileName.toString()
}
else {
mountPath = path.toString()
}
}
PodMountSecret(Map entry) {
this(entry.secret as String, entry.mountPath as String)
}
}

View File

@@ -0,0 +1,80 @@
/*
* 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.k8s.model
import groovy.transform.CompileStatic
import groovy.transform.EqualsAndHashCode
import groovy.transform.ToString
/**
* Model a Pod nodeSelector spec
*
* https://kubernetes.io/docs/concepts/configuration/assign-pod-node/#nodeselector
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
@CompileStatic
@ToString(includeNames = true)
@EqualsAndHashCode(includeFields = true)
class PodNodeSelector {
private Map spec = [:]
PodNodeSelector(selector) {
if( selector instanceof CharSequence )
createWithString(selector.toString())
else if( selector instanceof Map )
createWithMap(selector)
else if( selector != null )
throw new IllegalArgumentException("K8s invalid pod nodeSelector value: $selector [${selector.getClass().getName()}]")
}
private createWithMap(Map selection ) {
if(selection) {
for( Map.Entry entry : selection.entrySet() ) {
spec.put(entry.key.toString(), entry.value?.toString())
}
}
}
/**
* @param selector
* A string representing a comma separated list of pairs
* e.g. foo=1,bar=2
*
*/
private createWithString( String selector ) {
if(!selector) return
def entries = selector.tokenize(',')
for( String item : entries ) {
def pair = item.tokenize('=')
spec.put( trim(pair[0]), trim(pair[1]) ?: 'true' )
}
}
private String trim(String v) {
v?.trim()
}
Map<String,String> toSpec() { spec }
String toString() {
"PodNodeSelector[ ${spec?.toString()} ]"
}
}

View File

@@ -0,0 +1,323 @@
/*
* 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.k8s.model
import groovy.transform.CompileStatic
import groovy.transform.EqualsAndHashCode
import groovy.transform.PackageScope
import groovy.transform.ToString
/**
* Model K8s pod options such as environment variables,
* secret and config-maps
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
@CompileStatic
@ToString(includeNames = true)
@EqualsAndHashCode(includeFields = true)
class PodOptions {
private String imagePullPolicy
private String imagePullSecret
private Collection<PodEnv> envVars
private Collection<PodMountConfig> mountConfigMaps
private Collection<PodMountCsiEphemeral> mountCsiEphemerals
private Collection<PodMountEmptyDir> mountEmptyDirs
private Collection<PodMountSecret> mountSecrets
private Collection<PodVolumeClaim> mountClaims
private Collection<PodHostMount> mountHostPaths
private Map<String,String> labels = [:]
private Map<String,String> annotations = [:]
private PodNodeSelector nodeSelector
private Map affinity
private PodSecurityContext securityContext
private boolean automountServiceAccountToken
private String priorityClassName
private List<Map> tolerations
private Boolean privileged
private String schedulerName
private Integer ttlSecondsAfterFinished
private String runtimeClassName
PodOptions( List<Map> options=null ) {
int size = options ? options.size() : 0
envVars = new HashSet<>(size)
mountConfigMaps = new HashSet<>(size)
mountCsiEphemerals = new HashSet<>(size)
mountEmptyDirs = new HashSet<>(size)
mountSecrets = new HashSet<>(size)
mountClaims = new HashSet<>(size)
mountHostPaths = new HashSet<>(10)
automountServiceAccountToken = true
tolerations = new ArrayList<Map>(size)
init(options)
}
@PackageScope void init(List<Map> options) {
if( !options ) return
for( Map entry : options ) {
create(entry)
}
}
@PackageScope void create(Map<String,String> entry) {
if( entry.env && entry.value ) {
envVars << PodEnv.value(entry.env, entry.value)
}
else if( entry.env && entry.fieldPath ) {
envVars << PodEnv.fieldPath(entry.env, entry.fieldPath)
}
else if( entry.env && entry.secret ) {
envVars << PodEnv.secret(entry.env, entry.secret)
}
else if( entry.env && entry.config ) {
envVars << PodEnv.config(entry.env, entry.config)
}
else if( entry.mountPath && entry.secret ) {
mountSecrets << new PodMountSecret(entry)
}
else if( entry.mountPath && entry.config ) {
mountConfigMaps << new PodMountConfig(entry)
}
else if( entry.mountPath && entry.csi ) {
mountCsiEphemerals << new PodMountCsiEphemeral(entry)
}
else if( entry.mountPath && entry.emptyDir != null ) {
mountEmptyDirs << new PodMountEmptyDir(entry)
}
else if( entry.mountPath && entry.volumeClaim ) {
mountClaims << new PodVolumeClaim(entry)
}
else if( entry.mountPath && entry.hostPath instanceof CharSequence ) {
mountHostPaths << new PodHostMount(entry.hostPath, entry.mountPath)
}
else if( entry.pullPolicy || entry.imagePullPolicy ) {
this.imagePullPolicy = entry.pullPolicy ?: entry.imagePullPolicy as String
}
else if( entry.imagePullSecret || entry.imagePullSecrets ) {
this.imagePullSecret = entry.imagePullSecret ?: entry.imagePullSecrets
}
else if( entry.label && entry.value ) {
this.labels.put(entry.label as String, entry.value as String)
}
else if( entry.runAsUser != null ) {
this.securityContext = new PodSecurityContext(entry.runAsUser)
}
else if( entry.securityContext instanceof Map ) {
this.securityContext = new PodSecurityContext(entry.securityContext as Map)
}
else if( entry.nodeSelector ) {
this.nodeSelector = new PodNodeSelector(entry.nodeSelector)
}
else if( entry.affinity instanceof Map ) {
this.affinity = entry.affinity as Map
}
else if( entry.annotation && entry.value ) {
this.annotations.put(entry.annotation as String, entry.value as String)
}
else if( entry.automountServiceAccountToken instanceof Boolean ) {
this.automountServiceAccountToken = entry.automountServiceAccountToken as Boolean
}
else if( entry.priorityClassName ) {
this.priorityClassName = entry.priorityClassName
}
else if( entry.toleration instanceof Map ) {
tolerations << (entry.toleration as Map)
}
else if( entry.privileged instanceof Boolean ) {
this.privileged = entry.privileged as Boolean
}
else if( entry.schedulerName ) {
this.schedulerName = entry.schedulerName
}
else if( entry.ttlSecondsAfterFinished instanceof Integer ) {
this.ttlSecondsAfterFinished = entry.ttlSecondsAfterFinished as Integer
}
else if( entry.runtimeClassName ) {
this.runtimeClassName = entry.runtimeClassName
}
else
throw new IllegalArgumentException("Unknown pod options: $entry")
}
Collection<PodEnv> getEnvVars() { envVars }
Collection<PodMountConfig> getMountConfigMaps() { mountConfigMaps }
Collection<PodMountCsiEphemeral> getMountCsiEphemerals() { mountCsiEphemerals }
Collection<PodMountEmptyDir> getMountEmptyDirs() { mountEmptyDirs }
Collection<PodMountSecret> getMountSecrets() { mountSecrets }
Collection<PodHostMount> getMountHostPaths() { mountHostPaths }
Collection<PodVolumeClaim> getVolumeClaims() { mountClaims }
Map<String,String> getLabels() { labels }
Map<String,String> getAnnotations() { annotations }
PodNodeSelector getNodeSelector() { nodeSelector }
PodOptions setNodeSelector( PodNodeSelector sel ) {
nodeSelector = sel
return this
}
Map getAffinity() { affinity }
PodSecurityContext getSecurityContext() { securityContext }
PodOptions setSecurityContext( PodSecurityContext ctx ) {
this.securityContext = ctx
return this
}
String getImagePullSecret() { imagePullSecret }
PodOptions setImagePullSecret( String secret ) {
this.imagePullSecret = secret
return this
}
String getImagePullPolicy() { imagePullPolicy }
PodOptions setImagePullPolicy( String policy ) {
this.imagePullPolicy = policy
return this
}
boolean getAutomountServiceAccountToken() { automountServiceAccountToken }
PodOptions setAutomountServiceAccountToken( boolean mount ) {
this.automountServiceAccountToken = mount
return this
}
String getPriorityClassName() { priorityClassName }
String getSchedulerName() { schedulerName }
List<Map> getTolerations() { tolerations }
Boolean getPrivileged() { privileged }
Integer getTtlSecondsAfterFinished() { ttlSecondsAfterFinished }
String getRuntimeClassName() { runtimeClassName }
PodOptions plus( PodOptions other ) {
def result = new PodOptions()
// env vars
result.envVars.addAll(envVars)
result.envVars.addAll( other.envVars )
// config maps
result.mountConfigMaps.addAll( mountConfigMaps )
result.mountConfigMaps.addAll( other.mountConfigMaps )
// csi ephemeral volumes
result.mountCsiEphemerals.addAll( mountCsiEphemerals )
result.mountCsiEphemerals.addAll( other.mountCsiEphemerals )
// empty dirs
result.mountEmptyDirs.addAll( mountEmptyDirs )
result.mountEmptyDirs.addAll( other.mountEmptyDirs )
// host paths
result.mountHostPaths.addAll( mountHostPaths )
result.mountHostPaths.addAll( other.mountHostPaths )
// secrets
result.mountSecrets.addAll( mountSecrets )
result.mountSecrets.addAll( other.mountSecrets )
// volume claims
result.volumeClaims.addAll( volumeClaims )
result.volumeClaims.addAll( other.volumeClaims )
// sec context
result.securityContext = other.securityContext ?: this.securityContext
// node selector
result.nodeSelector = other.nodeSelector ?: this.nodeSelector
// affinity
result.affinity = other.affinity ?: this.affinity
// pull policy
result.imagePullPolicy = other.imagePullPolicy ?: this.imagePullPolicy
// image secret
result.imagePullSecret = other.imagePullSecret ?: this.imagePullSecret
// labels
result.labels.putAll(labels)
result.labels.putAll(other.labels)
// annotations
result.annotations.putAll(annotations)
result.annotations.putAll(other.annotations)
// automount service account token
result.automountServiceAccountToken = other.automountServiceAccountToken & this.automountServiceAccountToken
// priority class name
result.priorityClassName = other.priorityClassName ?: this.priorityClassName
// tolerations
result.tolerations = other.tolerations ?: this.tolerations
// privileged execution
result.privileged = other.privileged!=null ? other.privileged : this.privileged
// scheduler name
result.schedulerName = other.schedulerName ?: this.schedulerName
// ttl seconds after finished (job)
result.ttlSecondsAfterFinished = other.ttlSecondsAfterFinished!=null ? other.ttlSecondsAfterFinished : this.ttlSecondsAfterFinished
// runtime class name
result.runtimeClassName = other.runtimeClassName ?: this.runtimeClassName
return result
}
}

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 nextflow.k8s.model
import groovy.transform.CompileStatic
import groovy.transform.EqualsAndHashCode
import groovy.transform.ToString
/**
* Models K8s pod security context
*
* See
* https://kubernetes.io/docs/tasks/configure-pod-container/security-context/
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
@CompileStatic
@ToString(includeNames = true)
@EqualsAndHashCode(includeFields = true)
class PodSecurityContext {
private Map spec
PodSecurityContext(def user) {
spec = [runAsUser: user]
}
PodSecurityContext(Map ctx) {
assert ctx
spec = ctx
}
Map toSpec() { spec }
String toString() {
"PodSecurityContext[ ${spec?.toString()} ]"
}
}

View File

@@ -0,0 +1,791 @@
/*
* 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.k8s.model
import java.nio.file.Path
import java.util.concurrent.atomic.AtomicInteger
import groovy.transform.CompileDynamic
import groovy.transform.CompileStatic
import groovy.transform.PackageScope
import nextflow.executor.res.AcceleratorResource
import nextflow.util.MemoryUnit
import groovy.util.logging.Slf4j
/**
* Object build for a K8s pod specification
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
@CompileStatic
@Slf4j
class PodSpecBuilder {
static enum MetaType { LABEL, ANNOTATION }
static enum SegmentType {
PREFIX (253),
NAME (63),
VALUE (63)
private final int maxSize;
SegmentType(int maxSize) {
this.maxSize = maxSize;
}
}
static @PackageScope AtomicInteger VOLUMES = new AtomicInteger()
String podName
String imageName
String imagePullPolicy
String imagePullSecret
List<String> command = []
List<String> args = new ArrayList<>()
Map<String,String> labels = [:]
Map<String,String> annotations = [:]
String namespace
String restart
List<PodEnv> envVars = []
String workDir
Integer cpus
boolean cpuLimits
String memory
String disk
String serviceAccount
boolean automountServiceAccountToken = true
AcceleratorResource accelerator
Collection<PodMountConfig> configMaps = []
Collection<PodMountCsiEphemeral> csiEphemerals = []
Collection<PodMountEmptyDir> emptyDirs = []
Collection<PodMountSecret> secrets = []
Collection<PodHostMount> hostMounts = []
Collection<PodVolumeClaim> volumeClaims = []
PodSecurityContext securityContext
PodNodeSelector nodeSelector
Map affinity
String priorityClassName
List<Map> tolerations = []
boolean privileged
int activeDeadlineSeconds
Map<String,List<String>> capabilities
List<String> devices
Map<String,?> resourcesLimits
String schedulerName
Integer ttlSecondsAfterFinished
String runtimeClassName
/**
* @return A sequential volume unique identifier
*/
static protected String nextVolName() {
"vol-${VOLUMES.incrementAndGet()}".toString()
}
PodSpecBuilder withPodName(String name) {
this.podName = name
return this
}
PodSpecBuilder withImageName(String name) {
this.imageName = name
return this
}
PodSpecBuilder withImagePullPolicy(String policy) {
this.imagePullPolicy = policy
return this
}
PodSpecBuilder withWorkDir( String path ) {
this.workDir = path
return this
}
PodSpecBuilder withWorkDir(Path path ) {
this.workDir = path.toString()
return this
}
PodSpecBuilder withNamespace(String name) {
this.namespace = name
return this
}
PodSpecBuilder withServiceAccount(String name) {
this.serviceAccount = name
return this
}
PodSpecBuilder withCommand( cmd ) {
if( cmd==null ) return this
assert cmd instanceof List || cmd instanceof CharSequence, "Missing or invalid K8s command parameter: $cmd"
this.command = cmd instanceof List ? cmd as List<String> : ['/bin/bash','-c', cmd.toString()]
return this
}
PodSpecBuilder withArgs( args ) {
if( args==null ) return this
assert args instanceof List || args instanceof CharSequence, "Missing or invalid K8s args parameter: $args"
this.args = args instanceof List ? args as List<String> : ['/bin/bash','-c', args.toString()]
return this
}
PodSpecBuilder withCpus( Integer cpus ) {
this.cpus = cpus
return this
}
PodSpecBuilder withCpuLimits(boolean cpuLimits) {
this.cpuLimits = cpuLimits
return this
}
PodSpecBuilder withMemory(String mem) {
this.memory = mem
return this
}
PodSpecBuilder withMemory(MemoryUnit mem) {
this.memory = "${mem.mega}Mi".toString()
return this
}
PodSpecBuilder withDisk(String disk) {
this.disk = disk
return this
}
PodSpecBuilder withDisk(MemoryUnit disk) {
this.disk = "${disk.mega}Mi".toString()
return this
}
PodSpecBuilder withAccelerator(AcceleratorResource acc) {
this.accelerator = acc
return this
}
PodSpecBuilder withLabel( String name, String value ) {
this.labels.put(name, value)
return this
}
PodSpecBuilder withLabels(Map labels) {
this.labels.putAll(labels)
return this
}
PodSpecBuilder withAnnotation( String name, String value ) {
this.annotations.put(name, value)
return this
}
PodSpecBuilder withAnnotations(Map annotations) {
this.annotations.putAll(annotations)
return this
}
PodSpecBuilder withEnv( PodEnv env ) {
envVars.add(env)
return this
}
PodSpecBuilder withEnv( Collection envs ) {
envVars.addAll(envs)
return this
}
PodSpecBuilder withVolumeClaim( PodVolumeClaim claim ) {
volumeClaims.add(claim)
return this
}
PodSpecBuilder withVolumeClaims( Collection<PodVolumeClaim> claims ) {
volumeClaims.addAll(claims)
return this
}
PodSpecBuilder withConfigMaps( Collection<PodMountConfig> configMaps ) {
this.configMaps.addAll(configMaps)
return this
}
PodSpecBuilder withConfigMap( PodMountConfig configMap ) {
this.configMaps.add(configMap)
return this
}
PodSpecBuilder withCsiEphemerals( Collection<PodMountCsiEphemeral> csiEphemerals ) {
this.csiEphemerals.addAll(csiEphemerals)
return this
}
PodSpecBuilder withCsiEphemeral( PodMountCsiEphemeral csiEphemeral ) {
this.csiEphemerals.add(csiEphemeral)
return this
}
PodSpecBuilder withEmptyDirs( Collection<PodMountEmptyDir> emptyDirs ) {
this.emptyDirs.addAll(emptyDirs)
return this
}
PodSpecBuilder withEmptyDir( PodMountEmptyDir emptyDir ) {
this.emptyDirs.add(emptyDir)
return this
}
PodSpecBuilder withSecrets( Collection<PodMountSecret> secrets ) {
this.secrets.addAll(secrets)
return this
}
PodSpecBuilder withSecret( PodMountSecret secret ) {
this.secrets.add(secret)
return this
}
PodSpecBuilder withHostMounts( Collection<PodHostMount> mounts ) {
this.hostMounts.addAll(mounts)
return this
}
PodSpecBuilder withHostMount( String host, String mount ) {
this.hostMounts.add( new PodHostMount(host, mount))
return this
}
PodSpecBuilder withPrivileged(boolean value) {
this.privileged = value
return this
}
PodSpecBuilder withCapabilities(Map<String,List<String>> cap) {
this.capabilities = cap
for( String it : cap.keySet() ) {
if( it !in ['add','drop']) throw new IllegalArgumentException("K8s capability action can be either 'add' or 'drop' - offending value '$it'")
}
return this
}
PodSpecBuilder withActiveDeadline(int seconds) {
this.activeDeadlineSeconds = seconds
return this
}
PodSpecBuilder withResourcesLimits(Map<String,?> limits) {
this.resourcesLimits = limits
return this
}
PodSpecBuilder withPodOptions(PodOptions opts) {
// -- pull policy
if( opts.imagePullPolicy )
imagePullPolicy = opts.imagePullPolicy
if( opts.imagePullSecret )
imagePullSecret = opts.imagePullSecret
// -- env vars
if( opts.getEnvVars() )
envVars.addAll( opts.getEnvVars() )
// -- configMaps
if( opts.getMountConfigMaps() )
configMaps.addAll( opts.getMountConfigMaps() )
// -- csi ephemeral volumes
if( opts.getMountCsiEphemerals() )
csiEphemerals.addAll( opts.getMountCsiEphemerals() )
// -- emptyDirs
if( opts.getMountEmptyDirs() )
emptyDirs.addAll( opts.getMountEmptyDirs() )
// -- host paths
if( opts.getMountHostPaths() )
hostMounts.addAll( opts.getMountHostPaths() )
// -- secrets
if( opts.getMountSecrets() )
secrets.addAll( opts.getMountSecrets() )
// -- volume claims
if( opts.getVolumeClaims() )
volumeClaims.addAll( opts.getVolumeClaims() )
// -- labels
if( opts.labels ) {
def keys = opts.labels.keySet()
if( 'app' in keys ) throw new IllegalArgumentException("Invalid pod label -- `app` is a reserved label")
if( 'runName' in keys ) throw new IllegalArgumentException("Invalid pod label -- `runName` is a reserved label")
labels.putAll( opts.labels )
}
// - annotations
if( opts.annotations ) {
annotations.putAll( opts.annotations )
}
// -- security context
if( opts.securityContext )
securityContext = opts.securityContext
// -- node selector
if( opts.nodeSelector )
nodeSelector = opts.nodeSelector
// -- affinity
if( opts.affinity )
affinity = opts.affinity
// -- automount service account token
automountServiceAccountToken = opts.automountServiceAccountToken
// -- priority class name
priorityClassName = opts.priorityClassName
// -- tolerations
if( opts.tolerations )
tolerations.addAll(opts.tolerations)
// -- privileged
privileged = opts.privileged
// -- scheduler name
schedulerName = opts.schedulerName
// -- ttl seconds after finished (job)
if( opts.ttlSecondsAfterFinished != null )
ttlSecondsAfterFinished = opts.ttlSecondsAfterFinished
// runtime class name
if( opts.runtimeClassName != null )
runtimeClassName = opts.runtimeClassName
return this
}
@PackageScope List<Map> createPullSecret() {
def result = new ArrayList(1)
def entry = new LinkedHashMap(1)
entry.name = imagePullSecret
result.add(entry)
return result
}
Map build() {
assert this.podName, 'Missing K8s podName parameter'
assert this.imageName, 'Missing K8s imageName parameter'
assert this.command || this.args, 'Missing K8s command parameter'
final restart = this.restart ?: 'Never'
final metadata = new LinkedHashMap<String,Object>()
metadata.name = podName
metadata.namespace = namespace ?: 'default'
final labels = this.labels ?: [:]
final env = []
for( PodEnv entry : this.envVars ) {
env.add(entry.toSpec())
}
final container = [ name: this.podName, image: this.imageName ]
if( this.command )
container.command = this.command
if( this.args )
container.args = args
if( this.workDir )
container.put('workingDir', workDir)
if( imagePullPolicy )
container.imagePullPolicy = imagePullPolicy
final secContext = new LinkedHashMap(10)
if( privileged ) {
// note: privileged flag needs to be defined in the *container* securityContext
// not the 'spec' securityContext (see below)
secContext.privileged =true
}
if( capabilities ) {
secContext.capabilities = capabilities
}
if( secContext ) {
container.securityContext = secContext
}
final spec = [
restartPolicy: restart,
containers: [ container ],
]
if( nodeSelector )
spec.nodeSelector = nodeSelector.toSpec()
if( schedulerName )
spec.schedulerName = schedulerName
if( affinity )
spec.affinity = affinity
if( this.serviceAccount )
spec.serviceAccountName = this.serviceAccount
if( ! this.automountServiceAccountToken )
spec.automountServiceAccountToken = false
if( securityContext )
spec.securityContext = securityContext.toSpec()
if( imagePullSecret )
spec.imagePullSecrets = createPullSecret()
if( priorityClassName )
spec.priorityClassName = priorityClassName
// tolerations
if( this.tolerations )
spec.tolerations = this.tolerations
// add labels
if( labels )
metadata.labels = sanitize(labels, MetaType.LABEL)
if( annotations )
metadata.annotations = sanitize(annotations, MetaType.ANNOTATION)
// time directive
if ( activeDeadlineSeconds > 0)
spec.activeDeadlineSeconds = activeDeadlineSeconds
if ( runtimeClassName )
spec.runtimeClassName = runtimeClassName
final pod = [
apiVersion: 'v1',
kind: 'Pod',
metadata: metadata,
spec: spec
]
// add environment
if( env )
container.env = env
// add resources
if( this.cpus ) {
container.resources = addCpuResources(this.cpus, container.resources as Map)
}
if( this.memory ) {
container.resources = addMemoryResources(this.memory, container.resources as Map)
}
if( this.accelerator ) {
container.resources = addAcceleratorResources(this.accelerator, container.resources as Map)
}
if( this.disk ) {
container.resources = addDiskResources(this.disk, container.resources as Map)
}
if( this.resourcesLimits ) {
container.resources = addResourcesLimits(this.resourcesLimits, container.resources as Map)
}
// add storage definitions ie. volumes and mounts
final List<Map> mounts = []
final List<Map> volumes = []
final namesMap = [:]
// creates a volume name for each unique claim name
for( String claimName : volumeClaims.collect { it.claimName }.unique() ) {
final volName = nextVolName()
namesMap[claimName] = volName
volumes << [name: volName, persistentVolumeClaim: [claimName: claimName]]
}
// -- persistent volume claims
for( PodVolumeClaim entry : volumeClaims ) {
//check if we already have a volume for the pvc
final name = namesMap.get(entry.claimName)
final claim = [name: name, mountPath: entry.mountPath ]
if( entry.subPath )
claim.subPath = entry.subPath
if( entry.readOnly )
claim.readOnly = entry.readOnly
mounts << claim
}
// -- configMap volumes
for( PodMountConfig entry : configMaps ) {
final name = nextVolName()
configMapToSpec(name, entry, mounts, volumes)
}
// -- csi ephemeral volumes
for( PodMountCsiEphemeral entry : csiEphemerals ) {
final name = nextVolName()
mounts << [name: name, mountPath: entry.mountPath, readOnly: entry.csi.readOnly ?: false]
volumes << [name: name, csi: entry.csi]
}
// -- emptyDir volumes
for( PodMountEmptyDir entry : emptyDirs ) {
final name = nextVolName()
mounts << [name: name, mountPath: entry.mountPath]
volumes << [name: name, emptyDir: entry.emptyDir]
}
// -- secret volumes
for( PodMountSecret entry : secrets ) {
final name = nextVolName()
secretToSpec(name, entry, mounts, volumes)
}
// -- host path volumes
for( PodHostMount entry : hostMounts ) {
final name = nextVolName()
mounts << [name: name, mountPath: entry.mountPath]
volumes << [name: name, hostPath: [path: entry.hostPath]]
}
if( volumes )
spec.volumes = volumes
if( mounts )
container.volumeMounts = mounts
return pod
}
Map buildAsJob() {
final pod = build()
final spec = [
backoffLimit: 0,
template: [
metadata: pod.metadata,
spec: pod.spec
]
]
if( ttlSecondsAfterFinished != null )
spec.ttlSecondsAfterFinished = ttlSecondsAfterFinished
return [
apiVersion: 'batch/v1',
kind: 'Job',
metadata: pod.metadata,
spec: spec
]
}
@PackageScope
Map addResourcesLimits(Map limits, Map result) {
if( result == null )
result = new LinkedHashMap(2)
final limits0 = result.limits as Map ?: new LinkedHashMap(10)
limits0.putAll( limits )
result.limits = limits0
return result
}
@PackageScope
Map addCpuResources(Integer cpus, Map res) {
if( res == null )
res = new LinkedHashMap(2)
final requests0 = res.requests as Map ?: new LinkedHashMap<>(10)
requests0.cpu = cpus
res.requests = requests0
if( cpuLimits ) {
final limits0 = res.limits as Map ?: new LinkedHashMap(10)
limits0.cpu = cpus
res.limits = limits0
}
return res
}
@PackageScope
Map addMemoryResources(String memory, Map res) {
if( res == null )
res = new LinkedHashMap(2)
final req = res.requests as Map ?: new LinkedHashMap(10)
req.memory = memory
res.requests = req
final lim = res.limits as Map ?: new LinkedHashMap(10)
lim.memory = memory
res.limits = lim
return res
}
@PackageScope
Map addDiskResources(String diskRequest, Map res) {
if( res == null )
res = new LinkedHashMap(2)
final req = res.requests as Map ?: new LinkedHashMap(10)
req.'ephemeral-storage' = diskRequest
res.requests = req
final lim = res.limits as Map ?: new LinkedHashMap(10)
lim.'ephemeral-storage' = diskRequest
res.limits = lim
return res
}
@PackageScope
String getAcceleratorType(AcceleratorResource accelerator) {
def type = accelerator.type ?: 'nvidia.com'
if ( type.contains('/') )
// Assume the user has fully specified the resource type.
return type
// Assume we're using GPU and update as necessary.
if( !type.contains('.') ) type += '.com'
type += '/gpu'
return type
}
@PackageScope
Map addAcceleratorResources(AcceleratorResource accelerator, Map res) {
if( res == null )
res = new LinkedHashMap(2)
def type = getAcceleratorType(accelerator)
if( accelerator.request ) {
final req = res.requests as Map ?: new LinkedHashMap<>(2)
req.put(type, accelerator.request)
res.requests = req
}
if( accelerator.limit ) {
final lim = res.limits as Map ?: new LinkedHashMap<>(2)
lim.put(type, accelerator.limit)
res.limits = lim
}
return res
}
@PackageScope
@CompileDynamic
static void secretToSpec(String volName, PodMountSecret entry, List mounts, List volumes ) {
assert entry
final secret = [secretName: entry.secretName]
if( entry.secretKey ) {
secret.items = [ [key: entry.secretKey, path: entry.fileName ] ]
}
mounts << [name: volName, mountPath: entry.mountPath]
volumes << [name: volName, secret: secret ]
}
@PackageScope
@CompileDynamic
static void configMapToSpec(String volName, PodMountConfig entry, List<Map> mounts, List<Map> volumes ) {
assert entry
final config = [name: entry.configName]
if( entry.configKey ) {
config.items = [ [key: entry.configKey, path: entry.fileName ] ]
}
mounts << [name: volName, mountPath: entry.mountPath]
volumes << [name: volName, configMap: config ]
}
protected Map sanitize(Map map, MetaType kind) {
final result = new HashMap(map.size())
for( Map.Entry entry : map ) {
final key = sanitizeKey(entry.key as String, kind)
final value = (kind == MetaType.LABEL)
? sanitizeValue(entry.value, kind, SegmentType.VALUE)
: entry.value
result.put(key, value)
}
return result
}
protected String sanitizeKey(String value, MetaType kind) {
final parts = value.tokenize('/')
if (parts.size() == 2) {
return "${sanitizeValue(parts[0], kind, SegmentType.PREFIX)}/${sanitizeValue(parts[1], kind, SegmentType.NAME)}"
}
if( parts.size() == 1 ) {
return sanitizeValue(parts[0], kind, SegmentType.NAME)
}
else {
throw new IllegalArgumentException("Invalid key in pod ${kind.toString().toLowerCase()} -- Key can only contain exactly one '/' character")
}
}
/**
* Sanitize a string value to contain only alphanumeric characters, '-', '_' or '.',
* and to start and end with an alphanumeric character.
*/
protected String sanitizeValue(value, MetaType kind, SegmentType segment) {
def str = String.valueOf(value)
if( str.length() > segment.maxSize ) {
log.debug "K8s $kind $segment exceeds allowed size: $segment.maxSize -- offending str=$str"
str = str.substring(0,segment.maxSize)
}
str = str.replaceAll(/[^a-zA-Z0-9\.\_\-]+/, '_')
str = str.replaceAll(/^[^a-zA-Z0-9]+/, '')
str = str.replaceAll(/[^a-zA-Z0-9]+$/, '')
return str
}
}

View File

@@ -0,0 +1,76 @@
/*
* 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.k8s.model
import groovy.transform.CompileStatic
import groovy.transform.EqualsAndHashCode
import groovy.transform.ToString
/**
* Model a K8s pod persistent volume claim mount
*
* See https://kubernetes.io/docs/tasks/configure-pod-container/configure-persistent-volume-storage/#create-a-persistentvolumeclaim
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
@CompileStatic
@ToString(includeNames = true)
@EqualsAndHashCode
class PodVolumeClaim {
String claimName
String mountPath
String subPath
boolean readOnly
PodVolumeClaim(String name, String mount, String subPath=null, boolean readOnly=false) {
assert name
assert mount
this.claimName = name
this.mountPath = sanitize(mount)
this.subPath = subPath
this.readOnly = readOnly
validate(mountPath)
}
PodVolumeClaim(Map entry) {
assert entry.volumeClaim
assert entry.mountPath
this.claimName = entry.volumeClaim
this.mountPath = sanitize(entry.mountPath)
this.subPath = entry.subPath
this.readOnly = entry.readOnly ?: false
validate(mountPath)
}
private static validate(String path) {
if( !path.startsWith('/') )
throw new IllegalArgumentException("K8s volume claim path must be an absolute path: $path")
}
static private String sanitize(path) {
if( !path ) return null
def result = path.toString().trim()
while( result.endsWith('/') && result.size()>1 )
result = result.substring(0,result.size()-1)
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 nextflow.k8s.model
/**
* Model the resource type to be used to run nextflow tasks
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
enum ResourceType {
Pod, Job;
String lower() {
return this.name().toLowerCase()
}
}

View File

@@ -0,0 +1,487 @@
/*
* 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.k8s
import nextflow.BuildInfo
import nextflow.SysEnv
import nextflow.k8s.client.ClientConfig
import nextflow.k8s.model.PodEnv
import nextflow.k8s.model.PodSecurityContext
import nextflow.k8s.model.PodVolumeClaim
import nextflow.util.Duration
import spock.lang.Specification
import spock.lang.Unroll
/**
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
class K8sConfigTest extends Specification {
def 'should create config object' () {
when:
def cfg = new K8sConfig()
then:
cfg.getNamespace() == null
cfg.getServiceAccount() == null
!cfg.getDebug().getYaml()
when:
cfg = new K8sConfig( namespace:'foo', serviceAccount: 'bar', debug: [yaml: true] )
then:
cfg.getNamespace() == 'foo'
cfg.getServiceAccount() == 'bar'
cfg.getDebug().getYaml()
cfg.debug.yaml
}
def 'should set cleanup' () {
given:
K8sConfig cfg
when:
cfg = new K8sConfig()
then: 'it should return true when missing value'
cfg.getCleanup()
when:
cfg = new K8sConfig()
then: 'it should return false specified as default'
!cfg.getCleanup(false)
when:
cfg = new K8sConfig(cleanup:false)
then: 'it should return false'
!cfg.getCleanup()
when:
cfg = new K8sConfig(cleanup:true)
then: 'it should return true'
cfg.getCleanup()
when:
cfg = new K8sConfig(cleanup:true)
then: 'the default value should be ignored'
cfg.getCleanup(false)
}
def 'should create config with storage claims' () {
when:
def cfg = new K8sConfig(storageClaimName: 'pvc-1')
then:
cfg.getStorageClaimName() == 'pvc-1'
cfg.getStorageMountPath() == '/workspace'
cfg.getPodOptions().getVolumeClaims() == [ new PodVolumeClaim('pvc-1', '/workspace') ] as Set
when:
cfg = new K8sConfig([
storageClaimName: 'pvc-2',
storageMountPath: '/data',
pod: [ [volumeClaim:'foo', mountPath: '/here'],
[volumeClaim: 'bar', mountPath: '/there']] ])
then:
cfg.getStorageClaimName() == 'pvc-2'
cfg.getStorageMountPath() == '/data'
cfg.getPodOptions().getVolumeClaims() == [
new PodVolumeClaim('pvc-2', '/data'),
new PodVolumeClaim('foo', '/here'),
new PodVolumeClaim('bar', '/there')
] as Set
when:
cfg = new K8sConfig(storageClaimName: 'pvc-3', storageMountPath: '/some/path', storageSubPath: '/bar')
then:
cfg.getStorageClaimName() == 'pvc-3'
cfg.getStorageMountPath() == '/some/path'
cfg.getStorageSubPath() == '/bar'
cfg.getPodOptions().getVolumeClaims() == [ new PodVolumeClaim('pvc-3', '/some/path', '/bar') ] as Set
}
def 'should set device plugin' () {
when:
def cfg = new K8sConfig([:])
then:
cfg.fuseDevicePlugin() == ['nextflow.io/fuse':1]
when:
cfg = new K8sConfig([fuseDevicePlugin:['foo/fuse':10]])
then:
cfg.fuseDevicePlugin() == ['foo/fuse':10]
}
def 'should create client config' () {
given:
def CONFIG = [namespace: 'this', serviceAccount: 'that', client: [server: 'http://foo']]
when:
def config = new K8sConfig(CONFIG)
def client = config.getClient()
then:
client.server == 'http://foo'
client.namespace == 'this'
client.serviceAccount == 'that'
client.httpConnectTimeout == null // testing default null
client.httpReadTimeout == null // testing default null
client.retryConfig.maxAttempts == 4
}
def 'should create client config with http request timeouts' () {
given:
def CONFIG = [
namespace: 'this',
serviceAccount: 'that',
client: [server: 'http://foo'],
httpReadTimeout: '20s',
httpConnectTimeout: '25s' ]
when:
def config = new K8sConfig(CONFIG)
def client = config.getClient()
then:
client.server == 'http://foo'
client.namespace == 'this'
client.serviceAccount == 'that'
client.httpConnectTimeout == Duration.of('25s')
client.httpReadTimeout == Duration.of('20s')
}
@Unroll
def 'should create client config with discovery' () {
given:
def CONFIG = [context: CONTEXT, namespace: NAMESPACE, serviceAccount: SERVICE_ACCOUNT]
K8sConfig config = Spy(K8sConfig, constructorArgs: [ CONFIG ])
when:
def client = config.getClient()
then:
1 * config.clientDiscovery(CONTEXT, NAMESPACE, SERVICE_ACCOUNT) >> new ClientConfig(namespace: NAMESPACE, server: SERVER)
and:
client.server == SERVER
client.namespace == NAMESPACE ?: 'default'
client.serviceAccount == SERVICE_ACCOUNT ?: 'default'
where:
CONTEXT | SERVER | NAMESPACE | SERVICE_ACCOUNT
'foo' | 'host.com'| null | null
'bar' | 'this.com'| 'ns1' | 'sa2'
}
def 'should get nextflow image name' () {
when:
def cfg = new K8sConfig()
then:
cfg.getNextflowImageName() == "nextflow/nextflow:${BuildInfo.version}"
}
def 'should get autoMountHostPaths' () {
when:
def cfg = new K8sConfig()
then:
!cfg.getAutoMountHostPaths()
when:
cfg = new K8sConfig(autoMountHostPaths: true)
then:
cfg.getAutoMountHostPaths()
when:
cfg = new K8sConfig(autoMountHostPaths: false)
then:
!cfg.getAutoMountHostPaths()
}
def 'should get podOptions' () {
when:
def cfg = new K8sConfig()
def opts = cfg.getPodOptions()
then:
opts.envVars == [] as Set
opts.mountSecrets == [] as Set
opts.mountConfigMaps == [] as Set
opts.volumeClaims == [] as Set
when:
opts = new K8sConfig(pod: [ [pullPolicy: 'Always'], [env: 'HELLO', value: 'WORLD'] ]).getPodOptions()
then:
opts.getImagePullPolicy() == 'Always'
opts.getEnvVars() == [ PodEnv.value('HELLO','WORLD') ] as Set
}
def 'should return user name' () {
when:
def cfg = new K8sConfig()
then:
cfg.getUserName() == System.properties.get('user.name')
when:
cfg = new K8sConfig(userName: 'foo')
then:
cfg.getUserName() == 'foo'
}
def 'should return user dir' () {
when:
def cfg = new K8sConfig()
then:
cfg.getLaunchDir() == '/workspace/' + System.properties.get('user.name')
when:
cfg = new K8sConfig(storageMountPath: '/this/path', userName: 'foo')
then:
cfg.getLaunchDir() == '/this/path/foo'
when:
cfg = new K8sConfig(storageMountPath: '/this/path', userName: 'foo', launchDir: '/my/path')
then:
cfg.getLaunchDir() == '/my/path'
}
def 'should return work dir' () {
when:
def cfg = new K8sConfig()
then:
cfg.getWorkDir() == "/workspace/${System.properties.get('user.name')}/work"
when:
cfg = new K8sConfig(launchDir: '/my/dir')
then:
cfg.getWorkDir() == "/my/dir/work"
when:
cfg = new K8sConfig(launchDir: '/my/dir', workDir: '/the/wor/dir')
then:
cfg.getWorkDir() == "/the/wor/dir"
}
def 'should return project dir' () {
when:
def cfg = new K8sConfig()
then:
cfg.getProjectDir() == '/workspace/projects'
when:
cfg = new K8sConfig(storageMountPath: '/foo')
then:
cfg.getProjectDir() == '/foo/projects'
when:
cfg = new K8sConfig(storageMountPath: '/foo', projectDir: '/my/project/dir')
then:
cfg.getProjectDir() == '/my/project/dir'
}
def 'should return storage dir' () {
when:
def cfg = new K8sConfig()
then:
cfg.getStorageMountPath() == '/workspace'
when:
cfg = new K8sConfig(storageMountPath: '/mnt/there')
then:
cfg.getStorageMountPath() == '/mnt/there'
}
def 'should return compute resource type' () {
when:
def cfg = new K8sConfig()
then:
!cfg.useJobResource()
when:
cfg = new K8sConfig(computeResourceType: 'Job')
then:
cfg.useJobResource()
}
def 'should return storage claim name' () {
when:
def cfg = new K8sConfig()
then:
cfg.getStorageClaimName() == null
when:
cfg = new K8sConfig(storageClaimName: 'xxx')
then:
cfg.getStorageClaimName() == 'xxx'
}
def 'should create k8s config with one volume claim' () {
when:
def cfg = new K8sConfig( pod: [runAsUser: 1000] )
then:
cfg.getPodOptions().getSecurityContext() == new PodSecurityContext(1000)
cfg.getPodOptions().getVolumeClaims().size() == 0
when:
cfg = new K8sConfig( pod: [volumeClaim: 'nf-0001', mountPath: '/workspace'] )
then:
cfg.getPodOptions().getSecurityContext() == null
cfg.getPodOptions().getVolumeClaims() == [new PodVolumeClaim('nf-0001', '/workspace')] as Set
when:
cfg = new K8sConfig( pod: [
[runAsUser: 1000],
[volumeClaim: 'nf-0001', mountPath: '/workspace'],
[volumeClaim: 'nf-0002', mountPath: '/data', subPath: '/home']
])
then:
cfg.getPodOptions().getSecurityContext() == new PodSecurityContext(1000)
cfg.getPodOptions().getVolumeClaims() == [
new PodVolumeClaim('nf-0001', '/workspace'),
new PodVolumeClaim('nf-0002', '/data', '/home')
] as Set
}
def 'should set the sec context'( ) {
given:
def ctx = [runAsUser: 500, fsGroup: 200, allowPrivilegeEscalation: true, seLinuxOptions: [level: "s0:c123,c456"]]
when:
def cfg = new K8sConfig( runAsUser: 500 )
then:
cfg.getPodOptions().getSecurityContext() == new PodSecurityContext(500)
when:
cfg = new K8sConfig( securityContext: ctx )
then:
cfg.getPodOptions().getSecurityContext() == new PodSecurityContext(ctx)
}
def 'should set env and sec context' () {
given:
def ctx = [
[env: 'FUSION_BUCKETS', value: 's3://nextflow-ci'],
[securityContext: [privileged: true]]]
when:
def cfg = new K8sConfig( pod: ctx )
then:
cfg.getPodOptions().getEnvVars().first() == PodEnv.value('FUSION_BUCKETS', 's3://nextflow-ci')
cfg.getPodOptions().getSecurityContext().toSpec() == [privileged:true]
}
def 'should set the image pull policy' () {
when:
def cfg = new K8sConfig( pullPolicy: 'always' )
then:
cfg.getPodOptions().getImagePullPolicy() == 'always'
}
def 'should set preserve entrypoint setting'( ) {
when:
def cfg = new K8sConfig([:])
then:
!cfg.entrypointOverride()
when:
SysEnv.push(NXF_CONTAINER_ENTRYPOINT_OVERRIDE: 'true')
cfg = new K8sConfig()
def result = cfg.entrypointOverride()
SysEnv.pop()
then:
result
}
def 'should set debug.yaml' () {
when:
def cfg = new K8sConfig( debug: [yaml: true] )
then:
cfg.getDebug().getYaml()
when:
cfg = new K8sConfig( debug: [yaml: false] )
then:
!cfg.getDebug().getYaml()
when:
cfg = new K8sConfig( debug: null )
then:
!cfg.getDebug().getYaml()
when:
cfg = new K8sConfig( debug: [:] )
then:
!cfg.getDebug().getYaml()
}
def 'should set fetchNodeName' () {
when:
def cfg = new K8sConfig( fetchNodeName: true )
then:
cfg.fetchNodeName() == true
when:
cfg = new K8sConfig( fetchNodeName: false )
then:
cfg.fetchNodeName() == false
when:
cfg = new K8sConfig()
then:
cfg.fetchNodeName() == false
}
def 'should set clientRefreshInterval' () {
when:
def cfg = new K8sConfig()
then:
cfg.clientRefreshInterval == Duration.of('50m')
when:
cfg = new K8sConfig(clientRefreshInterval: '30m')
then:
cfg.clientRefreshInterval == Duration.of('30m')
when:
cfg = new K8sConfig(clientRefreshInterval: '1h')
then:
cfg.clientRefreshInterval == Duration.of('1h')
}
}

View File

@@ -0,0 +1,672 @@
/*
* 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.k8s
import java.nio.file.Files
import nextflow.cli.CliOptions
import nextflow.cli.CmdKubeRun
import nextflow.cli.Launcher
import nextflow.k8s.client.ClientConfig
import nextflow.k8s.client.K8sClient
import nextflow.k8s.model.PodMountConfig
import nextflow.k8s.model.PodOptions
import nextflow.k8s.model.PodSpecBuilder
import nextflow.k8s.model.PodVolumeClaim
import spock.lang.Specification
import spock.lang.Unroll
/**
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
class K8sDriverLauncherTest extends Specification {
def setup() {
PodSpecBuilder.VOLUMES.set(0)
}
def 'should execute run' () {
given:
def NAME = 'nxf-foo'
def NF_CONFIG = [process:[executor:'k8s']]
def K8S_CONFIG = Mock(K8sConfig)
def K8S_CLIENT = Mock(K8sClient)
def driver = Spy(K8sDriverLauncher)
when:
driver.run(NAME, ['a','b','c'])
then:
1 * driver.makeConfig(NAME) >> NF_CONFIG
1 * driver.makeK8sConfig(NF_CONFIG) >> K8S_CONFIG
1 * driver.makeK8sClient(K8S_CONFIG) >> K8S_CLIENT
1 * K8S_CONFIG.checkStorageAndPaths(K8S_CLIENT)
1 * driver.createK8sConfigMap() >> null
1 * driver.createK8sLauncherPod() >> null
1 * driver.waitPodStart() >> null
1 * driver.printK8sPodOutput() >> null
driver.pipelineName == NAME
driver.interactive == false
driver.config == NF_CONFIG
driver.k8sConfig == K8S_CONFIG
driver.k8sClient == K8S_CLIENT
}
def 'should make k8s config' () {
given:
K8sConfig k8sConfig
K8sDriverLauncher driver = Spy(K8sDriverLauncher)
when:
k8sConfig = driver.makeK8sConfig([:])
then:
k8sConfig != null
when:
k8sConfig = driver.makeK8sConfig(k8s: [storageClaimName: 'foo', storageMountPath: '/mnt'])
then:
k8sConfig.getStorageClaimName() == 'foo'
k8sConfig.getStorageMountPath() == '/mnt'
}
@Unroll
def 'should get cmd cli' () {
given:
def l = new K8sDriverLauncher(cmd: cmd, pipelineName: 'foo')
when:
cmd.launcher = new Launcher(options: new CliOptions())
then:
l.getLaunchCli() == expected
where:
cmd | expected
new CmdKubeRun() | 'nextflow run foo'
new CmdKubeRun(cacheable: false) | 'nextflow run foo -cache false'
new CmdKubeRun(resume: true) | 'nextflow run foo -resume true'
new CmdKubeRun(poolSize: 10) | 'nextflow run foo -ps 10'
new CmdKubeRun(pollInterval: 5) | 'nextflow run foo -pi 5'
new CmdKubeRun(queueSize: 9) | 'nextflow run foo -qs 9'
new CmdKubeRun(revision: 'xyz') | 'nextflow run foo -r xyz'
new CmdKubeRun(latest: true) | 'nextflow run foo -latest true'
new CmdKubeRun(withTrace: true) | 'nextflow run foo -with-trace true'
new CmdKubeRun(withTimeline: true) | 'nextflow run foo -with-timeline true'
new CmdKubeRun(withDag: true) | 'nextflow run foo -with-dag true'
new CmdKubeRun(dumpHashes: true) | 'nextflow run foo -dump-hashes true'
new CmdKubeRun(dumpChannels: 'lala') | 'nextflow run foo -dump-channels lala'
new CmdKubeRun(env: [XX:'hello', YY: 'world']) | 'nextflow run foo -e.XX hello -e.YY world'
new CmdKubeRun(process: [mem: '100',cpus:'2']) | 'nextflow run foo -process.mem 100 -process.cpus 2'
new CmdKubeRun(params: [alpha:'x', beta:'y']) | 'nextflow run foo --alpha x --beta y'
new CmdKubeRun(params: [alpha: '/path/*.txt']) | 'nextflow run foo --alpha /path/\\*.txt'
new CmdKubeRun(entryName: 'lala') | 'nextflow run foo -entry lala'
}
def 'should set the run name' () {
given:
def cmd = new CmdKubeRun()
cmd.launcher = new Launcher(options: new CliOptions())
when:
def l = new K8sDriverLauncher(cmd: cmd, pipelineName: 'foo', runName: 'bar')
then:
l.getLaunchCli() == 'nextflow run foo -name bar'
}
def 'should create launcher spec pod' () {
given:
def pod = Mock(PodOptions)
pod.getVolumeClaims() >> [ new PodVolumeClaim('pvc-1', '/mnt/path/data') ]
pod.getMountConfigMaps() >> [ new PodMountConfig('cfg-2', '/mnt/path/cfg') ]
def k8s = Mock(K8sConfig)
k8s.getNextflowImageName() >> 'the-image'
k8s.getLaunchDir() >> '/the/user/dir'
k8s.getWorkDir() >> '/the/work/dir'
k8s.getProjectDir() >> '/the/project/dir'
k8s.getPodOptions() >> pod
and:
def driver = Spy(K8sDriverLauncher)
driver.@runName = 'foo-boo'
driver.@k8sClient = new K8sClient(new ClientConfig(namespace: 'foo', serviceAccount: 'bar'))
driver.@k8sConfig = k8s
when:
def spec = driver.makeLauncherSpec()
then:
driver.getLaunchCli() >> 'nextflow run foo'
spec == [
apiVersion: 'v1',
kind: 'Pod',
metadata: [name:'foo-boo', namespace:'foo', labels:[app:'nextflow', runName:'foo-boo']],
spec: [
automountServiceAccountToken: false,
restartPolicy: 'Never',
containers: [
[
name: 'foo-boo',
image: 'the-image',
command: ['/bin/bash', '-c', "source /etc/nextflow/init.sh; nextflow run foo"],
env: [
[name:'NXF_WORK', value:'/the/work/dir'],
[name:'NXF_ASSETS', value:'/the/project/dir'],
[name:'NXF_EXECUTOR', value:'k8s'],
[name:'NXF_ANSI_LOG', value: 'false']
],
volumeMounts: [
[name:'vol-1', mountPath:'/mnt/path/data'],
[name:'vol-2', mountPath:'/mnt/path/cfg']
]
]
],
serviceAccountName: 'bar',
volumes: [
[name:'vol-1', persistentVolumeClaim:[claimName:'pvc-1']],
[name:'vol-2', configMap:[name:'cfg-2']]
]
]
]
}
def 'should create launcher spec job' () {
given:
def pod = Mock(PodOptions)
pod.getVolumeClaims() >> [ new PodVolumeClaim('pvc-1', '/mnt/path/data') ]
pod.getMountConfigMaps() >> [ new PodMountConfig('cfg-2', '/mnt/path/cfg') ]
def k8s = Mock(K8sConfig)
k8s.getNextflowImageName() >> 'the-image'
k8s.getLaunchDir() >> '/the/user/dir'
k8s.getWorkDir() >> '/the/work/dir'
k8s.getProjectDir() >> '/the/project/dir'
k8s.getPodOptions() >> pod
k8s.useJobResource() >> true
and:
def driver = Spy(K8sDriverLauncher)
driver.@runName = 'foo-boo'
driver.@k8sClient = new K8sClient(new ClientConfig(namespace: 'foo', serviceAccount: 'bar'))
driver.@k8sConfig = k8s
and:
def metadata = [name: 'foo-boo', namespace: 'foo', labels: [app: 'nextflow', runName: 'foo-boo']]
when:
def spec = driver.makeLauncherSpec()
then:
driver.getLaunchCli() >> 'nextflow run foo'
spec == [
apiVersion: 'batch/v1',
kind: 'Job',
metadata: metadata,
spec: [
backoffLimit: 0,
template: [
metadata: metadata,
spec: [
automountServiceAccountToken: false,
restartPolicy: 'Never',
containers: [
[
name: 'foo-boo',
image: 'the-image',
command: ['/bin/bash', '-c', "source /etc/nextflow/init.sh; nextflow run foo"],
env: [
[name:'NXF_WORK', value:'/the/work/dir'],
[name:'NXF_ASSETS', value:'/the/project/dir'],
[name:'NXF_EXECUTOR', value:'k8s'],
[name:'NXF_ANSI_LOG', value: 'false']
],
volumeMounts: [
[name:'vol-1', mountPath:'/mnt/path/data'],
[name:'vol-2', mountPath:'/mnt/path/cfg']
]
]
],
serviceAccountName: 'bar',
volumes: [
[name:'vol-1', persistentVolumeClaim:[claimName:'pvc-1']],
[name:'vol-2', configMap:[name:'cfg-2']]
]
]
]
]
]
}
def 'should use user provided pod image' () {
given:
def pod = Mock(PodOptions)
pod.getVolumeClaims() >> [ new PodVolumeClaim('pvc-1', '/mnt/path/data') ]
pod.getMountConfigMaps() >> [ new PodMountConfig('cfg-2', '/mnt/path/cfg') ]
def k8s = Mock(K8sConfig)
k8s.getLaunchDir() >> '/the/user/dir'
k8s.getWorkDir() >> '/the/work/dir'
k8s.getProjectDir() >> '/the/project/dir'
k8s.getPodOptions() >> pod
and:
def driver = Spy(K8sDriverLauncher)
driver.@runName = 'foo-boo'
driver.@k8sClient = new K8sClient(new ClientConfig(namespace: 'foo', serviceAccount: 'bar'))
driver.@k8sConfig = k8s
driver.@headImage = 'foo/bar'
when:
def result = driver.makeLauncherSpec()
then:
driver.getLaunchCli() >> 'nextflow run foo'
and:
result.spec.containers[0].image == 'foo/bar'
}
def 'should use user provided head-cpu and head-memory request' () {
given:
def pod = Mock(PodOptions)
pod.getVolumeClaims() >> [ new PodVolumeClaim('pvc-1', '/mnt/path/data') ]
pod.getMountConfigMaps() >> [ new PodMountConfig('cfg-2', '/mnt/path/cfg') ]
def k8s = Mock(K8sConfig)
k8s.getNextflowImageName() >> 'the-image'
k8s.getLaunchDir() >> '/the/user/dir'
k8s.getWorkDir() >> '/the/work/dir'
k8s.getProjectDir() >> '/the/project/dir'
k8s.getPodOptions() >> pod
and:
def driver = Spy(K8sDriverLauncher)
driver.@runName = 'foo-boo'
driver.@k8sClient = new K8sClient(new ClientConfig(namespace: 'foo', serviceAccount: 'bar'))
driver.@k8sConfig = k8s
driver.@headCpus = 2
driver.@headMemory = '200Mi'
when:
def result = driver.makeLauncherSpec()
then:
driver.getLaunchCli() >> 'nextflow run foo'
and:
result.spec.containers[0].resources == [
requests: [cpu: 2, memory: '200Mi'],
limits: [memory: '200Mi']
]
}
def 'should use user provided head-cpu and head-memory limits' () {
given:
def pod = Mock(PodOptions)
pod.getVolumeClaims() >> [ new PodVolumeClaim('pvc-1', '/mnt/path/data') ]
pod.getMountConfigMaps() >> [ new PodMountConfig('cfg-2', '/mnt/path/cfg') ]
def k8s = Mock(K8sConfig)
k8s.getNextflowImageName() >> 'the-image'
k8s.getLaunchDir() >> '/the/user/dir'
k8s.getWorkDir() >> '/the/work/dir'
k8s.getProjectDir() >> '/the/project/dir'
k8s.getPodOptions() >> pod
k8s.cpuLimitsEnabled() >> true
and:
def driver = Spy(K8sDriverLauncher)
driver.@runName = 'foo-boo'
driver.@k8sClient = new K8sClient(new ClientConfig(namespace: 'foo', serviceAccount: 'bar'))
driver.@k8sConfig = k8s
driver.@headCpus = 2
driver.@headMemory = '200Mi'
when:
def result = driver.makeLauncherSpec()
then:
driver.getLaunchCli() >> 'nextflow run foo'
and:
result.spec.containers[0].resources == [
requests: [cpu: 2, memory: '200Mi'],
limits: [cpu: 2, memory: '200Mi']
]
}
def 'should create config map' () {
given:
def folder = Files.createTempDirectory('foo')
def params = folder.resolve('params.json')
params.text = 'bla-bla'
def driver = Spy(K8sDriverLauncher)
def NXF_CONFIG = [foo: 'bar'].toConfigObject()
def SCM_FILE = folder.resolve('scm')
SCM_FILE.text = "hello = 'world'\n"
def EXPECTED = [:]
EXPECTED['init.sh'] == ''
def POD_OPTIONS = new PodOptions()
def K8S_CONFIG = Mock(K8sConfig)
K8S_CONFIG.getLaunchDir() >> '/launch/dir'
K8S_CONFIG.getPodOptions() >> POD_OPTIONS
when:
driver.@config = NXF_CONFIG
driver.@k8sConfig = K8S_CONFIG
driver.@cmd = new CmdKubeRun(paramsFile: params.toString())
driver.createK8sConfigMap()
then:
1 * driver.getScmFile() >> SCM_FILE
1 * driver.makeConfigMapName(_ as Map) >> 'nf-config-123'
1 * driver.tryCreateConfigMap('nf-config-123', _ as Map) >> { name, cfg ->
assert cfg.'init.sh' == "mkdir -p '/launch/dir'; if [ -d '/launch/dir' ]; then cd '/launch/dir'; else echo 'Cannot create directory: /launch/dir'; exit 1; fi; [ -f /etc/nextflow/scm ] && ln -s /etc/nextflow/scm \$NXF_HOME/scm; [ -f /etc/nextflow/nextflow.config ] && cp /etc/nextflow/nextflow.config \$PWD/nextflow.config; "
assert cfg.'nextflow.config' == "foo = 'bar'\n"
assert cfg.'scm' == "hello = 'world'\n"
assert cfg.'params.json' == 'bla-bla'
return null
}
POD_OPTIONS.getMountConfigMaps() == [ new PodMountConfig('nf-config-123', '/etc/nextflow') ] as Set
cleanup:
folder?.deleteDir()
}
def 'should create config map with pre-script' () {
given:
def folder = Files.createTempDirectory('foo')
def params = folder.resolve('params.json')
params.text = 'bla-bla'
def driver = Spy(K8sDriverLauncher)
driver.@headPreScript = '/bin/foo.sh'
def NXF_CONFIG = [foo: 'bar'].toConfigObject()
def SCM_FILE = folder.resolve('scm')
SCM_FILE.text = "hello = 'world'\n"
def EXPECTED = [:]
EXPECTED['init.sh'] == ''
def POD_OPTIONS = new PodOptions()
def K8S_CONFIG = Mock(K8sConfig)
K8S_CONFIG.getLaunchDir() >> '/launch/dir'
K8S_CONFIG.getPodOptions() >> POD_OPTIONS
when:
driver.@config = NXF_CONFIG
driver.@k8sConfig = K8S_CONFIG
driver.@cmd = new CmdKubeRun(paramsFile: params.toString())
driver.createK8sConfigMap()
then:
1 * driver.getScmFile() >> SCM_FILE
1 * driver.makeConfigMapName(_ as Map) >> 'nf-config-123'
1 * driver.tryCreateConfigMap('nf-config-123', _ as Map) >> { name, cfg ->
assert cfg.'init.sh' == "mkdir -p '/launch/dir'; if [ -d '/launch/dir' ]; then cd '/launch/dir'; else echo 'Cannot create directory: /launch/dir'; exit 1; fi; [ -f /etc/nextflow/scm ] && ln -s /etc/nextflow/scm \$NXF_HOME/scm; [ -f /etc/nextflow/nextflow.config ] && cp /etc/nextflow/nextflow.config \$PWD/nextflow.config; [ -f '/bin/foo.sh' ] && '/bin/foo.sh'; "
assert cfg.'nextflow.config' == "foo = 'bar'\n"
assert cfg.'scm' == "hello = 'world'\n"
assert cfg.'params.json' == 'bla-bla'
return null
}
POD_OPTIONS.getMountConfigMaps() == [ new PodMountConfig('nf-config-123', '/etc/nextflow') ] as Set
cleanup:
folder?.deleteDir()
}
def 'should make config' () {
given:
Map config
def driver = Spy(K8sDriverLauncher)
def NAME = 'somePipelineName'
def CFG_EMPTY = new ConfigObject()
def CFG_WITH_MOUNTS = new ConfigObject()
CFG_WITH_MOUNTS.k8s.storageClaimName = 'pvc'
CFG_WITH_MOUNTS.k8s.storageMountPath = '/foo'
when:
driver.@cmd = new CmdKubeRun()
config = driver.makeConfig(NAME).toMap()
then:
1 * driver.loadConfig(NAME) >> CFG_EMPTY
config.process.executor == 'k8s'
config.k8s.pod == null
config.k8s.storageMountPath == null
config.k8s.storageClaimName == null
when:
driver.@cmd = new CmdKubeRun()
config = driver.makeConfig(NAME).toMap()
then:
1 * driver.loadConfig(NAME) >> CFG_WITH_MOUNTS
config.process.executor == 'k8s'
config.k8s.storageClaimName == 'pvc'
config.k8s.storageMountPath == '/foo'
and:
new K8sConfig(config.k8s).getStorageClaimName() == 'pvc'
new K8sConfig(config.k8s).getStorageMountPath() == '/foo'
new K8sConfig(config.k8s).getPodOptions() == new PodOptions([ [volumeClaim:'pvc', mountPath: '/foo'] ])
when:
driver.@cmd = new CmdKubeRun(volMounts: ['pvc-1:/this','pvc-2:/that'] )
config = driver.makeConfig(NAME).toMap()
then:
1 * driver.loadConfig(NAME) >> CFG_EMPTY
config.process.executor == 'k8s'
config.k8s.storageClaimName == 'pvc-1'
config.k8s.storageMountPath == '/this'
config.k8s.pod == [ [volumeClaim: 'pvc-2', mountPath: '/that'] ]
and:
new K8sConfig(config.k8s).getStorageClaimName() == 'pvc-1'
new K8sConfig(config.k8s).getStorageMountPath() == '/this'
new K8sConfig(config.k8s).getPodOptions() == new PodOptions([
[volumeClaim:'pvc-1', mountPath: '/this'],
[volumeClaim:'pvc-2', mountPath: '/that']
])
when:
driver.@cmd = new CmdKubeRun(volMounts: ['xyz:/this'] )
config = driver.makeConfig(NAME).toMap()
then:
1 * driver.loadConfig(NAME) >> CFG_WITH_MOUNTS
config.process.executor == 'k8s'
config.k8s.storageClaimName == 'xyz'
config.k8s.storageMountPath == '/this'
config.k8s.pod == null
and:
new K8sConfig(config.k8s).getStorageClaimName() == 'xyz'
new K8sConfig(config.k8s).getStorageMountPath() == '/this'
new K8sConfig(config.k8s).getPodOptions() == new PodOptions([
[volumeClaim:'xyz', mountPath: '/this']
])
when:
driver.@cmd = new CmdKubeRun(volMounts: ['xyz', 'bar:/mnt/bar'] )
config = driver.makeConfig(NAME).toMap()
then:
1 * driver.loadConfig(NAME) >> CFG_WITH_MOUNTS
config.process.executor == 'k8s'
config.k8s.storageClaimName == 'xyz'
config.k8s.storageMountPath == null
config.k8s.pod == [ [volumeClaim: 'bar', mountPath: '/mnt/bar'] ]
and:
new K8sConfig(config.k8s).getStorageClaimName() == 'xyz'
new K8sConfig(config.k8s).getStorageMountPath() == '/workspace'
new K8sConfig(config.k8s).getPodOptions() == new PodOptions([
[volumeClaim:'xyz', mountPath: '/workspace'],
[volumeClaim:'bar', mountPath: '/mnt/bar']
])
}
def 'should add the plugin into the config' () {
given:
def cmd = new CmdKubeRun()
cmd.launcher = new Launcher(options: new CliOptions())
when:
def l = new K8sDriverLauncher(cmd: cmd, plugins: 'nf-cws@1.0.0', runName: 'bar')
then:
l.makeConfig( "/bar").get('plugins') == [ 'nf-cws@1.0.0' ]
}
def 'should make config - deprecated' () {
given:
Map config
def driver = Spy(K8sDriverLauncher)
def NAME = 'somePipelineName'
def CFG_EMPTY = new ConfigObject()
def CFG_WITH_MOUNTS = new ConfigObject()
CFG_WITH_MOUNTS.k8s.volumeClaims = [ pvc: [mountPath:'/foo'] ]
when:
driver.@cmd = new CmdKubeRun()
config = driver.makeConfig(NAME).toMap()
then:
1 * driver.loadConfig(NAME) >> CFG_EMPTY
config.process.executor == 'k8s'
when:
driver.@cmd = new CmdKubeRun()
config = driver.makeConfig(NAME).toMap()
then:
1 * driver.loadConfig(NAME) >> CFG_WITH_MOUNTS
config.process.executor == 'k8s'
config.k8s.storageClaimName == 'pvc'
config.k8s.storageMountPath == '/foo'
when:
driver.@cmd = new CmdKubeRun(volMounts: ['pvc-1:/this','pvc-2:/that'] )
config = driver.makeConfig(NAME).toMap()
then:
1 * driver.loadConfig(NAME) >> CFG_EMPTY
config.process.executor == 'k8s'
config.k8s.storageClaimName == 'pvc-1'
config.k8s.storageMountPath == '/this'
config.k8s.pod == [ [volumeClaim: 'pvc-2', mountPath: '/that'] ]
when:
driver.@cmd = new CmdKubeRun(volMounts: ['xyz:/this'] )
config = driver.makeConfig(NAME).toMap()
then:
1 * driver.loadConfig(NAME) >> CFG_WITH_MOUNTS
config.process.executor == 'k8s'
config.k8s.storageClaimName == 'xyz'
config.k8s.storageMountPath == '/this'
config.k8s.pod == null
and:
new K8sConfig(config.k8s).getStorageClaimName() == 'xyz'
new K8sConfig(config.k8s).getStorageMountPath() == '/this'
new K8sConfig(config.k8s).getPodOptions() == new PodOptions([
[volumeClaim:'xyz', mountPath: '/this']
])
}
def 'should return pod exit status' () {
given:
def POD_NAME = 'pod-x'
def client = Mock(K8sClient)
def driver = Spy(K8sDriverLauncher)
driver.@k8sClient = client
driver.@runName = POD_NAME
driver.@k8sConfig = Mock(K8sConfig)
when:
def status = driver.waitPodTermination()
then:
1 * client.podState(POD_NAME) >> [terminated: [exitCode: 99]]
1 * driver.k8sConfig.useJobResource() >> [:]
then:
status == 99
when:
status = driver.waitPodTermination()
then:
1 * client.podState(POD_NAME) >> [:]
then:
1 * client.podState(POD_NAME) >> [terminated: [exitCode: 99]]
then:
status == 99
when:
status = driver.waitPodTermination()
then:
1 * client.podState(POD_NAME) >> [:]
1 * driver.isWaitTimedOut(_) >> true
then:
status == 127
}
def 'should delete configMap' () {
given:
def POD_NAME = 'pod-x'
def config = Mock(K8sConfig)
def driver = Spy(K8sDriverLauncher)
driver.@k8sConfig = config
driver.@runName = POD_NAME
when:
driver.shutdown()
then:
1 * driver.waitPodTermination() >> 0
then:
1 * config.getCleanup(true) >> true
1 * driver.deleteConfigMap() >> null
when:
driver.shutdown()
then:
1 * driver.waitPodTermination() >> 1
then:
1 * config.getCleanup(false) >> true
1 * driver.deleteConfigMap() >> null
when:
driver.shutdown()
then:
1 * driver.waitPodTermination() >> 1
then:
1 * config.getCleanup(false) >> false
0 * driver.deleteConfigMap() >> null
}
}

View File

@@ -0,0 +1,66 @@
/*
* 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.k8s
import java.util.concurrent.TimeUnit
import com.google.common.cache.CacheBuilder
import nextflow.k8s.client.ClientConfig
import nextflow.k8s.client.K8sClient
import spock.lang.Specification
/**
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
class K8sExecutorTest extends Specification {
def 'should cache k8s client and refresh after expiration' () {
given:
def CONFIG = new K8sConfig(
client: [server: 'http://k8s-server'],
namespace: 'test-ns',
serviceAccount: 'test-sa',
clientRefreshInterval: '100ms'
)
and:
def executor = Spy(K8sExecutor)
executor.getK8sConfig() >> CONFIG
// use a short-lived cache for the test
executor.@clientCache = CacheBuilder.newBuilder()
.expireAfterWrite(100, TimeUnit.MILLISECONDS)
.build()
when: 'first call to getClient'
def client1 = executor.getClient()
then: 'a new K8sClient is created'
client1 instanceof K8sClient
client1.config.server == 'http://k8s-server'
when: 'second call within cache interval'
def client2 = executor.getClient()
then: 'returns the same cached instance'
client2.is(client1)
when: 'call after cache expiration'
sleep(150)
def client3 = executor.getClient()
then: 'a new K8sClient instance is created'
client3 instanceof K8sClient
!client3.is(client1)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,62 @@
/*
* 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.k8s
import java.nio.file.Files
import nextflow.Session
import nextflow.executor.Executor
import nextflow.processor.TaskConfig
import nextflow.processor.TaskProcessor
import nextflow.processor.TaskRun
import spock.lang.Specification
/**
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
class K8sWrapperBuilderTest extends Specification {
def 'should render launcher script' () {
given:
def folder = Files.createTempDirectory('test')
and:
def sess = Mock(Session)
def exec = Mock(Executor)
def proc = Mock(TaskProcessor) { getSession() >> sess; getExecutor() >> exec }
def config = new TaskConfig()
def task = Mock(TaskRun) {
getName() >> 'foo'
getConfig() >> config
getProcessor() >> proc
getWorkDir() >> folder
getInputFilesMap() >> [:]
getOutputFilesNames() >> []
}
and:
def builder = Spy(new K8sWrapperBuilder(task)) { getSecretsEnv() >> null; fixOwnership() >> false }
when:
def binding = builder.makeBinding()
then:
binding.header_script == "NXF_CHDIR=${folder}"
cleanup:
folder?.deleteDir()
}
}

View File

@@ -0,0 +1,120 @@
/*
* 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.k8s.client
import java.nio.file.Files
import spock.lang.Specification
/**
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
class ClientConfigTest extends Specification {
def 'should stringify a config' () {
when:
final CERT = 'LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNTRENDQVRDZ0F3SUJBZ0lJRlFsM1l2Y2k1TWN3RFFZSktvWklodmNOQVFFTEJRQXdGVEVUTUJFR0ExVUUKQXhNS2EzVmlaWEp1WlhSbGN6QWVGdzB4T0RBeE1UTXlNREEyTlRaYUZ3MHhPVEF4TVRNeU1EQTJOVFphTURNeApGREFTQmdOVkJBb1RDMFJ2WTJ0bGNpQkpibU11TVJzd0dRWURWUVFERXhKa2IyTnJaWEl0Wm05eUxXUmxjMnQwCmIzQXdnWjh3RFFZSktvWklodmNOQVFFQkJRQURnWTBBTUlHSkFvR0JBUEtYT0ZsV2t2THIzb29ETGNFOElyME0KTzNBMHZqQlVvUzZ0bUdBbFRYYTd0QWQwM3BTMXNJNit0WVRwVlU2YXR6ZU9vU0VrOWhmaWxBdVNYdG1hSHZCUAp1czFEcG1LZEZRMWI3OFRkSnQ4OGV3c3BRajFxYUwvQldHeitMUzUrRHUrNUJuUGtmZlhDS1UxQTdUc2tZamJyClhxeDhlN2FWZURWTmFjZXc0Z0RqQWdNQkFBR2pBakFBTUEwR0NTcUdTSWIzRFFFQkN3VUFBNElCQVFBMXVtVlAKR29EZTVCRXJrb21qWXdITXhiTTd4UStibTYrUDE1T0pINUo0UGNQeU11d25ocC9ORVp1NnpsTTZSUUo3SUNKQgpHWTRBMnFKVmJsWUkwQkJzRkF1TXMreTAyazdVVVVoK0NRYVd0SXhBcFNmbkQ4dUVXQ0g5VE1ZNGdLbTZjTDhVCk1OVVl1RnpUQ2hmTS96RjdUMXVaZWxJYXNrYXFaWSt3a3hxa3YyRUQxQ2F5MDUxSXRWRXZVbDIvSVZyVHdrT20KZ25nL3Q4L2RkeDhpOUkzTFJrMTlTaERKdXlQZ1NrTTZRSWlSd09mRHk4V0ZFaURpd0hBS0ErSEZhTGhOOFJTMwpieDUvdEhEN01id0FpdnorNTU4YUFEQjNEd1ZpekthM2d5Wm4yUzRjUGFqZnNwODFqRkNIQS9QekdQdTU2MzJwCkxRN0gyRW1aYmJuUHFYTFgKLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo='.decodeBase64()
final KEY = 'LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlDWGdJQkFBS0JnUUR5bHpoWlZwTHk2OTZLQXkzQlBDSzlERHR3Tkw0d1ZLRXVyWmhnSlUxMnU3UUhkTjZVCnRiQ092cldFNlZWT21yYzNqcUVoSlBZWDRwUUxrbDdabWg3d1Q3ck5RNlppblJVTlcrL0UzU2JmUEhzTEtVSTkKYW1pL3dWaHMvaTB1Zmc3dnVRWno1SDMxd2lsTlFPMDdKR0kyNjE2c2ZIdTJsWGcxVFduSHNPSUE0d0lEQVFBQgpBb0dBYWRUOCtVU2lvU1d6bFVRanZ1eHNQMHRKMXY2N2hqdzFnVGFzaGkxZjZRK2tUNmgxdml5eGxPU3dMZ2JaCmQ0eFpwL3dxWVZwTm5rZnp6RVNUNnB5cEo5WTEwdHY1cFpSWG9HbG1NT2tIZSswUW45N0c5ZDRzL2JCV3lmYXYKRzhRTC9tZFN6Vy85YUdrSkpiNWU0VDlsSURvRDNFVDgwYUFWbzl2V0NPVUxsdWtDUVFEK0hINU5ucVBuSTdnTApWOUJKZzlRRVBwUTVYa2traW8rejZ2YkRHQU5rR1VPV1dmRURKUHE2Q2JBb1dqeWh1Qy9KS1dYRWs4Rkt0M1Y2CkhVNllYeVpGQWtFQTlHVE9XOFM4KzVNNHE5R3lNeURxN1ZkVHA3M2daeSsvNjVQam5hNlpDUnhTZklxL2xKUVoKY2F6MkhGYVRzRFdLbkdhWGNxTmdBVXNEODNyWTlzM3hCd0pCQUt5Vjc1YUtPMm0rRWI3cWVsV2p5bmpEZytwZQp4akNpUnkxOFZQSjJPYjlmaFU3MWNVS2dlQVdvbE5NalRuREw1dkNxUkNzNTZ4cnk5VC9sN2I2QlNUMENRUURnCjRoV2xDZTdnQzhOZEQzTkxhdUhpRGJZenB4dmp0Mk9Ca2E4ai9ISmptTVVxUnI0dEtPNFUxUlFPVlhoRzc2MmgKWnlHNjRpeklZOCs1N3ZQUWZ3Wm5Ba0VBdW9RWW1lUi90UWhIakhRNFlhZGRHbkNBQ2hZZ29ObEFzSGhGTElxVQo1ZTZaMXN2Q3VKU285TDVVRCtrclFUYWlGU01pRHZwZlJyVE1ZKzZ5Q0tTajd3PT0KLS0tLS1FTkQgUlNBIFBSSVZBVEUgS0VZLS0tLS0K'.decodeBase64()
def config = new ClientConfig()
config.clientCert = CERT
config.clientKey = KEY
config.sslCert = CERT
println config.toString()
then:
noExceptionThrown()
}
def 'should create a client config from a map' () {
given:
def MAP = [
server:'foo.com',
token: 'blah-blah',
namespace: 'my-namespace',
verifySsl: true,
sslCert: 'fizzbuzz'.bytes.encodeBase64().toString(),
clientCert: 'hello'.bytes.encodeBase64().toString(),
clientKey: 'world'.bytes.encodeBase64().toString() ]
when:
def result = ClientConfig.fromNextflowConfig(MAP, null, null)
then:
result.server == 'foo.com'
result.token == 'blah-blah'
result.namespace == 'my-namespace'
result.serviceAccount == 'default'
result.verifySsl
result.clientCert == 'hello'.bytes
result.clientKey == 'world'.bytes
result.sslCert == 'fizzbuzz'.bytes
when:
result = ClientConfig.fromNextflowConfig(MAP, 'ns1', 'sa2')
then:
result.server == 'foo.com'
result.token == 'blah-blah'
result.namespace == 'ns1'
result.serviceAccount == 'sa2'
result.verifySsl
result.clientCert == 'hello'.bytes
result.clientKey == 'world'.bytes
result.sslCert == 'fizzbuzz'.bytes
}
def 'should create a client config from a map with files' () {
given:
def folder = Files.createTempDirectory('test')
def file1 = folder.resolve('file1')
def file2 = folder.resolve('file2')
def file3 = folder.resolve('file3')
file1.text = 'fizzbuzz'.bytes.encodeBase64().toString()
file2.text = 'hello'.bytes.encodeBase64().toString()
file3.text = 'world'.bytes.encodeBase64().toString()
def MAP = [
server:'foo.com',
token: 'blah-blah',
namespace: 'my-namespace',
verifySsl: false,
sslCertFile: file1,
clientCertFile: file2,
clientKeyFile: file3 ]
when:
def result = ClientConfig.fromNextflowConfig(MAP, null, null)
then:
result.server == 'foo.com'
result.token == 'blah-blah'
result.namespace == 'my-namespace'
result.serviceAccount == 'default'
!result.verifySsl
result.sslCert == file1.text.bytes
result.clientCert == file2.text.bytes
result.clientKey == file3.text.bytes
cleanup:
folder?.deleteDir()
}
}

View File

@@ -0,0 +1,437 @@
/*
* 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.k8s.client
import javax.net.ssl.KeyManager
import java.nio.file.Files
import spock.lang.Specification
import test.TestHelper
/**
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
class ConfigDiscoveryTest extends Specification {
def 'should read config from file' () {
given:
final CERT_DATA = "d29ybGQgaGVsbG8="
final CLIENT_CERT = "aGVsbG8gd29ybGQ="
final CLIENT_KEY = "Y2lhbyBtaWFv"
def CONFIG = TestHelper.createInMemTempFile('config')
CONFIG.text = """
apiVersion: v1
clusters:
- cluster:
insecure-skip-tls-verify: true
server: https://localhost:6443
certificate-authority-data: $CERT_DATA
name: docker-for-desktop-cluster
contexts:
- context:
cluster: docker-for-desktop-cluster
user: docker-for-desktop
name: docker-for-desktop
current-context: docker-for-desktop
kind: Config
preferences: {}
users:
- name: docker-for-desktop
user:
client-certificate-data: $CLIENT_CERT
client-key-data: $CLIENT_KEY
"""
.stripIndent()
def discovery = Spy(ConfigDiscovery)
def KEY_MANAGERS = [] as KeyManager[]
when:
def config = discovery.fromKubeConfig(CONFIG, null, null, null)
then:
0 * discovery.discoverAuthToken(_, 'default',null) >> 'secret-token'
1 * discovery.createKeyManagers(CLIENT_CERT.decodeBase64(), CLIENT_KEY.decodeBase64()) >> KEY_MANAGERS
config.server == 'https://localhost:6443'
config.token == null
config.namespace == 'default'
config.serviceAccount == 'default'
config.clientCert == CLIENT_CERT.decodeBase64()
config.clientKey == CLIENT_KEY.decodeBase64()
config.sslCert == CERT_DATA.decodeBase64()
config.keyManagers.is( KEY_MANAGERS )
!config.verifySsl
!config.isFromCluster
}
def 'should read config from file with provided namespace' () {
given:
final CERT_DATA = "d29ybGQgaGVsbG8="
final CLIENT_CERT = "aGVsbG8gd29ybGQ="
final CLIENT_KEY = "Y2lhbyBtaWFv"
def CONFIG = TestHelper.createInMemTempFile('config')
CONFIG.text = """
apiVersion: v1
clusters:
- cluster:
insecure-skip-tls-verify: true
server: https://localhost:6443
certificate-authority-data: $CERT_DATA
name: docker-for-desktop-cluster
contexts:
- context:
cluster: docker-for-desktop-cluster
user: docker-for-desktop
name: docker-for-desktop
current-context: docker-for-desktop
kind: Config
preferences: {}
users:
- name: docker-for-desktop
user:
client-certificate-data: $CLIENT_CERT
client-key-data: $CLIENT_KEY
"""
.stripIndent()
def discovery = Spy(ConfigDiscovery)
def KEY_MANAGERS = [] as KeyManager[]
when:
def config = discovery.fromKubeConfig(CONFIG, 'docker-for-desktop', 'ns1', 'sa2')
then:
0 * discovery.discoverAuthToken('docker-for-desktop','ns1','sa2') >> 'secret-token'
1 * discovery.createKeyManagers(CLIENT_CERT.decodeBase64(), CLIENT_KEY.decodeBase64()) >> KEY_MANAGERS
config.server == 'https://localhost:6443'
config.token == null
config.namespace == 'ns1'
config.serviceAccount == 'sa2'
config.clientCert == CLIENT_CERT.decodeBase64()
config.clientKey == CLIENT_KEY.decodeBase64()
config.sslCert == CERT_DATA.decodeBase64()
config.keyManagers.is( KEY_MANAGERS )
!config.verifySsl
!config.isFromCluster
}
def 'should read config from file with cert files' () {
given:
def folder = Files.createTempDirectory(null)
def CA_FILE = folder.resolve('ca'); CA_FILE.text = 'ca-content'
def CLIENT_CERT_FILE = folder.resolve('client-cert'); CLIENT_CERT_FILE.text = 'client-cert-content'
def CLIENT_KEY_FILE = folder.resolve('client-key'); CLIENT_KEY_FILE.text = 'client-key-content'
def CONFIG = folder.resolve('config')
def KEY_MANAGERS = [] as KeyManager[]
CONFIG.text = """
apiVersion: v1
clusters:
- cluster:
insecure-skip-tls-verify: true
server: https://localhost:6443
certificate-authority: $CA_FILE
name: docker-for-desktop-cluster
contexts:
- context:
cluster: docker-for-desktop-cluster
user: docker-for-desktop
name: docker-for-desktop
current-context: docker-for-desktop
kind: Config
preferences: {}
users:
- name: docker-for-desktop
user:
client-certificate: $CLIENT_CERT_FILE
client-key: $CLIENT_KEY_FILE
"""
.stripIndent()
def discovery = Spy(ConfigDiscovery)
when:
def config = discovery.fromKubeConfig(CONFIG, null, null, null)
then:
1 * discovery.createKeyManagers( CLIENT_CERT_FILE.bytes, CLIENT_KEY_FILE.bytes ) >> KEY_MANAGERS
config.server == 'https://localhost:6443'
config.token == null
config.namespace == 'default'
config.serviceAccount == 'default'
config.clientCert == CLIENT_CERT_FILE.bytes
config.clientKey == CLIENT_KEY_FILE.bytes
config.sslCert == CA_FILE.bytes
config.keyManagers.is( KEY_MANAGERS )
!config.verifySsl
!config.isFromCluster
cleanup:
folder?.deleteDir()
}
def 'should read config and use token' () {
given:
def folder = Files.createTempDirectory(null)
def CONFIG = folder.resolve('config')
CONFIG.text = """
apiVersion: v1
clusters:
- cluster:
insecure-skip-tls-verify: true
server: https://localhost:6443
name: docker-for-desktop-cluster
contexts:
- context:
cluster: docker-for-desktop-cluster
user: docker-for-desktop
name: docker-for-desktop
current-context: docker-for-desktop
kind: Config
preferences: {}
users:
- name: docker-for-desktop
user:
token: 90s090s98s7f8s
"""
.stripIndent()
def discovery = Spy(ConfigDiscovery)
when:
def config = discovery.fromKubeConfig(CONFIG, null, null, null)
then:
0 * discovery.discoverAuthToken(_,_,_) >> 'secret-token'
0 * discovery.createKeyManagers( _, _ ) >> null
config.server == 'https://localhost:6443'
config.token == '90s090s98s7f8s'
config.namespace == 'default'
config.serviceAccount == 'default'
!config.verifySsl
!config.isFromCluster
cleanup:
folder?.deleteDir()
}
def 'should read config and discover token' () {
given:
def folder = Files.createTempDirectory(null)
def CONFIG = folder.resolve('config')
CONFIG.text = """
apiVersion: v1
clusters:
- cluster:
insecure-skip-tls-verify: true
server: https://localhost:6443
name: docker-for-desktop-cluster
contexts:
- context:
cluster: docker-for-desktop-cluster
user: docker-for-desktop
name: docker-for-desktop
current-context: docker-for-desktop
kind: Config
preferences: {}
users:
- name: docker-for-desktop
user:
foo: bar
"""
.stripIndent()
def discovery = Spy(ConfigDiscovery)
when:
def config = discovery.fromKubeConfig(CONFIG, null, null, null)
then:
1 * discovery.discoverAuthToken(_, _, _) >> 'secret-token'
0 * discovery.createKeyManagers( _, _ ) >> null
config.server == 'https://localhost:6443'
config.token == 'secret-token'
config.namespace == 'default'
config.serviceAccount == 'default'
!config.verifySsl
!config.isFromCluster
cleanup:
folder?.deleteDir()
}
def 'should read config from given context' () {
given:
def folder = Files.createTempDirectory(null)
folder.resolve('fake-cert-file').text = 'fake-cert-content'
folder.resolve('fake-key-file').text = 'fake-key-content'
folder.resolve('fake-ca-file').text = 'fake-ca-content'
def CONFIG = folder.resolve('config')
CONFIG.text = '''
apiVersion: v1
clusters:
- cluster:
certificate-authority: fake-ca-file
server: https://1.2.3.4
name: development
- cluster:
insecure-skip-tls-verify: true
server: https://5.6.7.8
name: scratch
contexts:
- context:
cluster: development
namespace: frontend
user: developer
name: dev-frontend
- context:
cluster: development
namespace: storage
user: developer
name: dev-storage
- context:
cluster: scratch
namespace: default
user: experimenter
name: exp-scratch
current-context: ""
kind: Config
preferences: {}
users:
- name: developer
user:
client-certificate: fake-cert-file
- name: experimenter
user:
password: some-password
username: exp
'''.stripIndent()
when:
def cfg1 = new ConfigDiscovery().fromKubeConfig(CONFIG, 'dev-frontend', null, null)
then:
cfg1.server == 'https://1.2.3.4'
cfg1.sslCert == 'fake-ca-content'.bytes
cfg1.isVerifySsl()
cfg1.namespace == 'frontend'
cfg1.serviceAccount == 'default'
cfg1.clientCert == 'fake-cert-content'.bytes
when:
def cfg2 = new ConfigDiscovery().fromKubeConfig(CONFIG, 'dev-storage', null, null)
then:
cfg2.server == 'https://1.2.3.4'
cfg2.sslCert == 'fake-ca-content'.bytes
cfg2.isVerifySsl()
cfg2.namespace == 'storage'
cfg2.serviceAccount == 'default'
cfg2.clientCert == 'fake-cert-content'.bytes
when:
def cfg3 = new ConfigDiscovery().fromKubeConfig(CONFIG, 'exp-scratch', null, null)
then:
cfg3.server == 'https://5.6.7.8'
cfg3.sslCert == null
!cfg3.isVerifySsl()
cfg3.namespace == 'default'
cfg3.serviceAccount == 'default'
when:
new ConfigDiscovery().fromKubeConfig(CONFIG, 'foo', null, null)
then:
thrown(IllegalArgumentException)
true
cleanup:
folder.deleteDir()
}
def 'should load from cluster env' () {
given:
def CERT_FILE = TestHelper.createInMemTempFile('ca'); CERT_FILE.text = 'ca-content'
def TOKEN_FILE = TestHelper.createInMemTempFile('token'); TOKEN_FILE.text = 'my-token'
def NAMESPACE_FILE = TestHelper.createInMemTempFile('namespace'); NAMESPACE_FILE.text = 'foo-namespace'
def discovery = Spy(ConfigDiscovery)
when:
def env = [ KUBERNETES_SERVICE_HOST: 'foo.com', KUBERNETES_SERVICE_PORT: '4343' ]
def config = discovery.fromCluster(env, null, null)
then:
1 * discovery.path('/var/run/secrets/kubernetes.io/serviceaccount/ca.crt') >> CERT_FILE
1 * discovery.path('/var/run/secrets/kubernetes.io/serviceaccount/token') >> TOKEN_FILE
1 * discovery.path('/var/run/secrets/kubernetes.io/serviceaccount/namespace') >> NAMESPACE_FILE
0 * discovery.createKeyManagers(_,_) >> null
and:
config.server == 'foo.com:4343'
config.namespace == 'foo-namespace'
config.token == 'my-token'
config.sslCert == CERT_FILE.text.bytes
config.isFromCluster
when:
env = [ KUBERNETES_SERVICE_HOST: 'https://host.com' ]
config = discovery.fromCluster(env, 'my-namespace', null)
then:
1 * discovery.path('/var/run/secrets/kubernetes.io/serviceaccount/ca.crt') >> CERT_FILE
1 * discovery.path('/var/run/secrets/kubernetes.io/serviceaccount/token') >> TOKEN_FILE
1 * discovery.path('/var/run/secrets/kubernetes.io/serviceaccount/namespace') >> NAMESPACE_FILE
and:
config.server == 'https://host.com'
config.namespace == 'my-namespace'
}
def 'should create key managers' () {
given:
final CERT = 'LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNTRENDQVRDZ0F3SUJBZ0lJRlFsM1l2Y2k1TWN3RFFZSktvWklodmNOQVFFTEJRQXdGVEVUTUJFR0ExVUUKQXhNS2EzVmlaWEp1WlhSbGN6QWVGdzB4T0RBeE1UTXlNREEyTlRaYUZ3MHhPVEF4TVRNeU1EQTJOVFphTURNeApGREFTQmdOVkJBb1RDMFJ2WTJ0bGNpQkpibU11TVJzd0dRWURWUVFERXhKa2IyTnJaWEl0Wm05eUxXUmxjMnQwCmIzQXdnWjh3RFFZSktvWklodmNOQVFFQkJRQURnWTBBTUlHSkFvR0JBUEtYT0ZsV2t2THIzb29ETGNFOElyME0KTzNBMHZqQlVvUzZ0bUdBbFRYYTd0QWQwM3BTMXNJNit0WVRwVlU2YXR6ZU9vU0VrOWhmaWxBdVNYdG1hSHZCUAp1czFEcG1LZEZRMWI3OFRkSnQ4OGV3c3BRajFxYUwvQldHeitMUzUrRHUrNUJuUGtmZlhDS1UxQTdUc2tZamJyClhxeDhlN2FWZURWTmFjZXc0Z0RqQWdNQkFBR2pBakFBTUEwR0NTcUdTSWIzRFFFQkN3VUFBNElCQVFBMXVtVlAKR29EZTVCRXJrb21qWXdITXhiTTd4UStibTYrUDE1T0pINUo0UGNQeU11d25ocC9ORVp1NnpsTTZSUUo3SUNKQgpHWTRBMnFKVmJsWUkwQkJzRkF1TXMreTAyazdVVVVoK0NRYVd0SXhBcFNmbkQ4dUVXQ0g5VE1ZNGdLbTZjTDhVCk1OVVl1RnpUQ2hmTS96RjdUMXVaZWxJYXNrYXFaWSt3a3hxa3YyRUQxQ2F5MDUxSXRWRXZVbDIvSVZyVHdrT20KZ25nL3Q4L2RkeDhpOUkzTFJrMTlTaERKdXlQZ1NrTTZRSWlSd09mRHk4V0ZFaURpd0hBS0ErSEZhTGhOOFJTMwpieDUvdEhEN01id0FpdnorNTU4YUFEQjNEd1ZpekthM2d5Wm4yUzRjUGFqZnNwODFqRkNIQS9QekdQdTU2MzJwCkxRN0gyRW1aYmJuUHFYTFgKLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo='.decodeBase64()
final KEY = 'LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlDWGdJQkFBS0JnUUR5bHpoWlZwTHk2OTZLQXkzQlBDSzlERHR3Tkw0d1ZLRXVyWmhnSlUxMnU3UUhkTjZVCnRiQ092cldFNlZWT21yYzNqcUVoSlBZWDRwUUxrbDdabWg3d1Q3ck5RNlppblJVTlcrL0UzU2JmUEhzTEtVSTkKYW1pL3dWaHMvaTB1Zmc3dnVRWno1SDMxd2lsTlFPMDdKR0kyNjE2c2ZIdTJsWGcxVFduSHNPSUE0d0lEQVFBQgpBb0dBYWRUOCtVU2lvU1d6bFVRanZ1eHNQMHRKMXY2N2hqdzFnVGFzaGkxZjZRK2tUNmgxdml5eGxPU3dMZ2JaCmQ0eFpwL3dxWVZwTm5rZnp6RVNUNnB5cEo5WTEwdHY1cFpSWG9HbG1NT2tIZSswUW45N0c5ZDRzL2JCV3lmYXYKRzhRTC9tZFN6Vy85YUdrSkpiNWU0VDlsSURvRDNFVDgwYUFWbzl2V0NPVUxsdWtDUVFEK0hINU5ucVBuSTdnTApWOUJKZzlRRVBwUTVYa2traW8rejZ2YkRHQU5rR1VPV1dmRURKUHE2Q2JBb1dqeWh1Qy9KS1dYRWs4Rkt0M1Y2CkhVNllYeVpGQWtFQTlHVE9XOFM4KzVNNHE5R3lNeURxN1ZkVHA3M2daeSsvNjVQam5hNlpDUnhTZklxL2xKUVoKY2F6MkhGYVRzRFdLbkdhWGNxTmdBVXNEODNyWTlzM3hCd0pCQUt5Vjc1YUtPMm0rRWI3cWVsV2p5bmpEZytwZQp4akNpUnkxOFZQSjJPYjlmaFU3MWNVS2dlQVdvbE5NalRuREw1dkNxUkNzNTZ4cnk5VC9sN2I2QlNUMENRUURnCjRoV2xDZTdnQzhOZEQzTkxhdUhpRGJZenB4dmp0Mk9Ca2E4ai9ISmptTVVxUnI0dEtPNFUxUlFPVlhoRzc2MmgKWnlHNjRpeklZOCs1N3ZQUWZ3Wm5Ba0VBdW9RWW1lUi90UWhIakhRNFlhZGRHbkNBQ2hZZ29ObEFzSGhGTElxVQo1ZTZaMXN2Q3VKU285TDVVRCtrclFUYWlGU01pRHZwZlJyVE1ZKzZ5Q0tTajd3PT0KLS0tLS1FTkQgUlNBIFBSSVZBVEUgS0VZLS0tLS0K'.decodeBase64()
final discovery = new ConfigDiscovery()
when:
def managers = discovery.createKeyManagers(CERT, KEY)
then:
managers.size()==1
}
def 'should create key managers from an EC client key' () {
given:
final CERT = 'LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNTRENDQVRDZ0F3SUJBZ0lJRlFsM1l2Y2k1TWN3RFFZSktvWklodmNOQVFFTEJRQXdGVEVUTUJFR0ExVUUKQXhNS2EzVmlaWEp1WlhSbGN6QWVGdzB4T0RBeE1UTXlNREEyTlRaYUZ3MHhPVEF4TVRNeU1EQTJOVFphTURNeApGREFTQmdOVkJBb1RDMFJ2WTJ0bGNpQkpibU11TVJzd0dRWURWUVFERXhKa2IyTnJaWEl0Wm05eUxXUmxjMnQwCmIzQXdnWjh3RFFZSktvWklodmNOQVFFQkJRQURnWTBBTUlHSkFvR0JBUEtYT0ZsV2t2THIzb29ETGNFOElyME0KTzNBMHZqQlVvUzZ0bUdBbFRYYTd0QWQwM3BTMXNJNit0WVRwVlU2YXR6ZU9vU0VrOWhmaWxBdVNYdG1hSHZCUAp1czFEcG1LZEZRMWI3OFRkSnQ4OGV3c3BRajFxYUwvQldHeitMUzUrRHUrNUJuUGtmZlhDS1UxQTdUc2tZamJyClhxeDhlN2FWZURWTmFjZXc0Z0RqQWdNQkFBR2pBakFBTUEwR0NTcUdTSWIzRFFFQkN3VUFBNElCQVFBMXVtVlAKR29EZTVCRXJrb21qWXdITXhiTTd4UStibTYrUDE1T0pINUo0UGNQeU11d25ocC9ORVp1NnpsTTZSUUo3SUNKQgpHWTRBMnFKVmJsWUkwQkJzRkF1TXMreTAyazdVVVVoK0NRYVd0SXhBcFNmbkQ4dUVXQ0g5VE1ZNGdLbTZjTDhVCk1OVVl1RnpUQ2hmTS96RjdUMXVaZWxJYXNrYXFaWSt3a3hxa3YyRUQxQ2F5MDUxSXRWRXZVbDIvSVZyVHdrT20KZ25nL3Q4L2RkeDhpOUkzTFJrMTlTaERKdXlQZ1NrTTZRSWlSd09mRHk4V0ZFaURpd0hBS0ErSEZhTGhOOFJTMwpieDUvdEhEN01id0FpdnorNTU4YUFEQjNEd1ZpekthM2d5Wm4yUzRjUGFqZnNwODFqRkNIQS9QekdQdTU2MzJwCkxRN0gyRW1aYmJuUHFYTFgKLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo='.decodeBase64()
final KEY = 'LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JR0hBZ0VBTUJNR0J5cUdTTTQ5QWdFR0NDcUdTTTQ5QXdFSEJHMHdhd0lCQVFRZ21aZFZ3NmJRU0w1T1l5RjQKbzJ4V0hUQ05BSW1hRTkycGd2dGMzK2Z2UDVxaFJBTkNBQVJSd0RpUVptTUNqcWxvbFBzRTdiZjgwWjhrZkRXTworS2U4NUdVSll2MlBubWVxbDhkYjdwcmFlMHFPQUJaaXR2Mmh2SmJFeFdsUFR0MS9CYTNMK1B5NAotLS0tLUVORCBQUklWQVRFIEtFWS0tLS0tCg=='.decodeBase64()
final discovery = new ConfigDiscovery()
when:
def managers = discovery.createKeyManagers(CERT, KEY)
then:
managers.size()==1
}
def 'should create key managers from an EC-encrypted client key' () {
given:
final CERT = 'LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJrVENDQVRlZ0F3SUJBZ0lJSGw1Zmx0UmRTdDB3Q2dZSUtvWkl6ajBFQXdJd0l6RWhNQjhHQTFVRUF3d1kKYXpOekxXTnNhV1Z1ZEMxallVQXhOekUwTnpRM09UVTNNQjRYRFRJME1EVXdNekUwTlRJek4xb1hEVEkxTURVdwpNekUwTlRJek4xb3dNREVYTUJVR0ExVUVDaE1PYzNsemRHVnRPbTFoYzNSbGNuTXhGVEFUQmdOVkJBTVRESE41CmMzUmxiVHBoWkcxcGJqQlpNQk1HQnlxR1NNNDlBZ0VHQ0NxR1NNNDlBd0VIQTBJQUJQN1Q5RHVvUlllLzBlUkwKUmNHV2RoYnl2Q3BucXlsSVIyaUwxdGkwc1hVdEpZZjUrVXhIOWFBMjdzY2FSYW1qbjdnTTFrKzZNaVk5cm15OApyRmdoWm1xalNEQkdNQTRHQTFVZER3RUIvd1FFQXdJRm9EQVRCZ05WSFNVRUREQUtCZ2dyQmdFRkJRY0RBakFmCkJnTlZIU01FR0RBV2dCU0NhdXFoQVEvWEdoaFRtaFBoY21vRVdOeWluakFLQmdncWhrak9QUVFEQWdOSUFEQkYKQWlCZzRaNmlWeFV3Mk5uMHBQTG02VlovUGttQnVuTDEwZG50dEg3UVdIcklCd0loQU0vTDhVMGxQN0IyeFEyZwpsZjlhNHNhbzJ1bE5ONnQvQ0dibzlxTlo1QzZHCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0KLS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJkekNDQVIyZ0F3SUJBZ0lCQURBS0JnZ3Foa2pPUFFRREFqQWpNU0V3SHdZRFZRUUREQmhyTTNNdFkyeHAKWlc1MExXTmhRREUzTVRRM05EYzVOVGN3SGhjTk1qUXdOVEF6TVRRMU1qTTNXaGNOTXpRd05UQXhNVFExTWpNMwpXakFqTVNFd0h3WURWUVFEREJock0zTXRZMnhwWlc1MExXTmhRREUzTVRRM05EYzVOVGN3V1RBVEJnY3Foa2pPClBRSUJCZ2dxaGtqT1BRTUJCd05DQUFRZHFYVHdIQS9mVjRKZGdYa2FubXB1OVE0QStwUGRGaXZGdytiUmVhdEYKUXVOUTBKWndIbzlaa2ltb2lEUU5qb2h0TWdHckdtTVlsTTZuaXM4ZVFvM3RvMEl3UURBT0JnTlZIUThCQWY4RQpCQU1DQXFRd0R3WURWUjBUQVFIL0JBVXdBd0VCL3pBZEJnTlZIUTRFRmdRVWdtcnFvUUVQMXhvWVU1b1Q0WEpxCkJGamNvcDR3Q2dZSUtvWkl6ajBFQXdJRFNBQXdSUUloQUlvb2ZmNzdvb1VYS2hmNVo3aVRzdExhOTVwU2VaRmUKRHZjMXdFQXVEa3NTQWlBNzJQajJxNnpBclhpYkpUa0s2RTBHTEtVODdhTHhHc3BmS29uVVJnalI2Zz09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K'.decodeBase64()
final KEY = 'LS0tLS1CRUdJTiBFQyBQUklWQVRFIEtFWS0tLS0tCk1IY0NBUUVFSUNvQTNvRHkzN3NXdmszM3JGRGtRdlZ1Wkh1cCt1Uk40V3RqbUlPR1c4cHBvQW9HQ0NxR1NNNDkKQXdFSG9VUURRZ0FFL3RQME82aEZoNy9SNUV0RndaWjJGdks4S21lcktVaEhhSXZXMkxTeGRTMGxoL241VEVmMQpvRGJ1eHhwRnFhT2Z1QXpXVDdveUpqMnViTHlzV0NGbWFnPT0KLS0tLS1FTkQgRUMgUFJJVkFURSBLRVktLS0tLQo='.decodeBase64()
final discovery = new ConfigDiscovery()
when:
def managers = discovery.createKeyManagers(CERT, KEY)
then:
managers.size()==1
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,66 @@
/*
* 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.k8s.client
import spock.lang.Specification
/**
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
class K8sResponseExceptionTest extends Specification {
def 'should create response from valid json' () {
when:
def resp = new K8sResponseException(
'Request /this/that failed',
new K8sResponseJson('{"foo":"one","bar":"two"}'))
then:
resp.getMessage() == '''
Request /this/that failed
{
"foo": "one",
"bar": "two"
}
'''.stripIndent().leftTrim()
}
def 'should create response from error message' () {
when:
def resp = new K8sResponseException(
'Request /this/that failed',
new K8sResponseJson('Oops.. it crashed badly'))
then:
resp.getMessage() == 'Request /this/that failed -- Oops.. it crashed badly'
}
def 'should contain the response object passed to it' () {
given:
def resp_json = new K8sResponseJson('{"error": "out of cheese error"}')
when:
def resp = new K8sResponseException("Error occurred",resp_json)
then:
resp.response == resp_json
}
}

View File

@@ -0,0 +1,70 @@
/*
* 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.k8s.client
import spock.lang.Specification
/**
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
class K8sResponseJsonTest extends Specification {
def 'should create a response from a map' () {
given:
def MAP = [foo: 'one', bar: 'two']
when:
def resp = new K8sResponseJson(MAP)
then:
resp.foo == 'one'
resp.bar == 'two'
resp.toString() == '''
{
"foo": "one",
"bar": "two"
}
'''.stripIndent().trim()
}
def 'should create a response from a json string' () {
when:
def resp = new K8sResponseJson('{"foo":"one","bar":"two"}')
then:
resp.foo == 'one'
resp.bar == 'two'
resp.toString() == '''
{
"foo": "one",
"bar": "two"
}
'''.stripIndent().trim()
}
def 'should create a response from an error message' () {
when:
def resp = new K8sResponseJson('Ooops .. this crashed')
then:
resp.toString() == 'Ooops .. this crashed'
}
}

View File

@@ -0,0 +1,62 @@
/*
* 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.k8s.model
import spock.lang.Specification
/**
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
class PodEnvTest extends Specification {
def 'should return env spec' () {
expect:
PodEnv.value('ALPHA', 'aaa').toSpec() == [name:'ALPHA', value:'aaa']
}
def 'should create env fieldPath spec' () {
expect:
PodEnv.fieldPath('ALPHA', 'aaa').toSpec() == [
name:'ALPHA',
valueFrom: [fieldRef:[fieldPath: 'aaa']]
]
}
def 'should create env secret spec' () {
expect:
PodEnv.secret('ALPHA', 'data/key-1').toSpec() == [
name: 'ALPHA',
valueFrom: [secretKeyRef:[name:'data', key:'key-1']]
]
PodEnv.secret('ALPHA', 'data').toSpec() == [
name: 'ALPHA',
valueFrom: [secretKeyRef:[name:'data', key:'ALPHA']]
]
}
def 'should create env config spec' () {
expect:
PodEnv.config('ALPHA', 'data/key-1').toSpec() == [
name: 'ALPHA',
valueFrom: [configMapKeyRef:[name:'data', key:'key-1']]
]
}
}

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 nextflow.k8s.model
import spock.lang.Specification
/**
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
class PodMountConfigTest extends Specification {
def 'should create mount for configmap' () {
when:
def opt = new PodMountConfig(mountPath: '/etc/some/name', config: 'here' )
then:
opt.mountPath == '/etc/some/name'
opt.fileName == null
opt.configName == 'here'
opt.configKey == null
when:
opt = new PodMountConfig(mountPath: '/etc/some/name', config: 'here/there.txt' )
then:
opt.mountPath == '/etc/some'
opt.fileName == 'name'
opt.configName == 'here'
opt.configKey == 'there.txt'
}
}

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 nextflow.k8s.model
import spock.lang.Specification
/**
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
class PodMountSecretTest extends Specification {
def 'should create mount for configmap' () {
when:
def opt = new PodMountSecret(mountPath: '/etc/some/name', secret: 'here' )
then:
opt.mountPath == '/etc/some/name'
opt.fileName == null
opt.secretName == 'here'
opt.secretKey == null
when:
opt = new PodMountSecret(mountPath: '/etc/some/name', secret: 'here/there.txt' )
then:
opt.mountPath == '/etc/some'
opt.fileName == 'name'
opt.secretName == 'here'
opt.secretKey == 'there.txt'
}
}

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 nextflow.k8s.model
import spock.lang.Specification
/**
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
class PodNodeSelectorTest extends Specification {
def 'should create node selector' () {
expect:
new PodNodeSelector(selector).toSpec() == spec
where:
selector | spec
'' | [:]
'foo=1' | [foo:'1']
'x=a,y=2,z=9' | [x:'a',y:'2',z:'9']
'x= a , y=2 , z =9' | [x:'a',y:'2',z:'9']
'gpu,intel' | [gpu:'true',intel: 'true']
[foo:1, bar: 'two'] | [foo:'1', bar:'two']
}
}

View File

@@ -0,0 +1,568 @@
/*
* 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.k8s.model
import spock.lang.Specification
/**
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
class PodOptionsTest extends Specification {
def 'should create empty options' () {
when:
def options = new PodOptions(null)
then:
options.getEnvVars() == [] as Set
options.getMountConfigMaps() == [] as Set
options.getMountCsiEphemerals() == [] as Set
options.getMountEmptyDirs() == [] as Set
options.getMountSecrets() == [] as Set
options.getAutomountServiceAccountToken() == true
}
def 'should set pullPolicy' () {
when:
def options = new PodOptions()
then:
options.getImagePullPolicy() == null
when:
options = new PodOptions([ [pullPolicy:'Always'] ])
then:
options.getImagePullPolicy() == 'Always'
when:
options = new PodOptions([ [imagePullPolicy:'latest'] ])
then:
options.getImagePullPolicy() == 'latest'
}
def 'should set imagePullSecret' () {
when:
def options = new PodOptions()
then:
options.imagePullSecret == null
when:
options = new PodOptions([ [imagePullSecret:'foo'] ])
then:
options.imagePullSecret == 'foo'
when:
options = new PodOptions([ [imagePullSecrets:'bar'] ])
then:
options.imagePullSecret == 'bar'
}
def 'should return config mounts' () {
given:
def options = [
[mountPath: '/this/path1.txt', config: 'name/key1'],
[mountPath: '/this/path2.txt', config: 'name/key2'],
[mountPath: '/this/path2.txt', config: 'name/key2'], // <-- identical entry are ignored
[mountPath: '/this/path2.txt', secret: 'name/secret'],
[env: 'FOO', config: '/name/foo']
]
when:
def configs = new PodOptions(options).getMountConfigMaps()
then:
configs.size() == 2
configs == [
new PodMountConfig(mountPath: '/this/path1.txt', config: 'name/key1'),
new PodMountConfig(mountPath: '/this/path2.txt', config: 'name/key2')
] as Set
}
def 'should return csi ephemeral mounts' () {
given:
def options = [
[
mountPath: '/data',
csi: [
driver: 'inline.storage.kubernetes.io',
volumeAttributes: [foo: 'bar']
]
]
]
when:
def csiEphemerals = new PodOptions(options).getMountCsiEphemerals()
then:
csiEphemerals == [
new PodMountCsiEphemeral(mountPath: '/data', csi: options[0].csi)
] as Set
}
def 'should return emptyDir mounts' () {
given:
def options = [
[mountPath: '/scratch1', emptyDir: [medium: 'Memory']],
[mountPath: '/scratch2', emptyDir: [medium: 'Disk']]
]
when:
def emptyDirs = new PodOptions(options).getMountEmptyDirs()
then:
emptyDirs.size() == 2
emptyDirs == [
new PodMountEmptyDir(options[0]),
new PodMountEmptyDir(options[1]) ] as Set
}
def 'should return secret mounts' () {
given:
def options = [
[mountPath: '/this/path1.txt', config: 'name/key1'],
[mountPath: '/this/alpha.txt', secret: 'name/secret1'],
[mountPath: '/this/beta.txt', secret: 'name/secret2'],
[mountPath: '/this/beta.txt', secret: 'name/secret2'],
[env: 'FOO', config: '/name/foo']
]
when:
def secrets = new PodOptions(options).getMountSecrets()
then:
secrets.size() == 2
secrets == [
new PodMountSecret(mountPath: '/this/alpha.txt', secret: 'name/secret1'),
new PodMountSecret(mountPath: '/this/beta.txt', secret: 'name/secret2'),
] as Set
}
def 'should return env definitions' () {
given:
def options = [
[mountPath: '/this/path1.txt', config: 'name/key1'],
[mountPath: '/this/alpha.txt', secret: 'name/secret1'],
[mountPath: '/this/beta.txt', secret: 'name/secret2'],
[env: 'FOO', config: '/name/foo'],
[env: 'FOO', config: '/name/foo'],
[env: 'BAR', config: '/name/BAR'],
[env: 'ALPHA', value: 'aaa'],
[env: 'ALPHA', value: 'aaa'],
[env: 'BETA', value: 'bbb'],
[env: 'PASSWORD', secret:'name/key'],
[env: 'PASSWORD', secret:'name/key'],
]
when:
def env = new PodOptions(options).getEnvVars()
then:
env.size() == 5
env == [
PodEnv.config('FOO', '/name/foo'),
PodEnv.config('BAR', '/name/BAR'),
PodEnv.value('ALPHA', 'aaa'),
PodEnv.value('BETA', 'bbb'),
PodEnv.secret('PASSWORD', 'name/key'),
] as Set
}
def 'should create persistent volume claims' () {
given:
def options = [
[volumeClaim:'pvc1', mountPath: '/this/path'],
[volumeClaim:'pvc2', mountPath: '/that/path'],
[volumeClaim:'pvc3', mountPath: '/some/data', subPath: '/foo']
]
when:
def claims = new PodOptions(options).getVolumeClaims()
then:
claims.size() == 3
claims == [
new PodVolumeClaim('pvc1', '/this/path'),
new PodVolumeClaim('pvc2', '/that/path'),
new PodVolumeClaim('pvc3', '/some/data', '/foo')
] as Set
}
def 'should create host path' () {
given:
def options = [
[hostPath: '/host/one', mountPath: '/pod/1'],
[hostPath: '/host/two', mountPath: '/pod/2']
]
when:
def mounts = new PodOptions(options).getMountHostPaths()
then:
mounts == [
new PodHostMount('/host/one', '/pod/1'),
new PodHostMount('/host/two', '/pod/2')
] as Set
}
def 'should not create env' () {
when:
new PodOptions([ [env:'FOO'] ])
then:
thrown(IllegalArgumentException)
when:
new PodOptions([ [secret:'FOO'] ])
then:
thrown(IllegalArgumentException)
when:
new PodOptions([ [config:'FOO'] ])
then:
thrown(IllegalArgumentException)
when:
new PodOptions([ [volumeClaim:'FOO'] ])
then:
thrown(IllegalArgumentException)
}
def 'should merge podOptions' () {
given:
def list1 = [
[env: 'HELLO', value: 'WORLD'],
[config: 'data/key', mountPath: '/data/file.txt'],
[secret: 'secret/key', mountPath: '/etc/secret'],
[volumeClaim: 'pvc', mountPath: '/mnt/claim'],
[runAsUser: 500]
]
def list2 = [
[env: 'ALPHA', value: 'GAMMA'],
[config: 'bar/key', mountPath: '/b/bb'],
[secret: 'foo/key', mountPath: '/a/aa'],
[volumeClaim: 'cvp', mountPath: '/c/cc'],
[env: 'DELTA', value: 'LAMBDA'],
[config: 'y', mountPath: '/y'],
[secret: 'x', mountPath: '/x'],
[volumeClaim: 'z', mountPath: '/z'],
]
def list3 = [
[env: 'HELLO', value: 'WORLD'],
[config: 'data/key', mountPath: '/data/file.txt'],
[secret: 'secret/key', mountPath: '/etc/secret'],
[volumeClaim: 'pvc', mountPath: '/mnt/claim'],
[env: 'DELTA', value: 'LAMBDA'],
[config: 'y', mountPath: '/y'],
[secret: 'x', mountPath: '/x'],
[volumeClaim: 'z', mountPath: '/z'],
[csi: [driver: 'inline.storage.kubernetes.io'], mountPath: '/data'],
[emptyDir: [:], mountPath: '/scratch1'],
[securityContext: [runAsUser: 1000, fsGroup: 200, allowPrivilegeEscalation: true]],
[nodeSelector: 'foo=X, bar=Y'],
[automountServiceAccountToken: false],
[priorityClassName: 'high-priority']
]
PodOptions opts
when:
opts = new PodOptions() + new PodOptions()
then:
opts == new PodOptions()
when:
opts = new PodOptions(list1) + new PodOptions()
then:
opts == new PodOptions(list1)
opts.securityContext.toSpec() == [runAsUser:500]
when:
opts = new PodOptions() + new PodOptions(list1)
then:
opts == new PodOptions(list1)
opts.securityContext.toSpec() == [runAsUser:500]
when:
opts = new PodOptions(list1) + new PodOptions(list1)
then:
opts == new PodOptions(list1)
opts.securityContext.toSpec() == [runAsUser:500]
when:
opts = new PodOptions(list1) + new PodOptions(list2)
then:
opts == new PodOptions(list1 + list2)
opts.securityContext.toSpec() == [runAsUser:500]
when:
opts = new PodOptions(list1) + new PodOptions(list3)
then:
opts.getEnvVars() == [
PodEnv.value('HELLO','WORLD'),
PodEnv.value('DELTA','LAMBDA')
] as Set
opts.getMountConfigMaps() == [
new PodMountConfig('data/key', '/data/file.txt'),
new PodMountConfig('y', '/y'),
] as Set
opts.getMountCsiEphemerals() == [
new PodMountCsiEphemeral([driver: 'inline.storage.kubernetes.io'], '/data')
] as Set
opts.getMountEmptyDirs() == [
new PodMountEmptyDir([:], '/scratch1'),
] as Set
opts.getMountSecrets() == [
new PodMountSecret('secret/key', '/etc/secret'),
new PodMountSecret('x', '/x')
] as Set
opts.getVolumeClaims() == [
new PodVolumeClaim('pvc','/mnt/claim'),
new PodVolumeClaim('z','/z'),
] as Set
opts.securityContext.toSpec() == [runAsUser: 1000, fsGroup: 200, allowPrivilegeEscalation: true]
opts.nodeSelector.toSpec() == [foo: 'X', bar: "Y"]
opts.getAutomountServiceAccountToken() == false
opts.getPriorityClassName() == 'high-priority'
}
def 'should copy image pull policy' (){
given:
def data = [
[imagePullPolicy : 'FOO']
]
when:
def opts = new PodOptions() + new PodOptions(data)
then:
opts.imagePullPolicy == 'FOO'
when:
opts = new PodOptions(data) + new PodOptions()
then:
opts.imagePullPolicy == 'FOO'
}
def 'should copy image pull secret' (){
given:
def data = [
[imagePullSecret : 'BAR']
]
when:
def opts = new PodOptions() + new PodOptions(data)
then:
opts.imagePullSecret == 'BAR'
when:
opts = new PodOptions(data) + new PodOptions()
then:
opts.imagePullSecret == 'BAR'
}
def 'should copy pod labels' (){
given:
def data = [
[label: "LABEL", value: 'VALUE']
]
when:
def opts = new PodOptions() + new PodOptions(data)
then:
opts.labels == ["LABEL": "VALUE"]
when:
opts = new PodOptions(data) + new PodOptions()
then:
opts.labels == ["LABEL": "VALUE"]
when:
opts = new PodOptions([[label:"FOO", value:'one']]) + new PodOptions([[label:"BAR", value:'two']])
then:
opts.labels == [FOO: 'one', BAR: 'two']
}
def 'should copy host paths' (){
given:
def data = [
[hostPath: "/foo", mountPath: '/one']
]
when:
def opts = new PodOptions() + new PodOptions(data)
then:
opts.getMountHostPaths() == [new PodHostMount('/foo', '/one')] as Set
when:
opts = new PodOptions(data) + new PodOptions()
then:
opts.getMountHostPaths() == [new PodHostMount('/foo', '/one')] as Set
when:
opts = new PodOptions([[hostPath:"/foo", mountPath: '/one']]) + new PodOptions([[hostPath:"/bar", mountPath: '/two']])
then:
opts.getMountHostPaths() == [
new PodHostMount('/foo','/one'),
new PodHostMount('/bar','/two')
] as Set
}
def 'should create pod labels' () {
given:
def options = [
[label: 'ALPHA', value: 'aaa'],
[label: 'DELTA', value: 'bbb'],
[label: 'DELTA', value: 'ddd']
]
when:
def opts = new PodOptions(options)
then:
opts.labels.size() == 2
opts.labels == [ALPHA: 'aaa', DELTA: 'ddd']
}
def 'should copy pod annotations' (){
given:
def data = [
[annotation: "ANNOTATION", value: 'VALUE']
]
when:
def opts = new PodOptions() + new PodOptions(data)
then:
opts.annotations == ["ANNOTATION": "VALUE"]
when:
opts = new PodOptions(data) + new PodOptions()
then:
opts.annotations == ["ANNOTATION": "VALUE"]
when:
opts = new PodOptions([[annotation:"FOO", value:'one']]) + new PodOptions([[annotation:"BAR", value:'two']])
then:
opts.annotations == [FOO: 'one', BAR: 'two']
}
def 'should create pod annotations' () {
given:
def options = [
[annotation: 'ALPHA', value: 'aaa'],
[annotation: 'DELTA', value: 'bbb'],
[annotation: 'DELTA', value: 'ddd']
]
when:
def opts = new PodOptions(options)
then:
opts.annotations.size() == 2
opts.annotations == [ALPHA: 'aaa', DELTA: 'ddd']
}
def 'should create user security context' () {
when:
def opts = new PodOptions([ [runAsUser: 1000] ])
then:
opts.getSecurityContext() == new PodSecurityContext(1000)
when:
opts = new PodOptions([ [runAsUser: 'foo'] ])
then:
opts.getSecurityContext() == new PodSecurityContext('foo')
when:
opts = new PodOptions([ [runAsUser: 'foo'] ])
then:
opts.getSecurityContext() != new PodSecurityContext('bar')
when:
def ctx = [runAsUser: 500, fsGroup: 200, allowPrivilegeEscalation: true, seLinuxOptions: [level: "s0:c123,c456"]]
def expected = new PodSecurityContext(ctx)
opts = new PodOptions([ [securityContext: ctx] ])
then:
opts.getSecurityContext() == expected
opts.getSecurityContext().toSpec() == ctx
}
def 'should create pod node selector' () {
when:
def opts = new PodOptions([ [nodeSelector: 'foo=1, bar=true, baz=Z'] ])
then:
opts.nodeSelector.toSpec() == [foo: '1', bar: 'true', baz: 'Z']
}
def 'should set pod automount service token' () {
when:
def opts = new PodOptions([[automountServiceAccountToken: false]])
then:
opts.getAutomountServiceAccountToken() == false
}
def 'should set pod priority class name' () {
when:
def opts = new PodOptions([[priorityClassName: 'high-priority']])
then:
opts.getPriorityClassName() == 'high-priority'
}
def 'should set pod privileged' () {
when:
def opts = new PodOptions([:])
then:
!opts.getPrivileged()
when:
opts = new PodOptions([[privileged: true]])
then:
opts.getPrivileged()
}
def 'should set pod schedulerName' () {
when:
def opts = new PodOptions()
then:
opts.getSchedulerName() == null
when:
opts = new PodOptions([ [schedulerName:'my-scheduler'] ])
then:
opts.getSchedulerName() == 'my-scheduler'
}
}

View File

@@ -0,0 +1,914 @@
/*
* 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.k8s.model
import nextflow.executor.res.AcceleratorResource
import nextflow.util.MemoryUnit
import spock.lang.Specification
import spock.lang.Unroll
/**
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
class PodSpecBuilderTest extends Specification {
def setup() {
PodSpecBuilder.VOLUMES.set(0)
}
def 'should create pod spec' () {
when:
def spec = new PodSpecBuilder()
.withPodName('foo')
.withImageName('busybox')
.withWorkDir('/some/work/dir')
.withCommand(['echo', 'hello'])
.build()
then:
spec == [
apiVersion: 'v1',
kind: 'Pod',
metadata: [name:'foo', namespace:'default'],
spec: [
restartPolicy:'Never',
containers:[[
name:'foo',
image:'busybox',
command:['echo', 'hello'],
workingDir:'/some/work/dir'
]]
]
]
}
def 'should create pod spec with args' () {
when:
def pod = new PodSpecBuilder()
.withPodName('foo')
.withImageName('busybox')
.withArgs(['echo', 'hello'])
.build()
then:
pod.spec.containers[0].args == ['echo', 'hello']
}
def 'should create pod spec with args string' () {
when:
def pod = new PodSpecBuilder()
.withPodName('foo')
.withImageName('busybox')
.withArgs('echo foo')
.build()
then:
pod.spec.containers[0].args == ['/bin/bash', '-c', 'echo foo']
}
def 'should create pod spec with privileged' () {
when:
def pod = new PodSpecBuilder()
.withPodName('foo')
.withImageName('busybox')
.withCommand('echo foo')
.withPrivileged(true)
.build()
then:
pod.spec.containers[0].securityContext == [privileged: true]
}
def 'should create pod with resources limits' () {
when:
def pod1 = new PodSpecBuilder()
.withPodName('foo')
.withImageName('busybox')
.withCommand('echo foo')
.withResourcesLimits('nextflow.io/fuse': 1)
.build()
then:
pod1.spec.containers[0].resources == [limits:['nextflow.io/fuse':1]]
when:
def pod2 = new PodSpecBuilder()
.withPodName('foo')
.withImageName('busybox')
.withCommand('echo foo')
.withCpus(8)
.withCpuLimits(true)
.withMemory(MemoryUnit.of('10GB'))
.withResourcesLimits('nextflow.io/fuse': 1)
.build()
then:
pod2.spec.containers[0].resources == [
requests: ['cpu':8, 'memory':'10240Mi'],
limits: ['cpu':8, 'memory':'10240Mi', 'nextflow.io/fuse':1] ]
}
def 'should set namespace, labels and annotations' () {
when:
def pod = new PodSpecBuilder()
.withPodName('foo')
.withImageName('busybox')
.withCommand(['sh', '-c', 'echo hello'])
.withNamespace('xyz')
.withLabel('app','myApp')
.withLabel('runName','something')
.withLabel('version','3.6.1')
.withAnnotation("anno1", "value1")
.withAnnotations([anno2: "value2", anno3: "value3"])
.build()
then:
pod.metadata.namespace == 'xyz'
pod.metadata.labels == [
app: 'myApp',
runName: 'something',
version: '3.6.1'
]
pod.metadata.annotations == [
anno1: "value1",
anno2: "value2",
anno3: "value3"
]
}
def 'should truncate labels longer than 63 chars' () {
when:
def pod = new PodSpecBuilder()
.withPodName('foo')
.withImageName('busybox')
.withCommand(['sh', '-c', 'echo hello'])
.withLabel('app','myApp')
.withLabel('runName','something')
.withLabel('tag','somethingreallylonggggggggggggggggggggggggggggggggggggggggggendEXTRABIT')
.withLabels([tag2: 'somethingreallylonggggggggggggggggggggggggggggggggggggggggggendEXTRABIT', tag3: 'somethingreallylonggggggggggggggggggggggggggggggggggggggggggendEXTRABIT'])
.build()
then:
pod.metadata.labels == [
app: 'myApp',
runName: 'something',
tag: 'somethingreallylonggggggggggggggggggggggggggggggggggggggggggend',
tag2: 'somethingreallylonggggggggggggggggggggggggggggggggggggggggggend',
tag3: 'somethingreallylonggggggggggggggggggggggggggggggggggggggggggend'
]
}
def 'should set resources and env' () {
when:
def pod = new PodSpecBuilder()
.withPodName('foo')
.withImageName('busybox')
.withCommand('echo hello')
.withEnv(PodEnv.value('ALPHA','hello'))
.withEnv(PodEnv.value('DELTA', 'world'))
.withCpus(8)
.withAccelerator( new AcceleratorResource(request: 5, limit:10, type: 'foo.org') )
.withMemory('100Gi')
.withDisk('10Gi')
.build()
then:
pod.spec.containers[0].env == [
[name:'ALPHA', value:'hello'],
[name:'DELTA', value:'world']
]
pod.spec.containers[0].resources == [
requests: ['foo.org/gpu':5, cpu:8, memory:'100Gi', 'ephemeral-storage':'10Gi'],
limits: ['foo.org/gpu':10, memory:'100Gi', 'ephemeral-storage':'10Gi']
]
}
def 'should get storage spec for volume claims' () {
when:
def pod = new PodSpecBuilder()
.withPodName('foo')
.withImageName('busybox')
.withCommand(['echo'])
.withVolumeClaim(new PodVolumeClaim('first','/work'))
.withVolumeClaim(new PodVolumeClaim('second', '/data', '/foo'))
.withVolumeClaim(new PodVolumeClaim('third', '/things', null, true))
.build()
then:
pod.spec.containers[0].volumeMounts == [
[name:'vol-1', mountPath:'/work'],
[name:'vol-2', mountPath:'/data', subPath: '/foo'],
[name:'vol-3', mountPath:'/things', readOnly: true]
]
pod.spec.volumes == [
[name:'vol-1', persistentVolumeClaim:[claimName:'first']],
[name:'vol-2', persistentVolumeClaim:[claimName:'second']],
[name:'vol-3', persistentVolumeClaim:[claimName:'third']]
]
}
def 'should only define one volume per persistentVolumeClaim' () {
when:
def pod = new PodSpecBuilder()
.withPodName('foo')
.withImageName('busybox')
.withCommand(['echo'])
.withVolumeClaim(new PodVolumeClaim('first','/work'))
.withVolumeClaim(new PodVolumeClaim('first','/work2', '/bar'))
.withVolumeClaim(new PodVolumeClaim('second', '/data', '/foo'))
.withVolumeClaim(new PodVolumeClaim('second', '/data2', '/fooz'))
.build()
then:
pod.spec.containers[0].volumeMounts == [
[name:'vol-1', mountPath:'/work'],
[name:'vol-1', mountPath:'/work2', subPath: '/bar'],
[name:'vol-2', mountPath:'/data', subPath: '/foo'],
[name:'vol-2', mountPath:'/data2', subPath: '/fooz']
]
pod.spec.volumes == [
[name:'vol-1', persistentVolumeClaim:[claimName:'first']],
[name:'vol-2', persistentVolumeClaim:[claimName:'second']]
]
}
def 'should get config map mounts' () {
when:
def pod = new PodSpecBuilder()
.withPodName('foo')
.withImageName('busybox')
.withCommand(['echo'])
.withConfigMap(new PodMountConfig(config: 'cfg1', mountPath: '/etc/config'))
.withConfigMap(new PodMountConfig(config: 'data2', mountPath: '/data/path'))
.build()
then:
pod.spec.containers[0].volumeMounts == [
[name:'vol-1', mountPath:'/etc/config'],
[name:'vol-2', mountPath:'/data/path']
]
pod.spec.volumes == [
[name:'vol-1', configMap:[name:'cfg1']],
[name:'vol-2', configMap:[name:'data2']]
]
}
def 'should get csi ephemeral mounts' () {
when:
def pod = new PodSpecBuilder()
.withPodName('foo')
.withImageName('busybox')
.withCommand(['echo'])
.withCsiEphemeral(new PodMountCsiEphemeral(csi: [driver: 'inline.storage.kubernetes.io', readOnly: true], mountPath: '/data'))
.build()
then:
pod.spec.containers[0].volumeMounts == [
[name: 'vol-1', mountPath: '/data', readOnly: true]
]
pod.spec.volumes == [
[name: 'vol-1', csi: [driver: 'inline.storage.kubernetes.io', readOnly: true]]
]
}
def 'should get empty dir mounts' () {
when:
def pod = new PodSpecBuilder()
.withPodName('foo')
.withImageName('busybox')
.withCommand(['echo'])
.withEmptyDir(new PodMountEmptyDir(mountPath: '/scratch1', emptyDir: [medium: 'Disk']))
.withEmptyDir(new PodMountEmptyDir(mountPath: '/scratch2', emptyDir: [medium: 'Memory']))
.build()
then:
pod.spec.containers[0].volumeMounts == [
[name: 'vol-1', mountPath: '/scratch1'],
[name: 'vol-2', mountPath: '/scratch2']
]
pod.spec.volumes == [
[name: 'vol-1', emptyDir: [medium: 'Disk']],
[name: 'vol-2', emptyDir: [medium: 'Memory']]
]
}
def 'should consume env secrets' () {
when:
def pod = new PodSpecBuilder()
.withPodName('foo')
.withImageName('busybox')
.withCommand(['echo'])
.withEnv( PodEnv.value('FOO','abc'))
.withEnv( PodEnv.secret('VAR_X', 'delta/bar'))
.withEnv( PodEnv.secret('VAR_Y', 'gamma'))
.build()
then:
pod.spec.containers[0].env == [
[name: 'FOO', value: 'abc'],
[name: 'VAR_X', valueFrom: [secretKeyRef: [name:'delta', key:'bar']]],
[name: 'VAR_Y', valueFrom: [secretKeyRef: [name:'gamma', key:'VAR_Y']]]
]
}
def 'should consume env configMap' () {
when:
def pod = new PodSpecBuilder()
.withPodName('foo')
.withImageName('busybox')
.withCommand(['echo'])
.withEnv( PodEnv.value('FOO','abc'))
.withEnv( PodEnv.config('VAR_X', 'data'))
.withEnv( PodEnv.config('VAR_Y', 'omega/bar-2'))
.build()
then:
pod.spec.containers[0].env == [
[name: 'FOO', value: 'abc'],
[name: 'VAR_X', valueFrom: [configMapKeyRef: [name:'data', key:'VAR_X']]],
[name: 'VAR_Y', valueFrom: [configMapKeyRef: [name:'omega', key:'bar-2']]]
]
}
def 'should consume file secrets' () {
when:
def pod = new PodSpecBuilder()
.withPodName('foo')
.withImageName('busybox')
.withCommand(['echo'])
.withSecret(new PodMountSecret(secret: 'alpha', mountPath: '/this/and/that'))
.withSecret(new PodMountSecret(secret: 'delta/foo', mountPath: '/etc/mnt/bar.txt'))
.build()
then:
pod.spec.containers[0].volumeMounts == [
[name:'vol-1', mountPath:'/this/and/that'],
[name:'vol-2', mountPath:'/etc/mnt']
]
pod.spec.volumes == [
[name:'vol-1', secret:[secretName: 'alpha']],
[name:'vol-2', secret:[
secretName: 'delta',
items: [
[ key: 'foo', path:'bar.txt' ]
]
]]
]
}
def 'should get host path mounts' () {
when:
def pod = new PodSpecBuilder()
.withPodName('foo')
.withImageName('busybox')
.withCommand(['echo'])
.withHostMount('/tmp','/scratch')
.withHostMount('/host/data','/mnt/container')
.build()
then:
pod.spec.containers[0].volumeMounts == [
[name:'vol-1', mountPath:'/scratch'],
[name:'vol-2', mountPath:'/mnt/container']
]
pod.spec.volumes == [
[name:'vol-1', hostPath: [path:'/tmp']],
[name:'vol-2', hostPath: [path:'/host/data']]
]
}
def 'should return secret file volume and mounts' () {
given:
List mounts
List volumes
def builder = new PodSpecBuilder()
when:
def secret1 = new PodMountSecret(secret:'foo', mountPath: '/etc/conf')
builder.secretToSpec( 'vol1', secret1, mounts=[], volumes=[] )
then:
mounts == [
[ name: 'vol1', mountPath: '/etc/conf']
]
volumes == [
[ name: 'vol1', secret: [secretName: 'foo']]
]
when:
def secret2 = new PodMountSecret(secret:'bar/hello.txt', mountPath: '/etc/conf/world.txt')
builder.secretToSpec( 'vol2', secret2, mounts=[], volumes=[] )
then:
mounts == [
[ name: 'vol2', mountPath: '/etc/conf']
]
volumes == [
[ name: 'vol2', secret: [
secretName: 'bar',
items: [ [key: 'hello.txt', path:'world.txt'] ]
]]
]
}
def 'should return configmap file volume and mounts' () {
given:
List mounts
List volumes
def builder = new PodSpecBuilder()
when:
def config1 = new PodMountConfig(config:'foo', mountPath: '/etc/conf')
builder.configMapToSpec( 'vol1', config1, mounts=[], volumes=[] )
then:
mounts == [
[ name: 'vol1', mountPath: '/etc/conf']
]
volumes == [
[ name: 'vol1', configMap: [name: 'foo']]
]
when:
def config2 = new PodMountConfig(config:'bar/hello.txt', mountPath: '/etc/conf/world.txt')
builder.configMapToSpec( 'vol2', config2, mounts=[], volumes=[] )
then:
mounts == [
[ name: 'vol2', mountPath: '/etc/conf']
]
volumes == [
[ name: 'vol2', configMap: [
name: 'bar',
items: [ [key: 'hello.txt', path:'world.txt'] ]
]]
]
}
def 'should create pod spec with pod options' () {
given:
def affinity = [
nodeAffinity: [
requiredDuringSchedulingIgnoredDuringExecution: [
nodeSelectorTerms: [
[key: 'foo', operator: 'In', values: ['bar', 'baz']]
]
]
]
]
def tolerations = [[
key: 'example-key',
operator: 'Exists',
effect: 'NoSchedule'
]]
def opts = Mock(PodOptions)
and:
def builder = new PodSpecBuilder()
.withPodName('foo')
.withImageName('busybox')
.withCommand(['echo'])
.withLabel('runName', 'crazy_john')
.withAnnotation('evict', 'false')
when:
def pod = builder.withPodOptions(opts).build()
then:
_ * opts.getAffinity() >> affinity
_ * opts.getAnnotations() >> [OMEGA:'zzz', SIGMA:'www']
_ * opts.getAutomountServiceAccountToken() >> false
2 * opts.getEnvVars() >> [ PodEnv.value('HELLO','WORLD') ]
2 * opts.getImagePullPolicy() >> 'always'
2 * opts.getImagePullSecret() >> 'myPullSecret'
_ * opts.getLabels() >> [ALPHA: 'xxx', GAMMA: 'yyy']
2 * opts.getVolumeClaims() >> [ new PodVolumeClaim('pvc1', '/work') ]
2 * opts.getMountConfigMaps() >> [ new PodMountConfig('data', '/home/user') ]
2 * opts.getMountSecrets() >> [ new PodMountSecret('blah', '/etc/secret.txt') ]
_ * opts.getNodeSelector() >> new PodNodeSelector(gpu:true, queue: 'fast')
_ * opts.getPriorityClassName() >> 'high-priority'
_ * opts.getSecurityContext() >> new PodSecurityContext(1000)
_ * opts.getTolerations() >> tolerations
and:
pod.metadata == [
name:'foo',
namespace:'default',
labels:[runName:'crazy_john', ALPHA:'xxx', GAMMA:'yyy'],
annotations: [evict: 'false', OMEGA:'zzz', SIGMA:'www']
]
and:
pod.spec.affinity == affinity
pod.spec.automountServiceAccountToken == false
pod.spec.imagePullSecrets == [[ name: 'myPullSecret' ]]
pod.spec.nodeSelector == [gpu: 'true', queue: 'fast']
pod.spec.priorityClassName == 'high-priority'
pod.spec.securityContext == [ runAsUser: 1000 ]
pod.spec.tolerations == tolerations
pod.spec.containers[0].imagePullPolicy == 'always'
pod.spec.containers[0].env == [[name:'HELLO', value:'WORLD']]
pod.spec.containers[0].volumeMounts == [
[name:'vol-1', mountPath:'/work'],
[name:'vol-2', mountPath:'/home/user'],
[name:'vol-3', mountPath:'/etc/secret.txt']
]
and:
pod.spec.volumes == [
[name:'vol-1', persistentVolumeClaim:[claimName:'pvc1']],
[name:'vol-2', configMap:[name:'data']],
[name:'vol-3', secret:[secretName:'blah']]
]
}
def 'should create pod spec with activeDeadlineSeconds' () {
when:
def pod = new PodSpecBuilder()
.withPodName('foo')
.withImageName('busybox')
.withCommand(['echo', 'hello'])
.withActiveDeadline(100)
.build()
then:
pod.spec.activeDeadlineSeconds == 100
}
def 'should create pod spec with schedulerName' () {
when:
def pod = new PodSpecBuilder()
.withPodName('foo')
.withImageName('busybox')
.withCommand(['echo', 'hello'])
.withPodOptions(new PodOptions(schedulerName: 'my-scheduler'))
.build()
then:
pod.spec.schedulerName == 'my-scheduler'
}
def 'should create image pull request map' () {
given:
def builder = new PodSpecBuilder(imagePullSecret: 'MySecret')
when:
def result = builder.createPullSecret()
then:
result.size() == 1
result.get(0).name == 'MySecret'
}
def 'should return the resources map' () {
given:
def builder = new PodSpecBuilder()
when:
def res = builder.addAcceleratorResources(new AcceleratorResource(request:2, limit: 5), null)
then:
res.requests == ['nvidia.com/gpu': 2]
res.limits == ['nvidia.com/gpu': 5]
when:
res = builder.addAcceleratorResources(new AcceleratorResource(limit: 5, type:'foo'), null)
then:
res.requests == ['foo.com/gpu': 5]
res.limits == ['foo.com/gpu': 5]
when:
res = builder.addAcceleratorResources(new AcceleratorResource(request: 5, type:'foo.org'), null)
then:
res.requests == ['foo.org/gpu': 5]
res.limits == null
when:
res = builder.addAcceleratorResources(new AcceleratorResource(request: 5, type: 'foo.org'), [requests: [cpu: 2]])
then:
res.requests == [cpu: 2, 'foo.org/gpu': 5]
res.limits == null
when:
res = builder.addAcceleratorResources(new AcceleratorResource(request: 5, limit: 10, type: 'foo.org'), [requests: [cpu: 2]])
then:
res.requests == [cpu: 2, 'foo.org/gpu': 5]
res.limits == ['foo.org/gpu': 10]
when:
res = builder.addAcceleratorResources(new AcceleratorResource(request: 5, type:'example.com/fpga'), null)
then:
res.requests == ['example.com/fpga': 5]
res.limits == null
when:
res = builder.addAcceleratorResources(new AcceleratorResource(request: 5, limit: 10, type: 'example.com/fpga'), [requests: [cpu: 2]])
then:
res.requests == [cpu: 2, 'example.com/fpga': 5]
res.limits == ['example.com/fpga': 10]
}
def 'should add resources limits' () {
given:
def builder = new PodSpecBuilder()
Map resources
when:
resources = builder.addResourcesLimits(['foo':1], null)
then:
resources == [limits:[foo:1]]
when:
resources = builder.addResourcesLimits(['foo':1], [requests: ['x':1], limits: ['y': 2]])
then:
resources == [requests:[x:1], limits:[y:2, foo:1]]
}
@Unroll
def 'should sanitize k8s label value: #label' () {
given:
def builder = new PodSpecBuilder()
expect:
builder.sanitizeValue(label, PodSpecBuilder.MetaType.LABEL, PodSpecBuilder.SegmentType.VALUE) == str
where:
label | str
null | 'null'
'hello' | 'hello'
'hello world' | 'hello_world'
'hello world' | 'hello_world'
'hello.world' | 'hello.world'
'hello-world' | 'hello-world'
'hello_world' | 'hello_world'
'hello_world-' | 'hello_world'
'hello_world_' | 'hello_world'
'hello_world.' | 'hello_world'
'hello_123' | 'hello_123'
'HELLO 123' | 'HELLO_123'
'123hello' | '123hello'
'x2345678901234567890123456789012345678901234567890123456789012345' | 'x23456789012345678901234567890123456789012345678901234567890123'
}
@Unroll
def 'should sanitize k8s label key: #label_key' () {
given:
def builder = new PodSpecBuilder()
expect:
builder.sanitizeKey(label_key, PodSpecBuilder.MetaType.LABEL) == str
where:
label_key | str
'foo' | 'foo'
'key 1' | 'key_1'
'foo.bar/key 2' | 'foo.bar/key_2'
'foo.bar/' | 'foo.bar'
'/foo.bar' | 'foo.bar'
'x2345678901234567890123456789012345678901234567890123456789012345' | 'x23456789012345678901234567890123456789012345678901234567890123'
'x23456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345/key 2' | 'x234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123/key_2'
'foo.bar/x2345678901234567890123456789012345678901234567890123456789012345' | 'foo.bar/x23456789012345678901234567890123456789012345678901234567890123'
}
@Unroll
def 'should report error if sanitizing k8s label with more than one slash character: #label_key' () {
given:
def builder = new PodSpecBuilder()
when:
builder.sanitizeKey(label_key, PodSpecBuilder.MetaType.LABEL)
then:
def error = thrown(expectedException)
error.message == expectedMessage
where:
label_key | expectedException | expectedMessage
'foo.bar/key 2/key 3' | IllegalArgumentException | "Invalid key in pod label -- Key can only contain exactly one '/' character"
'foo.bar/foo/bar/bar' | IllegalArgumentException | "Invalid key in pod label -- Key can only contain exactly one '/' character"
}
@Unroll
def 'should sanitize k8s label map' () {
given:
def builder = new PodSpecBuilder()
expect:
builder.sanitize(KEY_VALUE, PodSpecBuilder.MetaType.LABEL) == EXPECTED
where:
KEY_VALUE | EXPECTED
[foo:'bar'] | [foo:'bar']
['key 1':'value 2'] | [key_1:'value_2']
['foo.bar/key 2':'value 3'] | ['foo.bar/key_2':'value_3']
}
@Unroll
def 'should sanitize k8s annotation key' () {
given:
def builder = new PodSpecBuilder()
expect:
builder.sanitize(KEY_VALUE, PodSpecBuilder.MetaType.ANNOTATION) == EXPECTED
where:
KEY_VALUE | EXPECTED
[foo:'bar'] | [foo:'bar']
['key 1':'value 2'] | [key_1:'value 2']
['foo.bar/key 2':'value 3'] | ['foo.bar/key_2':'value 3']
['x2345678901234567890123456789012345678901234567890123456789012345':'value 5'] | ['x23456789012345678901234567890123456789012345678901234567890123':'value 5']
['x23456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345/key 4':'value 6'] | ['x234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123/key_4':'value 6']
['foo.bar/x2345678901234567890123456789012345678901234567890123456789012345':'value 7'] | ['foo.bar/x23456789012345678901234567890123456789012345678901234567890123':'value 7']
}
@Unroll
def 'should report error if sanitizing k8s annotation key with more than one slash character: #annotation_key' () {
given:
def builder = new PodSpecBuilder()
when:
builder.sanitizeKey(annotation_key, PodSpecBuilder.MetaType.ANNOTATION)
then:
def error = thrown(expectedException)
error.message == expectedMessage
where:
annotation_key | expectedException | expectedMessage
'foo.bar/key 2/key 3' | IllegalArgumentException | "Invalid key in pod annotation -- Key can only contain exactly one '/' character"
'foo.bar/foo/bar/bar' | IllegalArgumentException | "Invalid key in pod annotation -- Key can only contain exactly one '/' character"
}
@Unroll
def 'should not sanitize k8s annotation value' () {
given:
def builder = new PodSpecBuilder()
expect:
builder.sanitize(ANNOTATION, PodSpecBuilder.MetaType.ANNOTATION) == EXPECTED
where:
ANNOTATION | EXPECTED
['foo':'value 1'] | ['foo':'value 1']
['foo':'foo.bar / *'] | ['foo':'foo.bar / *']
['foo':'value 2 \n value 3'] | ['foo':'value 2 \n value 3']
['foo':'value 3'] | ['foo':'value 3']
['foo':'x2345678901234567890123456789012345678901234567890123456789012345'] | ['foo':'x2345678901234567890123456789012345678901234567890123456789012345']
}
def 'should create job spec' () {
when:
def spec = new PodSpecBuilder()
.withPodName('foo')
.withImageName('busybox')
.withCommand(['echo', 'hello'])
.buildAsJob()
then:
spec == [
apiVersion: 'batch/v1',
kind: 'Job',
metadata: [name: 'foo', namespace: 'default'],
spec: [
backoffLimit: 0,
template: [
metadata: [name: 'foo', namespace: 'default'],
spec: [
restartPolicy: 'Never',
containers: [[
name: 'foo',
image: 'busybox',
command: ['echo', 'hello'],
]]
]
]
]
]
}
def 'should create job spec with labels and annotations' () {
when:
def job = new PodSpecBuilder()
.withPodName('foo')
.withImageName('busybox')
.withCommand(['echo', 'hello'])
.withLabel('app','someApp')
.withLabel('runName','someName')
.withLabel('version','3.8.1')
.withAnnotation('anno1', 'val1')
.withAnnotations([anno2: 'val2', anno3: 'val3'])
.buildAsJob()
def metadata = [
name: 'foo',
namespace: 'default',
labels: [
app: 'someApp',
runName: 'someName',
version: '3.8.1'
],
annotations: [
anno1: 'val1',
anno2: 'val2',
anno3: 'val3'
]
]
then:
job.metadata == metadata
job.spec.template.metadata == metadata
}
def 'should create job spec with ttl seconds' () {
when:
def job = new PodSpecBuilder()
.withPodName('foo')
.withImageName('busybox')
.withCommand(['echo', 'hello'])
.buildAsJob()
then:
!job.spec.ttlSecondsAfterFinished
when:
job = new PodSpecBuilder()
.withPodName('foo')
.withImageName('busybox')
.withCommand(['echo', 'hello'])
.withPodOptions( new PodOptions(ttlSecondsAfterFinished: 60) )
.buildAsJob()
then:
job.spec.ttlSecondsAfterFinished == 60
}
def 'should create pod spec with runtimeClassName' () {
when:
def pod = new PodSpecBuilder()
.withPodName('foo')
.withImageName('busybox')
.withCommand(['echo', 'hello'])
.build()
then:
!pod.spec.runtimeClassName
when:
pod = new PodSpecBuilder()
.withPodName('foo')
.withImageName('busybox')
.withCommand(['echo', 'hello'])
.withPodOptions(new PodOptions(runtimeClassName: 'val1'))
.build()
then:
pod.spec.runtimeClassName == 'val1'
}
}

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 nextflow.k8s.model
import spock.lang.Specification
/**
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
class PodVolumeClaimTest extends Specification {
def 'should create a pod volume claim' () {
when:
def vol1 = new PodVolumeClaim('foo', '/bar')
then:
vol1.claimName == 'foo'
vol1.mountPath == '/bar'
vol1.readOnly == false
when:
def vol2 = new PodVolumeClaim(volumeClaim: 'alpha', mountPath: '/gamma')
then:
vol2.claimName == 'alpha'
vol2.mountPath == '/gamma'
vol2.readOnly == false
when:
def vol3 = new PodVolumeClaim('aaa', '/bbb', null, true)
then:
vol3.claimName == 'aaa'
vol3.mountPath == '/bbb'
vol3.readOnly == true
when:
def vol4 = new PodVolumeClaim(volumeClaim: 'ccc', mountPath: '/ddd', readOnly: true)
then:
vol4.claimName == 'ccc'
vol4.mountPath == '/ddd'
vol4.readOnly == true
}
def 'should sanitize paths' () {
expect :
new PodVolumeClaim('foo','/data/work//').mountPath == '/data/work'
new PodVolumeClaim('foo','//').mountPath == '/'
new PodVolumeClaim('foo','/data').mountPath == '/data'
when:
new PodVolumeClaim('foo','data')
then:
thrown(IllegalArgumentException)
}
}