Repository: reframe-hpc/reframe

Issue:
Support apptainer container platform
As far as I understand from @teojgo , it should be very easy to add as we already support Singularity.

Comments:

Patch:
diff --git a/docs/config_reference.rst b/docs/config_reference.rst
index 356a97ce..d03e882f 100644
--- a/docs/config_reference.rst
+++ b/docs/config_reference.rst
@@ -467,6 +467,7 @@ ReFrame can launch containerized applications, but you need to configure properl
    The type of the container platform.
    Available values are the following:
 
+   - ``Apptainer``: The `Apptainer <https://apptainer.org/>`__ container runtime.
    - ``Docker``: The `Docker <https://www.docker.com/>`__ container runtime.
    - ``Sarus``: The `Sarus <https://sarus.readthedocs.io/>`__ container runtime.
    - ``Shifter``: The `Shifter <https://github.com/NERSC/shifter>`__ container runtime.
diff --git a/reframe/core/containers.py b/reframe/core/containers.py
index 15be3766..6182b3d8 100644
--- a/reframe/core/containers.py
+++ b/reframe/core/containers.py
@@ -243,6 +243,7 @@ class Singularity(ContainerPlatform):
     def __init__(self):
         super().__init__()
         self.with_cuda = False
+        self._launch_command = 'singularity'
 
     def emit_prepare_commands(self, stagedir):
         return []
@@ -259,10 +260,23 @@ class Singularity(ContainerPlatform):
 
         run_opts += self.options
         if self.command:
-            return (f'singularity exec {" ".join(run_opts)} '
+            return (f'{self._launch_command} exec {" ".join(run_opts)} '
                     f'{self.image} {self.command}')
 
-        return f'singularity run {" ".join(run_opts)} {self.image}'
+        return f'{self._launch_command} run {" ".join(run_opts)} {self.image}'
+
+
+class Apptainer(Singularity):
+    '''Container platform backend for running containers with `Apptainer
+    <https://apptainer.org/>`__.
+
+    .. versionadded:: 4.0.0
+
+    '''
+
+    def __init__(self):
+        super().__init__()
+        self._launch_command = 'apptainer'
 
 
 class ContainerPlatformField(fields.TypedField):


Test patch:
diff --git a/unittests/test_containers.py b/unittests/test_containers.py
index ea713465..b4d30724 100644
--- a/unittests/test_containers.py
+++ b/unittests/test_containers.py
@@ -13,7 +13,8 @@ import reframe.core.containers as containers
     'Sarus', 'Sarus+nocommand', 'Sarus+nopull', 'Sarus+mpi', 'Sarus+load',
     'Shifter', 'Shifter+nocommand', 'Shifter+mpi', 'Shifter+nopull',
     'Shifter+load',
-    'Singularity', 'Singularity+nocommand', 'Singularity+cuda'
+    'Singularity', 'Singularity+nocommand', 'Singularity+cuda',
+    'Apptainer', 'Apptainer+nocommand', 'Apptainer+cuda'
 ])
 def container_variant(request):
     return request.param
@@ -101,7 +102,7 @@ def expected_cmd_mount_points(container_variant):
                 '--mount=type=bind,source="/path/two",destination="/two" '
                 '--mount=type=bind,source="/foo",destination="/rfm_workdir" '
                 'load/library/image:tag cmd')
-    elif container_variant in {'Singularity', 'Singularity+nopull'}:
+    elif container_variant == 'Singularity':
         return ('singularity exec -B"/path/one:/one" '
                 '-B"/path/two:/two" -B"/foo:/rfm_workdir" '
                 '--pwd /rfm_workdir image:tag cmd')
@@ -113,6 +114,19 @@ def expected_cmd_mount_points(container_variant):
         return ('singularity run -B"/path/one:/one" '
                 '-B"/path/two:/two" -B"/foo:/rfm_workdir" '
                 '--pwd /rfm_workdir image:tag')
+    elif container_variant == 'Apptainer':
+        return ('apptainer exec -B"/path/one:/one" '
+                '-B"/path/two:/two" -B"/foo:/rfm_workdir" '
+                '--pwd /rfm_workdir image:tag cmd')
+    elif container_variant == 'Apptainer+cuda':
+        return ('apptainer exec -B"/path/one:/one" '
+                '-B"/path/two:/two" -B"/foo:/rfm_workdir" --nv '
+                '--pwd /rfm_workdir image:tag cmd')
+    elif container_variant == 'Apptainer+nocommand':
+        return ('apptainer run -B"/path/one:/one" '
+                '-B"/path/two:/two" -B"/foo:/rfm_workdir" '
+                '--pwd /rfm_workdir image:tag')
+
 
 
 @pytest.fixture
@@ -180,7 +194,7 @@ def expected_cmd_run_opts(container_variant):
                 '--mount=type=bind,source="/path/one",destination="/one" '
                 '--mount=type=bind,source="/foo",destination="/rfm_workdir" '
                 '--mpi --foo --bar image:tag cmd')
-    elif container_variant in {'Singularity'}:
+    elif container_variant == 'Singularity':
         return ('singularity exec -B"/path/one:/one" -B"/foo:/rfm_workdir" '
                 '--foo --bar image:tag cmd')
     elif container_variant == 'Singularity+cuda':
@@ -189,6 +203,15 @@ def expected_cmd_run_opts(container_variant):
     elif container_variant == 'Singularity+nocommand':
         return ('singularity run -B"/path/one:/one" -B"/foo:/rfm_workdir" '
                 '--foo --bar image:tag')
+    elif container_variant == 'Apptainer':
+        return ('apptainer exec -B"/path/one:/one" -B"/foo:/rfm_workdir" '
+                '--foo --bar image:tag cmd')
+    elif container_variant == 'Apptainer+cuda':
+        return ('apptainer exec -B"/path/one:/one" -B"/foo:/rfm_workdir" '
+                '--nv --foo --bar image:tag cmd')
+    elif container_variant == 'Apptainer+nocommand':
+        return ('apptainer run -B"/path/one:/one" -B"/foo:/rfm_workdir" '
+                '--foo --bar image:tag')
 
 
 def test_mount_points(container_platform, expected_cmd_mount_points):
@@ -245,6 +268,9 @@ def expected_run_with_commands(container_variant_noopt):
     elif container_variant_noopt == 'Singularity':
         return ("singularity exec -B\"/foo:/rfm_workdir\" "
                 "--foo image:tag bash -c 'cmd1; cmd2'")
+    elif container_variant_noopt == 'Apptainer':
+        return ("apptainer exec -B\"/foo:/rfm_workdir\" "
+                "--foo image:tag bash -c 'cmd1; cmd2'")
 
 
 @pytest.fixture
@@ -267,6 +293,9 @@ def expected_run_with_workdir(container_variant_noopt):
     elif container_variant_noopt == 'Singularity':
         return ('singularity exec -B\"/foo:/rfm_workdir\" --pwd foodir '
                 '--foo image:tag cmd1')
+    elif container_variant_noopt == 'Apptainer':
+        return ('apptainer exec -B\"/foo:/rfm_workdir\" --pwd foodir '
+                '--foo image:tag cmd1')
 
 
 def test_run_with_workdir(container_platform_with_opts,