From d67f8318072e5c4010604df231250dd2717c9b05 Mon Sep 17 00:00:00 2001 From: Paolo Di Tommaso Date: Fri, 24 Apr 2026 21:38:09 +0200 Subject: [PATCH 1/4] Add Apple container engine support Introduce AppleContainerBuilder and AppleContainerConfig to run tasks with Apple's `container` runtime (https://github.com/apple/container), which launches one lightweight VM per container on Apple silicon via Virtualization.framework. Wires the builder into ContainerBuilder.create() and Session.getContainerConfig() so it can be selected via the `appleContainer` config scope. Signed-off-by: Paolo Di Tommaso --- .../src/main/groovy/nextflow/Session.groovy | 2 + .../container/AppleContainerBuilder.groovy | 158 ++++++++++++++++++ .../container/AppleContainerConfig.groovy | 119 +++++++++++++ .../container/ContainerBuilder.groovy | 2 + .../AppleContainerBuilderTest.groovy | 148 ++++++++++++++++ .../container/ContainerBuilderTest.groovy | 1 + 6 files changed, 430 insertions(+) create mode 100644 modules/nextflow/src/main/groovy/nextflow/container/AppleContainerBuilder.groovy create mode 100644 modules/nextflow/src/main/groovy/nextflow/container/AppleContainerConfig.groovy create mode 100644 modules/nextflow/src/test/groovy/nextflow/container/AppleContainerBuilderTest.groovy diff --git a/modules/nextflow/src/main/groovy/nextflow/Session.groovy b/modules/nextflow/src/main/groovy/nextflow/Session.groovy index 07792c8787..6cff196fd1 100644 --- a/modules/nextflow/src/main/groovy/nextflow/Session.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/Session.groovy @@ -37,6 +37,7 @@ import nextflow.cache.CacheDB import nextflow.cache.CacheFactory import nextflow.conda.CondaConfig import nextflow.config.Manifest +import nextflow.container.AppleContainerConfig import nextflow.container.ApptainerConfig import nextflow.container.CharliecloudConfig import nextflow.container.ContainerConfig @@ -1187,6 +1188,7 @@ class Session implements ISession { new SingularityConfig(config.singularity as Map ?: Collections.emptyMap()), new ApptainerConfig(config.apptainer as Map ?: Collections.emptyMap()), new CharliecloudConfig(config.charliecloud as Map ?: Collections.emptyMap()), + new AppleContainerConfig(config.appleContainer as Map ?: Collections.emptyMap()), ] as List if( engine ) { diff --git a/modules/nextflow/src/main/groovy/nextflow/container/AppleContainerBuilder.groovy b/modules/nextflow/src/main/groovy/nextflow/container/AppleContainerBuilder.groovy new file mode 100644 index 0000000000..239b305fdf --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/container/AppleContainerBuilder.groovy @@ -0,0 +1,158 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package nextflow.container + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +/** + * Wrap a task execution inside an Apple container (apple/container) runtime. + * + * @author Paolo Di Tommaso + */ +@Slf4j +@CompileStatic +class AppleContainerBuilder extends ContainerBuilder { + + private boolean remove + + private boolean tty + + private String name + + private String capAdd + + private String removeCommand + + private String killCommand + + private kill = true + + AppleContainerBuilder(String name, AppleContainerConfig config) { + this.image = name + + if( config.engineOptions ) + addEngineOptions(config.engineOptions) + + if( config.runOptions ) + addRunOptions(config.runOptions) + + if( config.temp ) + this.temp = config.temp + + this.remove = config.remove + this.tty = config.tty + + if( !config.writableInputMounts ) + this.readOnlyInputs = true + } + + AppleContainerBuilder(String name) { + this(name, new AppleContainerConfig([:])) + } + + @Override + AppleContainerBuilder params( Map params ) { + if( !params ) return this + + if( params.containsKey('entry') ) + this.entryPoint = params.entry + + if( params.containsKey('kill') ) + this.kill = params.kill + + if( params.containsKey('capAdd') ) + this.capAdd = params.capAdd + + return this + } + + @Override + AppleContainerBuilder setName( String name ) { + this.name = name + return this + } + + @Override + AppleContainerBuilder build(StringBuilder result) { + assert image + + result << 'container ' + + if( engineOptions ) + result << engineOptions.join(' ') << ' ' + + result << 'run -i ' + + if( tty ) + result << '-t ' + + if( cpus ) + result << "--cpus ${cpus} " + + if( memory ) + result << "-m ${memory} " + + if( platform ) + result << "--platform ${platform} " + + // environment variables + appendEnv(result) + + if( temp ) + result << "-v $temp:/tmp " + + // volume mounts + result << makeVolumes(mounts) + result << '-w "$NXF_TASK_WORKDIR" ' + + if( entryPoint ) + result << '--entrypoint ' << entryPoint << ' ' + + if( runOptions ) + result << runOptions.join(' ') << ' ' + + if( capAdd ) + result << '--cap-add ' << capAdd << ' ' + + if( name ) + result << '--name ' << name << ' ' + + // image is the final positional argument + result << image + + runCommand = result.toString() + + if( remove && name ) { + removeCommand = 'container rm ' + name + } + + if( kill ) { + killCommand = 'container stop ' + if( kill instanceof String ) killCommand = "container kill -s $kill " + killCommand += name + } + + return this + } + + @Override + String getRemoveCommand() { removeCommand } + + @Override + String getKillCommand() { killCommand } +} diff --git a/modules/nextflow/src/main/groovy/nextflow/container/AppleContainerConfig.groovy b/modules/nextflow/src/main/groovy/nextflow/container/AppleContainerConfig.groovy new file mode 100644 index 0000000000..d7cf53312f --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/container/AppleContainerConfig.groovy @@ -0,0 +1,119 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.container + +import groovy.transform.CompileStatic +import groovy.transform.EqualsAndHashCode +import groovy.util.logging.Slf4j +import nextflow.config.spec.ConfigOption +import nextflow.config.spec.ConfigScope +import nextflow.config.spec.ScopeName +import nextflow.script.dsl.Description + +@ScopeName("appleContainer") +@Description(""" + The `appleContainer` scope controls how [Apple container](https://github.com/apple/container) runtime is used by Nextflow. +""") +@Slf4j +@CompileStatic +@EqualsAndHashCode +class AppleContainerConfig implements ConfigScope, ContainerConfig { + + @ConfigOption + @Description(""" + Enable Apple container execution (default: `false`). + """) + boolean enabled + + @ConfigOption + @Description(""" + Specify additional options supported by the `container` CLI i.e. `container [OPTIONS] run`. + """) + final String engineOptions + + @ConfigOption + @Description(""" + Comma separated list of environment variable names to be included in the container environment. + """) + final List envWhitelist + + @ConfigOption(types=[String,Boolean]) + @Description(""" + """) + final Object kill + + @ConfigOption + @Description(""" + The registry from where container images are pulled. It should NOT include the protocol prefix i.e. `http://`. + """) + final String registry + + @ConfigOption + @Description(""" + Clean up the container after the execution (default: `true`). + """) + final boolean remove + + @ConfigOption + @Description(""" + Specify extra command line options supported by the `container run` command. + """) + final String runOptions + + @ConfigOption + @Description(""" + Mounts a path of your choice as the `/tmp` directory in the container. + """) + final String temp + + @ConfigOption + @Description(""" + Allocates a pseudo-tty (default: `false`). + """) + final boolean tty + + @ConfigOption + @Description(""" + When `false`, mount input directories as read-only (default: `true`). + """) + final boolean writableInputMounts + + /* required by extension point -- do not remove */ + AppleContainerConfig() {} + + AppleContainerConfig(Map opts) { + enabled = opts.enabled as boolean + engineOptions = opts.engineOptions + envWhitelist = ContainerHelper.parseEnvWhitelist(opts.envWhitelist) + kill = opts.kill != null ? opts.kill : true + registry = opts.registry + remove = opts.remove != null ? opts.remove as boolean : true + runOptions = opts.runOptions + temp = opts.temp + tty = opts.tty as boolean + writableInputMounts = opts.writableInputMounts != null ? opts.writableInputMounts as boolean : true + } + + @Override + String getEngine() { + return 'apple-container' + } + + @Override + boolean canRunOciImage() { + return true + } +} diff --git a/modules/nextflow/src/main/groovy/nextflow/container/ContainerBuilder.groovy b/modules/nextflow/src/main/groovy/nextflow/container/ContainerBuilder.groovy index e926d3cc85..bf8ab790f1 100644 --- a/modules/nextflow/src/main/groovy/nextflow/container/ContainerBuilder.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/container/ContainerBuilder.groovy @@ -49,6 +49,8 @@ abstract class ContainerBuilder { return new ShifterBuilder(containerImage, config) if( config instanceof CharliecloudConfig ) return new CharliecloudBuilder(containerImage, config) + if( config instanceof AppleContainerConfig ) + return new AppleContainerBuilder(containerImage, config) // throw new IllegalArgumentException("Unknown container engine: $config.engine") } diff --git a/modules/nextflow/src/test/groovy/nextflow/container/AppleContainerBuilderTest.groovy b/modules/nextflow/src/test/groovy/nextflow/container/AppleContainerBuilderTest.groovy new file mode 100644 index 0000000000..0c2377ef0b --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/container/AppleContainerBuilderTest.groovy @@ -0,0 +1,148 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package nextflow.container + +import java.nio.file.Paths + +import nextflow.util.MemoryUnit +import spock.lang.Specification + +class AppleContainerBuilderTest extends Specification { + + def 'should build the basic run command'() { + expect: + new AppleContainerBuilder('alpine') + .build() + .runCommand == 'container run -i -v "$NXF_TASK_WORKDIR":"$NXF_TASK_WORKDIR" -w "$NXF_TASK_WORKDIR" alpine' + } + + def 'should include env vars with -e'() { + expect: + new AppleContainerBuilder('alpine') + .addEnv([FOO: 1, BAR: 'hello world']) + .build() + .runCommand == 'container run -i -e "FOO=1" -e "BAR=hello world" -v "$NXF_TASK_WORKDIR":"$NXF_TASK_WORKDIR" -w "$NXF_TASK_WORKDIR" alpine' + } + + def 'should include mounts'() { + given: + def db = Paths.get('/home/db') + expect: + new AppleContainerBuilder('alpine') + .addMount(db) + .build() + .runCommand == 'container run -i -v /home/db:/home/db -v "$NXF_TASK_WORKDIR":"$NXF_TASK_WORKDIR" -w "$NXF_TASK_WORKDIR" alpine' + } + + def 'should mount temp dir'() { + expect: + new AppleContainerBuilder('alpine', new AppleContainerConfig(temp: '/hola')) + .build() + .runCommand == 'container run -i -v /hola:/tmp -v "$NXF_TASK_WORKDIR":"$NXF_TASK_WORKDIR" -w "$NXF_TASK_WORKDIR" alpine' + } + + def 'should add -t when tty enabled'() { + expect: + new AppleContainerBuilder('alpine', new AppleContainerConfig(tty: true)) + .build() + .runCommand == 'container run -i -t -v "$NXF_TASK_WORKDIR":"$NXF_TASK_WORKDIR" -w "$NXF_TASK_WORKDIR" alpine' + } + + def 'should pass cpus as --cpus'() { + expect: + new AppleContainerBuilder('alpine') + .setCpus(4) + .build() + .runCommand == 'container run -i --cpus 4 -v "$NXF_TASK_WORKDIR":"$NXF_TASK_WORKDIR" -w "$NXF_TASK_WORKDIR" alpine' + } + + def 'should pass memory as -m'() { + expect: + new AppleContainerBuilder('alpine') + .setMemory('2G') + .build() + .runCommand == 'container run -i -m 2G -v "$NXF_TASK_WORKDIR":"$NXF_TASK_WORKDIR" -w "$NXF_TASK_WORKDIR" alpine' + + new AppleContainerBuilder('alpine') + .setMemory(new MemoryUnit('100M')) + .build() + .runCommand == 'container run -i -m 100m -v "$NXF_TASK_WORKDIR":"$NXF_TASK_WORKDIR" -w "$NXF_TASK_WORKDIR" alpine' + } + + def 'should pass platform as --platform'() { + expect: + new AppleContainerBuilder('alpine') + .setPlatform('linux/arm64') + .build() + .runCommand == 'container run -i --platform linux/arm64 -v "$NXF_TASK_WORKDIR":"$NXF_TASK_WORKDIR" -w "$NXF_TASK_WORKDIR" alpine' + } + + def 'should emit entrypoint via params'() { + expect: + new AppleContainerBuilder('alpine') + .params(entry: '/bin/bash') + .build() + .runCommand == 'container run -i -v "$NXF_TASK_WORKDIR":"$NXF_TASK_WORKDIR" -w "$NXF_TASK_WORKDIR" --entrypoint /bin/bash alpine' + } + + def 'should emit cap-add via params'() { + expect: + new AppleContainerBuilder('alpine') + .params(capAdd: 'SYS_ADMIN') + .build() + .runCommand == 'container run -i -v "$NXF_TASK_WORKDIR":"$NXF_TASK_WORKDIR" -w "$NXF_TASK_WORKDIR" --cap-add SYS_ADMIN alpine' + } + + def 'should set name and produce remove/kill commands'() { + when: + def b = new AppleContainerBuilder('alpine').setName('c1').build() + then: + b.runCommand == 'container run -i -v "$NXF_TASK_WORKDIR":"$NXF_TASK_WORKDIR" -w "$NXF_TASK_WORKDIR" --name c1 alpine' + b.removeCommand == 'container rm c1' + b.killCommand == 'container stop c1' + } + + def 'should disable remove and kill when configured'() { + when: + def config = new AppleContainerConfig(remove: false) + def b = new AppleContainerBuilder('alpine', config).setName('c2').params(kill: false).build() + then: + b.removeCommand == null + b.killCommand == null + } + + def 'should emit kill with signal string'() { + when: + def b = new AppleContainerBuilder('alpine').setName('c3').params(kill: 'SIGKILL').build() + then: + b.killCommand == 'container kill -s SIGKILL c3' + } + + def 'should append runOptions and engineOptions'() { + expect: + new AppleContainerBuilder('alpine', new AppleContainerConfig(runOptions: '--ssh', engineOptions: '--debug')) + .build() + .runCommand == 'container --debug run -i -v "$NXF_TASK_WORKDIR":"$NXF_TASK_WORKDIR" -w "$NXF_TASK_WORKDIR" --ssh alpine' + } + + def 'should append launcher after run command'() { + when: + def cli = new AppleContainerBuilder('alpine').build().getRunCommand('bwa --this file.fa') + then: + cli == 'container run -i -v "$NXF_TASK_WORKDIR":"$NXF_TASK_WORKDIR" -w "$NXF_TASK_WORKDIR" alpine bwa --this file.fa' + } +} diff --git a/modules/nextflow/src/test/groovy/nextflow/container/ContainerBuilderTest.groovy b/modules/nextflow/src/test/groovy/nextflow/container/ContainerBuilderTest.groovy index fce31d5623..5468d902c1 100644 --- a/modules/nextflow/src/test/groovy/nextflow/container/ContainerBuilderTest.groovy +++ b/modules/nextflow/src/test/groovy/nextflow/container/ContainerBuilderTest.groovy @@ -85,6 +85,7 @@ class ContainerBuilderTest extends Specification { new SarusConfig() | SarusBuilder new ShifterConfig() | ShifterBuilder new CharliecloudConfig() | CharliecloudBuilder + new AppleContainerConfig() | AppleContainerBuilder } From 8abafd23c2c9f426b97f0b80672e6709723b6de2 Mon Sep 17 00:00:00 2001 From: Paolo Di Tommaso Date: Fri, 24 Apr 2026 21:53:59 +0200 Subject: [PATCH 2/4] Register AppleContainerConfig as config scope extension Signed-off-by: Paolo Di Tommaso --- modules/nextflow/src/main/resources/META-INF/extensions.idx | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/nextflow/src/main/resources/META-INF/extensions.idx b/modules/nextflow/src/main/resources/META-INF/extensions.idx index 7a5a21cc99..b5e05cba73 100644 --- a/modules/nextflow/src/main/resources/META-INF/extensions.idx +++ b/modules/nextflow/src/main/resources/META-INF/extensions.idx @@ -20,6 +20,7 @@ nextflow.config.ConfigMap nextflow.config.Manifest nextflow.config.RegistryConfig nextflow.config.WorkflowConfig +nextflow.container.AppleContainerConfig nextflow.container.ApptainerConfig nextflow.container.CharliecloudConfig nextflow.container.DockerConfig From f4eb3ef66c86b2d75a5b38fdf99477212d9f4772 Mon Sep 17 00:00:00 2001 From: Paolo Di Tommaso Date: Fri, 24 Apr 2026 21:59:33 +0200 Subject: [PATCH 3/4] Document Apple container engine Signed-off-by: Paolo Di Tommaso --- docs/container.md | 82 ++++++++++++++++++++++++++++++++++++++++ docs/reference/config.md | 38 +++++++++++++++++++ 2 files changed, 120 insertions(+) diff --git a/docs/container.md b/docs/container.md index 58973e6a23..aa6833497c 100644 --- a/docs/container.md +++ b/docs/container.md @@ -8,6 +8,88 @@ Nextflow supports a variety of container runtimes. Containerization allows you t When creating a container image to use with Nextflow, make sure that Bash (3.x or later) and `ps` are installed in the image, along with other tools required for collecting metrics (See {ref}`this section `). Bash should be available on the path `/bin/bash` and it should be the container entrypoint. ::: +(container-apple)= + +## Apple container + +[Apple container](https://github.com/apple/container) is a lightweight container runtime that runs each container inside its own virtual machine on macOS, using the [Virtualization framework](https://developer.apple.com/documentation/virtualization). + +### Prerequisites + +You will need [Apple container](https://github.com/apple/container) installed on your macOS system. It requires Apple silicon (M1 or newer) and a recent macOS release. + +Start the system service before running your pipeline: + +```bash +container system start +``` + +### How it works + +You won't need to modify your Nextflow script in order to run it with Apple container. Simply enable it in the Nextflow configuration file: + +```groovy +process.container = 'nextflow/examples:latest' +appleContainer.enabled = true +``` + +Every time your script launches a process execution, Nextflow will run it inside a container created from the specified image. In practice Nextflow will automatically wrap your processes and run them by executing the `container run` command with the image you have provided. + +:::{note} +Since each container runs in its own lightweight VM, tasks benefit from strong isolation without the overhead of a full VM per task. Images are standard OCI images and can be pulled from any registry. +::: + +:::{note} +Apple container only supports Linux images. When running an `amd64` image on Apple silicon, pass `--rosetta` via `appleContainer.runOptions` and set the platform explicitly, e.g. `process.arch = 'linux/amd64'`. +::: + +### Multiple containers + +It is possible to specify a different image for each process definition in your pipeline script. Suppose you have two processes named `hello` and `bye`. You can specify two different images for them in the Nextflow script as shown below: + +```nextflow +process hello { + container 'image_name_1' + + script: + """ + do this + """ +} + +process bye { + container 'image_name_2' + + script: + """ + do that + """ +} +``` + +Alternatively, the same container definitions can be provided by using the configuration file as shown below: + +```groovy +process { + withName:hello { + container = 'image_name_1' + } + withName:bye { + container = 'image_name_2' + } +} + +appleContainer { + enabled = true +} +``` + +Read the {ref}`Process scope ` section to learn more about processes configuration. + +### Advanced settings + +Apple container advanced configuration settings are described in {ref}`config-apple-container` section in the Nextflow configuration page. + (container-apptainer)= ## Apptainer diff --git a/docs/reference/config.md b/docs/reference/config.md index 6519a7764d..893f024174 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -34,6 +34,44 @@ The following settings are available: `workDir` : The pipeline work directory. Equivalent to the `-work-dir` option of the `run` command. +(config-apple-container)= + +## `appleContainer` + +The `appleContainer` scope controls how [Apple container](https://github.com/apple/container) is used by Nextflow. + +The following settings are available: + +`appleContainer.enabled` +: Execute tasks with Apple container (default: `false`). + +`appleContainer.engineOptions` +: Specify additional options supported by the `container` CLI i.e. `container [OPTIONS] run`. + +`appleContainer.envWhitelist` +: Comma separated list of environment variable names to be included in the container environment. + +`appleContainer.kill` +: Set the signal used to stop a running container (default: `true`, which sends `SIGTERM` via `container stop`). Set to a signal name (e.g. `'SIGKILL'`) to use `container kill -s`, or `false` to disable. + +`appleContainer.registry` +: The registry from where container images are pulled. It should NOT include the protocol prefix i.e. `http://`. + +`appleContainer.remove` +: Clean up the container after the execution (default: `true`). + +`appleContainer.runOptions` +: Specify extra command line options supported by the `container run` command (e.g. `--rosetta`, `--ssh`). + +`appleContainer.temp` +: Mounts a path of your choice as the `/tmp` directory in the container. + +`appleContainer.tty` +: Allocate a pseudo-tty (default: `false`). + +`appleContainer.writableInputMounts` +: When `false`, mount input directories as read-only (default: `true`). + (config-apptainer)= ## `apptainer` From a49aecc9428cb824ab2e718bf9eddfa3e0a6ae48 Mon Sep 17 00:00:00 2001 From: Paolo Di Tommaso Date: Fri, 24 Apr 2026 22:37:48 +0200 Subject: [PATCH 4/4] Treat apple-container as docker-like engine in Wave resolver Signed-off-by: Paolo Di Tommaso --- .../io/seqera/wave/plugin/resolver/WaveContainerResolver.groovy | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/nf-wave/src/main/io/seqera/wave/plugin/resolver/WaveContainerResolver.groovy b/plugins/nf-wave/src/main/io/seqera/wave/plugin/resolver/WaveContainerResolver.groovy index 28df47dde3..cb8df9b772 100644 --- a/plugins/nf-wave/src/main/io/seqera/wave/plugin/resolver/WaveContainerResolver.groovy +++ b/plugins/nf-wave/src/main/io/seqera/wave/plugin/resolver/WaveContainerResolver.groovy @@ -45,7 +45,7 @@ import nextflow.util.SysHelper class WaveContainerResolver implements ContainerResolver { private DefaultContainerResolver defaultResolver = new DefaultContainerResolver() - static final private List DOCKER_LIKE = ['docker','podman','sarus'] + static final private List DOCKER_LIKE = ['docker','podman','sarus','apple-container'] static final private List SINGULARITY_LIKE = ['singularity','apptainer'] static final private String DOCKER_PREFIX = 'docker://' private WaveClient client0