move to standalone plugin

This commit is contained in:
2026-08-23 13:44:39 +02:00
parent f227d054b8
commit 4188bea061
65 changed files with 14093 additions and 77 deletions

View File

@@ -0,0 +1,497 @@
/*
* 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 recreationaltech.plugin
import nextflow.BuildInfo
import nextflow.SysEnv
import recreationaltech.plugin.client.ClientConfig
import recreationaltech.plugin.model.PodEnv
import recreationaltech.plugin.model.PodSecurityContext
import recreationaltech.plugin.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')
}
def 'should have nodeInit image' () {
when:
def cfg = new K8sConfig(
nodeInit: [
image: 'some-image:0'
]
)
then:
cfg.nodeInit.image == 'some-image:0'
}
}

View File

@@ -0,0 +1,674 @@
/*
* 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 recreationaltech.plugin
import java.nio.file.Files
import nextflow.cli.CliOptions
import nextflow.cli.CmdKubeRun
import nextflow.cli.Launcher
import recreationaltech.plugin.client.ClientConfig
import recreationaltech.plugin.client.K8sClient
import recreationaltech.plugin.model.PodMountConfig
import recreationaltech.plugin.model.PodOptions
import recreationaltech.plugin.model.PodSpecBuilder
import recreationaltech.plugin.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') ]
pod.automountServiceAccountToken = false
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: [
restartPolicy: 'Never',
containers: [
[
name: 'foo-boo',
image: 'the-image',
command: ['/bin/bash', '-c', "source /etc/nextflow/init.sh; nextflow run foo; if [ -x /etc/nextflow/node-init-cleanup.sh ]; then /etc/nextflow/node-init-cleanup.sh || true; fi; exit \$status"],
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',
automountServiceAccountToken: false,
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: [
restartPolicy: 'Never',
containers: [
[
name: 'foo-boo',
image: 'the-image',
command: ['/bin/bash', '-c', "source /etc/nextflow/init.sh; nextflow run foo; if [ -x /etc/nextflow/node-init-cleanup.sh ]; then /etc/nextflow/node-init-cleanup.sh || true; fi; exit \$status"],
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',
automountServiceAccountToken: false,
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
driver.@initDeployer = new K8sNodeInitDeployer(driver.k8sClient, config)
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

@@ -1,22 +0,0 @@
package recreationaltech.plugin
import nextflow.Session
import spock.lang.Specification
/**
* Implements a basic factory test
*
*/
class K8sDvfsObserverTest extends Specification {
def 'should create the observer instance' () {
given:
def factory = new K8sDvfsFactory()
when:
def result = factory.create(Mock(Session))
then:
result.size() == 1
result.first() instanceof K8sDvfsObserver
}
}

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 recreationaltech.plugin
import java.util.concurrent.TimeUnit
import com.google.common.cache.CacheBuilder
import recreationaltech.plugin.client.ClientConfig
import recreationaltech.plugin.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)
}
}

View File

