Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for Singularity OCI mode #4440

Merged
merged 16 commits into from
Nov 6, 2023
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -1335,6 +1335,12 @@ The following settings are available:
`singularity.noHttps`
: Pull the Singularity image with http protocol (default: `false`).

`singularity.oci`
: :::{versionadded} 23.11.0-edge
:::
: Enable OCI-mode the allows the use of native OCI-compatible containers with Singularity. See [Singularity documentation](https://docs.sylabs.io/guides/4.0/user-guide/oci_runtime.html#oci-mode) for more details and requirements (default: `false`).


`singularity.pullTimeout`
: The amount of time the Singularity pull can last, exceeding which the process is terminated (default: `20 min`).

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ class ContainerConfig extends LinkedHashMap {
get('engine')
}

boolean singularityOciMode() {
getEngine()=='singularity' && get('oci')?.toString() == 'true'
}

List<String> getEnvWhitelist() {
def result = get('envWhitelist')
if( !result )
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ class ContainerHandler {
final normalizedImageName = normalizeSingularityImageName(imageName)
if( !config.isEnabled() || !normalizedImageName )
return normalizedImageName
if( normalizedImageName.startsWith('docker://') && config.singularityOciMode() )
return normalizedImageName
final requiresCaching = normalizedImageName =~ IMAGE_URL_PREFIX
if( ContainerInspectMode.active() && requiresCaching )
return imageName
Expand Down Expand Up @@ -192,7 +194,7 @@ class ContainerHandler {
}


public static final Pattern IMAGE_URL_PREFIX = ~/^[^\/:\. ]+:\/\/(.*)/
public static final Pattern IMAGE_URL_PREFIX = ~/^[^\/:. ]+:\/\/(.*)/

/**
* Normalize Singularity image name resolving the absolute path or
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ class SingularityBuilder extends ContainerBuilder<SingularityBuilder> {

private String runCmd0

private Boolean oci

SingularityBuilder(String name) {
this.image = name
this.homeMount = defaultHomeMount()
Expand Down Expand Up @@ -92,6 +94,9 @@ class SingularityBuilder extends ContainerBuilder<SingularityBuilder> {
if( params.containsKey('readOnlyInputs') )
this.readOnlyInputs = params.readOnlyInputs?.toString() == 'true'

if( params.oci!=null )
oci = params.oci.toString() == 'true'

return this
}

Expand All @@ -117,9 +122,12 @@ class SingularityBuilder extends ContainerBuilder<SingularityBuilder> {
if( !homeMount )
result << '--no-home '

if( newPidNamespace )
if( newPidNamespace && !oci )
result << '--pid '

if( oci != null )
result << (oci ? '--oci ' : '--no-oci ')

if( autoMounts ) {
makeVolumes(mounts, result)
}
Expand All @@ -145,6 +153,11 @@ class SingularityBuilder extends ContainerBuilder<SingularityBuilder> {
protected CharSequence appendEnv(StringBuilder result) {
makeEnv('TMP',result) .append(' ')
makeEnv('TMPDIR',result) .append(' ')
// add magic variables required by singularity to run in OCI-mode
if( oci ) {
result .append('${XDG_RUNTIME_DIR:+XDG_RUNTIME_DIR="$XDG_RUNTIME_DIR"} ')
result .append('${DBUS_SESSION_BUS_ADDRESS:+DBUS_SESSION_BUS_ADDRESS="$DBUS_SESSION_BUS_ADDRESS"} ')
}
super.appendEnv(result)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,27 @@ class ContainerConfigTest extends Specification {

}


def 'should validate oci mode' () {

when:
def cfg = new ContainerConfig(OPTS)
then:
cfg.singularityOciMode() == EXPECTED

where:
OPTS | EXPECTED
[:] | false
[oci:false] | false
[oci:true] | false
[engine:'apptainer', oci:true] | false
[engine:'docker', oci:true] | false
[engine:'singularity'] | false
[engine:'singularity', oci:false] | false
[engine:'singularity', oci:true] | true

}

def 'should get fusion options' () {
when:
def cfg = new ContainerConfig(OPTS)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -200,11 +200,12 @@ class ContainerHandlerTest extends Specification {
result == 'shifter://image'

}

@Unroll
def 'test normalize method for singularity' () {
given:
def BASE = Paths.get('/abs/path/')
def handler = Spy(new ContainerHandler(engine: 'singularity', enabled: true, baseDir: BASE))
def handler = Spy(new ContainerHandler(engine: 'singularity', enabled: true, oci:OCI, baseDir: BASE))

when:
def result = handler.normalizeImageName(IMAGE)
Expand All @@ -215,17 +216,21 @@ class ContainerHandlerTest extends Specification {
result == EXPECTED

where:
IMAGE | NORMALIZED | X | EXPECTED
null | null | 0 | null
'' | null | 0 | null
'/abs/path/bar.img' | '/abs/path/bar.img' | 0 | '/abs/path/bar.img'
'/abs/path bar.img' | '/abs/path bar.img' | 0 | '/abs/path\\ bar.img'
'file:///abs/path/bar.img' | '/abs/path/bar.img' | 0 | '/abs/path/bar.img'
'foo.img' | Paths.get('foo.img').toAbsolutePath().toString() | 0 | Paths.get('foo.img').toAbsolutePath().toString()
'shub://busybox' | 'shub://busybox' | 1 | '/path/to/busybox'
'docker://library/busybox' | 'docker://library/busybox' | 1 | '/path/to/busybox'
'foo' | 'docker://foo' | 1 | '/path/to/foo'
'library://pditommaso/foo/bar.sif:latest' | 'library://pditommaso/foo/bar.sif:latest' | 1 | '/path/to/foo-bar-latest.img'
IMAGE | NORMALIZED | OCI | X | EXPECTED
null | null | false | 0 | null
'' | null | false | 0 | null
'/abs/path/bar.img' | '/abs/path/bar.img' | false | 0 | '/abs/path/bar.img'
'/abs/path bar.img' | '/abs/path bar.img' | false | 0 | '/abs/path\\ bar.img'
'file:///abs/path/bar.img' | '/abs/path/bar.img' | false | 0 | '/abs/path/bar.img'
'foo.img' | Paths.get('foo.img').toAbsolutePath().toString() | false | 0 | Paths.get('foo.img').toAbsolutePath().toString()
'shub://busybox' | 'shub://busybox' | false | 1 | '/path/to/busybox'
'docker://library/busybox' | 'docker://library/busybox' | false | 1 | '/path/to/busybox'
'foo' | 'docker://foo' | false | 1 | '/path/to/foo'
'library://pditommaso/foo/bar.sif:latest' | 'library://pditommaso/foo/bar.sif:latest' | false | 1 | '/path/to/foo-bar-latest.img'
and:
'docker://library/busybox' | 'docker://library/busybox' | true | 0 | 'docker://library/busybox'
'shub://busybox' | 'shub://busybox' | true | 1 | '/path/to/busybox'

}

def 'should not invoke caching when engine is disabled' () {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,11 @@ class SingularityBuilderTest extends Specification {
.build()
.runCommand == 'set +u; env - PATH="$PATH" ${TMP:+SINGULARITYENV_TMP="$TMP"} ${TMPDIR:+SINGULARITYENV_TMPDIR="$TMPDIR"} singularity exec --no-home ubuntu'

new SingularityBuilder('ubuntu')
.params(oci: true)
.build()
.runCommand == 'set +u; env - PATH="$PATH" ${TMP:+SINGULARITYENV_TMP="$TMP"} ${TMPDIR:+SINGULARITYENV_TMPDIR="$TMPDIR"} ${XDG_RUNTIME_DIR:+XDG_RUNTIME_DIR="$XDG_RUNTIME_DIR"} ${DBUS_SESSION_BUS_ADDRESS:+DBUS_SESSION_BUS_ADDRESS="$DBUS_SESSION_BUS_ADDRESS"} singularity exec --no-home --oci -B "$PWD" ubuntu'

}

def 'should mount home directory if specified' () {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1010,6 +1010,20 @@ class BashWrapperBuilderTest extends Specification {
binding.kill_cmd == '[[ "$pid" ]] && nxf_kill $pid'
}

def 'should create wrapper with singularity oci mode'() {
when:
def binding = newBashWrapperBuilder(
containerEnabled: true,
containerImage: 'docker://ubuntu:latest',
environment: [PATH: '/path/to/bin:$PATH', FOO: 'xxx'],
containerConfig: [enabled: true, engine: 'singularity', oci: true] as ContainerConfig ).makeBinding()

then:
binding.launch_cmd == 'set +u; env - PATH="$PATH" ${TMP:+SINGULARITYENV_TMP="$TMP"} ${TMPDIR:+SINGULARITYENV_TMPDIR="$TMPDIR"} ${XDG_RUNTIME_DIR:+XDG_RUNTIME_DIR="$XDG_RUNTIME_DIR"} ${DBUS_SESSION_BUS_ADDRESS:+DBUS_SESSION_BUS_ADDRESS="$DBUS_SESSION_BUS_ADDRESS"} ${NXF_TASK_WORKDIR:+SINGULARITYENV_NXF_TASK_WORKDIR="$NXF_TASK_WORKDIR"} singularity exec --no-home --oci -B /work/dir docker://ubuntu:latest /bin/bash -c "cd $PWD; eval $(nxf_container_env); /bin/bash -ue /work/dir/.command.sh"'
binding.cleanup_cmd == ""
binding.kill_cmd == '[[ "$pid" ]] && nxf_kill $pid'
}

def 'should create task and container env' () {
given:
def ENV = [FOO: 'hello', BAR: 'hello world', PATH: '/some/path:$PATH']
Expand Down
2 changes: 1 addition & 1 deletion plugins/nf-wave/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ dependencies {
api 'org.apache.commons:commons-lang3:3.12.0'
api 'com.google.code.gson:gson:2.10.1'
api 'org.yaml:snakeyaml:2.0'
api 'io.seqera:wave-utils:0.7.9'
api 'io.seqera:wave-utils:0.8.0'

testImplementation(testFixtures(project(":nextflow")))
testImplementation "org.codehaus.groovy:groovy:3.0.19"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import groovy.util.logging.Slf4j
import io.seqera.wave.plugin.WaveClient
import nextflow.Global
import nextflow.Session
import nextflow.container.ContainerConfig
import nextflow.container.resolver.ContainerInfo
import nextflow.container.resolver.ContainerResolver
import nextflow.container.resolver.DefaultContainerResolver
Expand Down Expand Up @@ -52,8 +53,7 @@ class WaveContainerResolver implements ContainerResolver {
return client0 = new WaveClient( Global.session as Session )
}

private String getContainerEngine0(TaskRun task) {
final config = task.getContainerConfig()
private String getContainerEngine0(ContainerConfig config) {
final result = config.getEngine()
if( result )
return result
Expand All @@ -68,13 +68,15 @@ class WaveContainerResolver implements ContainerResolver {
return defaultResolver.resolveImage(task, imageName)

final freeze = client().config().freezeMode()
final engine = getContainerEngine0(task)
final nativeSingularityBuild = freeze && engine in SINGULARITY_LIKE
final config = task.getContainerConfig()
final engine = getContainerEngine0(config)
final singularityOciMode = config.singularityOciMode()
final singularitySpec = freeze && engine in SINGULARITY_LIKE && !singularityOciMode
if( !imageName ) {
// when no image name is provided the module bundle should include a
// Dockerfile or a Conda recipe or a Spack recipe to build
// an image on-fly with an automatically assigned name
return waveContainer(task, null, nativeSingularityBuild)
return waveContainer(task, null, singularitySpec)
}

if( engine in DOCKER_LIKE ) {
Expand All @@ -90,7 +92,7 @@ class WaveContainerResolver implements ContainerResolver {
return defaultResolver.resolveImage(task, imageName)
}
// fetch the wave container name
final image = waveContainer(task, imageName, nativeSingularityBuild)
final image = waveContainer(task, imageName, singularitySpec)
// oras prefixed container are served directly
if( image && image.target.startsWith("oras://") )
return image
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,7 @@ class WaveClientTest extends Specification {
&& micromamba install -y -n base conda-forge::procps-ng \\
&& micromamba clean -a -y
USER root
ENV PATH="$MAMBA_ROOT_PREFIX/bin:$PATH"
'''.stripIndent()
and:
!assets.moduleResources
Expand Down Expand Up @@ -572,6 +573,7 @@ class WaveClientTest extends Specification {
&& micromamba install -y -n base conda-forge::procps-ng \\
&& micromamba clean -a -y
USER root
ENV PATH="$MAMBA_ROOT_PREFIX/bin:$PATH"
'''.stripIndent()
and:
!assets.moduleResources
Expand Down Expand Up @@ -647,6 +649,7 @@ class WaveClientTest extends Specification {
&& micromamba install -y -n base conda-forge::procps-ng \\
&& micromamba clean -a -y
USER root
ENV PATH="$MAMBA_ROOT_PREFIX/bin:$PATH"
'''.stripIndent()
and:
assets.condaFile == condaFile
Expand Down