@@ -0,0 +1,164 @@
package recreationaltech.plugin
import recreationaltech.plugin.client.K8sClient
import recreationaltech.plugin.model.PodSpecBuilder
import spock.lang.Specification
class K8sNodeInitDeployerTest extends Specification {
def setup() {
PodSpecBuilder.VOLUMES.set(0)
}
def 'should not deploy pods when node init is disabled' () {
given:
def client = Mock(K8sClient)
def config = new K8sConfig(nodeInit: [enabled: false])
def deployer = new K8sNodeInitDeployer(client, config)
when:
deployer.deploy()
then:
0 * client.nodeList()
0 * client.podCreate(_)
}
def 'should deploy one init pod for each node' () {
given:
def client = Mock(K8sClient)
def config = new K8sConfig(nodeInit: [
enabled: true,
image: 'ubuntu:latest',
command: ['/bin/bash', '-c', 'echo init']
])
def deployer = new K8sNodeInitDeployer(client, config)
when:
deployer.deploy()
then:
1 * client.nodeList() >> [
items: [
[metadata: [name: 'node-a']],
[metadata: [name: 'node-b']]
]
]
then:
1 * client.podCreate({ Map spec ->
spec.kind == 'Pod'
spec.metadata.name == 'nf-init-node-a'
spec.metadata.namespace == 'default'
spec.spec.nodeName == 'node-a'
spec.spec.restartPolicy == 'Never'
def container = spec.spec.containers[0]
container.name == 'nf-init-node-a'
container.image == 'ubuntu:latest'
container.command == ['/bin/bash', '-c', 'echo init']
container.securityContext.privileged == true
spec.spec.volumes*.hostPath*.path as Set == ['/sys', '/dev', '/lib/modules'] as Set
container.volumeMounts*.mountPath as Set == ['/sys', '/dev', '/lib/modules'] as Set
})
then:
1 * client.podCreate({ Map spec ->
spec.kind == 'Pod'
spec.metadata.name == 'nf-init-node-b'
spec.spec.nodeName == 'node-b'
def container = spec.spec.containers[0]
container.name == 'nf-init-node-b'
container.image == 'ubuntu:latest'
container.command == ['/bin/bash', '-c', 'echo init']
container.securityContext.privileged == true
})
0 * client._
}
def 'should lowercase and truncate generated pod names' () {
given:
def client = Mock(K8sClient)
def config = new K8sConfig(nodeInit: [
enabled: true,
image: 'ubuntu:latest',
command: ['true']
])
def deployer = new K8sNodeInitDeployer(client, config)
when:
deployer.deploy()
then:
1 * client.nodeList() >> [
items: [
[metadata: [name: 'NODE-WITH-A-VERY-LONG-NAME-ABCDEFGHIJKLMNOPQRSTUVWXYZ-0123']]
]
]
then:
1 * client.podCreate({ Map spec ->
spec.metadata.name == 'nf-init-node-with-a-very-long-name-abcdefghijklmnopqrstuvwxyz-0'
spec.metadata.name.size() == 63
spec.metadata.name == spec.metadata.name.toLowerCase()
spec.spec.nodeName == 'NODE-WITH-A-VERY-LONG-NAME-ABCDEFGHIJKLMNOPQRSTUVWXYZ-0123'
})
then:
0 * client._
}
def 'should not cleanup pods when node init is disabled' () {
given:
def client = Mock(K8sClient)
def config = new K8sConfig(nodeInit: [enabled: false, cleanup: true])
def deployer = new K8sNodeInitDeployer(client, config)
when:
deployer.cleanup()
then:
0 * client.nodeList()
0 * client.podDelete(_)
}
def 'should not cleanup pods when cleanup is disabled' () {
given:
def client = Mock(K8sClient)
def config = new K8sConfig(nodeInit: [enabled: true, cleanup: false])
def deployer = new K8sNodeInitDeployer(client, config)
when:
deployer.cleanup()
then:
0 * client.nodeList()
0 * client.podDelete(_)
}
def 'should cleanup one init pod for each node' () {
given:
def client = Mock(K8sClient)
def config = new K8sConfig(nodeInit: [enabled: true, cleanup: true])
def deployer = new K8sNodeInitDeployer(client, config)
when:
deployer.cleanup()
then:
1 * client.nodeList() >> [
items: [
[metadata: [name: 'node-a']],
[metadata: [name: 'NODE-B']]
]
]
then:
1 * client.podDelete('nf-init-node-a')
1 * client.podDelete('nf-init-node-b')
0 * client._
}
}

View File

@@ -0,0 +1,55 @@
package recreationaltech.plugin
import spock.lang.Specification
class K8sRuntimeEstimatorTest extends Specification {
def 'does extract a function' () {
given:
def observations = [a: [new Tuple2(0, 0), new Tuple2(100, 100)]]
def estimator = new K8sRuntimeEstimator(observations)
when:
def estimation = estimator.estimate("a", 1234)
then:
estimation != Double.POSITIVE_INFINITY
estimation == 1234.0
}
def 'can extract a function from single measurement' () {
given:
def observations = [a: [new Tuple2(100, 100)]]
def estimator = new K8sRuntimeEstimator(observations)
when:
def estimation = estimator.estimate("a", 1234)
then:
estimation == 100.0 /* Single measurement implies constant runtime */
}
def 'unknown function returns positive infinity' () {
given:
def observations = [] as HashMap<String, ArrayList>
def estimator = new K8sRuntimeEstimator(observations)
when:
def estimation = estimator.estimate("b", 1234)
then:
estimation == Double.POSITIVE_INFINITY
}
def 'extracts function from multiple measurements' () {
given:
def observations = [a: [new Tuple2(10, 15), new Tuple2(20, 34), new Tuple2(30, 46)]]
def estimator = new K8sRuntimeEstimator(observations)
when:
def estimation = estimator.estimate("a", 40)
then:
estimation != Double.POSITIVE_INFINITY
}
}

View File

@@ -0,0 +1,23 @@
package recreationaltech.plugin
import nextflow.processor.TaskRun
import spock.lang.Specification
class K8sSchedulingRequestTest extends Specification {
def 'should create scheduling request from task handler' () {
given:
def task = Mock(TaskRun)
def handler = Spy(K8sTaskHandler)
handler.task = task
when:
def request = new K8sSchedulingRequest(handler)
then:
request.handler == handler
request.task == task
request.submitTimeMillis > 0
request.submitTimeMillis <= System.currentTimeMillis()
}
}

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 recreationaltech.plugin
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 recreationaltech.plugin.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 recreationaltech.plugin.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 recreationaltech.plugin.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 recreationaltech.plugin.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 recreationaltech.plugin.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 recreationaltech.plugin.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 recreationaltech.plugin.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 recreationaltech.plugin.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 recreationaltech.plugin.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 recreationaltech.plugin.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 recreationaltech.plugin.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)
}
}