Repository: avocado-framework/avocado

Issue:
Update PodmanSpawner to the new deployment model
After #4932, we need to update the PodmanSpawner to get the benefits from this new API, being able to deploy Avocado from eggs instead of the current standalone model (nrunner.py)


Discussion:
I guess I am not fully aware of the type of deployment you mean here and the particularities of its implementation but want to add my 2 cents here. For instance, looking at this from the perspective of the LXC spawner but also in general I am not sure if we should always go in direction of packaging. Some systems could be very different or even impossible to do package management on and simply deploying currently used (and potentially modified) code like a standalone `nrunner.py` generalizes best over various conditions within the container environment. But I might be misunderstanding what you mean here.
Hi @pevogam,

We are assuming that the only requirement will be Python and we are planning to use eggs for that. Where we can even run it directly from them, without installing.

I tried to create an epic issue (#4934), with all the related issues that we are working on this problem. Let me know if that makes for you and let us know about your concerns.

Resolving patch:
diff --git a/avocado/plugins/spawners/podman.py b/avocado/plugins/spawners/podman.py
--- a/avocado/plugins/spawners/podman.py
+++ b/avocado/plugins/spawners/podman.py
@@ -7,7 +7,9 @@
 from avocado.core.plugin_interfaces import CLI, DeploymentSpawner, Init
 from avocado.core.settings import settings
 from avocado.core.spawners.common import SpawnerMixin, SpawnMethod
+from avocado.core.version import VERSION
 from avocado.utils import distro
+from avocado.utils.asset import Asset
 from avocado.utils.podman import Podman, PodmanException
 
 LOG = logging.getLogger(__name__)
@@ -79,6 +81,8 @@ class PodmanSpawner(DeploymentSpawner, SpawnerMixin):
     description = 'Podman (container) based spawner'
     METHODS = [SpawnMethod.STANDALONE_EXECUTABLE]
 
+    _PYTHON_VERSIONS_CACHE = {}
+
     def is_task_alive(self, runtime_task):
         if runtime_task.spawner_handle is None:
             return False
@@ -93,25 +97,53 @@ def is_task_alive(self, runtime_task):
         # FIXME: check how podman 2.x is reporting valid "OK" states
         return out.startswith(b'Up ')
 
+    def _fetch_asset(self, url):
+        cachedirs = self.config.get('datadir.paths.cache_dirs')
+        asset = Asset(url, cache_dirs=cachedirs)
+        return asset.fetch()
+
+    def get_eggs_paths(self, py_major, py_minor):
+        """Return the basic eggs needed to bootstrap Avocado.
+
+        This will return a tuple with the current location and where this
+        should be deployed.
+        """
+        result = []
+        # Setuptools
+        # For now let's pin to setuptools 59.2.
+        # TODO: Automatically get latest setuptools version.
+        eggs = [f"https://github.com/avocado-framework/setuptools/releases/download/v59.2.0/setuptools-59.2.0-py{py_major}.{py_minor}.egg",
+                f"https://github.com/avocado-framework/avocado/releases/download/{VERSION}/avocado_framework-{VERSION}-py{py_major}.{py_minor}.egg"]
+        for url in eggs:
+            path = self._fetch_asset(url)
+            to = os.path.join('/tmp/', os.path.basename(path))
+            result.append((path, to))
+        return result
+
+    @property
+    async def python_version(self):
+        image = self.config.get('spawner.podman.image')
+        if image not in self._PYTHON_VERSIONS_CACHE:
+            if not self.podman:
+                msg = "Cannot get Python version: self.podman not defined."
+                LOG.debug(msg)
+                return None, None, None
+            result = await self.podman.get_python_version(image)
+            self._PYTHON_VERSIONS_CACHE[image] = result
+        return self._PYTHON_VERSIONS_CACHE[image]
+
     async def deploy_artifacts(self):
         pass
 
     async def deploy_avocado(self, where):
-        # Currently limited to avocado-runner, we'll expand on that
-        # when the runner requirements system is in place
-        this_path = os.path.abspath(__file__)
-        base_path = os.path.dirname(os.path.dirname(os.path.dirname(this_path)))
-        avocado_runner_path = os.path.join(base_path, 'core', 'nrunner.py')
-        try:
-            # pylint: disable=W0201
-            await self.podman.execute("cp",
-                                      avocado_runner_path,
-                                      "%s:%s" % (where,
-                                                 ENTRY_POINT_CMD))
-        except PodmanException:
-            return False
+        # Deploy all the eggs to container inside /tmp/
+        major, minor, _ = await self.python_version
+        eggs = self.get_eggs_paths(major, minor)
 
-    async def _create_container_for_task(self, runtime_task):
+        for egg, to in eggs:
+            await self.podman.copy_to_container(where, egg, to)
+
+    async def _create_container_for_task(self, runtime_task, env_args):
         mount_status_server_socket = False
         mounted_status_server_socket = '/tmp/.status_server.sock'
         status_server_uri = runtime_task.task.status_services[0].uri
@@ -120,8 +152,13 @@ async def _create_container_for_task(self, runtime_task):
             mount_status_server_socket = True
             runtime_task.task.status_services[0].uri = mounted_status_server_socket
 
+        _, _, python_binary = await self.python_version
+        entry_point_args = [python_binary,
+                            '-m',
+                            'avocado.core.nrunner',
+                            'task-run']
+
         task = runtime_task.task
-        entry_point_args = [ENTRY_POINT_CMD, "task-run"]
         entry_point_args.extend(task.get_command_args())
         entry_point = json.dumps(entry_point_args)
         entry_point_arg = "--entrypoint=" + entry_point
@@ -137,11 +174,14 @@ async def _create_container_for_task(self, runtime_task):
 
         image = self.config.get('spawner.podman.image')
 
+        envs = [f"-e={k}={v}" for k, v in env_args.items()]
         try:
             # pylint: disable=W0201
             _, stdout, _ = await self.podman.execute("create",
                                                      *status_server_opts,
-                                                     entry_point_arg, image)
+                                                     entry_point_arg,
+                                                     *envs,
+                                                     image)
         except PodmanException as ex:
             msg = f"Could not create podman container: {ex}"
             runtime_task.status = msg
@@ -159,7 +199,13 @@ async def spawn_task(self, runtime_task):
             runtime_task.status = str(ex)
             return False
 
-        container_id = await self._create_container_for_task(runtime_task)
+        major, minor, _ = await self.python_version
+        # Return only the "to" location
+        eggs = self.get_eggs_paths(major, minor)
+        destination_eggs = ":".join(map(lambda egg: str(egg[1]), eggs))
+        env_args = {'PYTHONPATH': destination_eggs}
+        container_id = await self._create_container_for_task(runtime_task,
+                                                             env_args)
 
         runtime_task.spawner_handle = container_id
 
diff --git a/avocado/utils/podman.py b/avocado/utils/podman.py
--- a/avocado/utils/podman.py
+++ b/avocado/utils/podman.py
@@ -49,11 +49,15 @@ async def execute(self, *args):
         :rtype: tuple with returncode, stdout and stderr.
         """
         try:
+            LOG.debug("Executing %s", args[0])
             proc = await create_subprocess_exec(self.podman_bin,
                                                 *args,
                                                 stdout=subprocess.PIPE,
                                                 stderr=subprocess.PIPE)
             stdout, stderr = await proc.communicate()
+            LOG.debug("Return code: %s", proc.returncode)
+            LOG.debug("Stdout: %s", stdout.decode("utf-8", "replace"))
+            LOG.debug("Stderr: %s", stderr.decode("utf-8", "replace"))
         except (FileNotFoundError, PermissionError) as ex:
             # Since this method is also used by other methods, let's
             # log here as well.
@@ -68,6 +72,24 @@ async def execute(self, *args):
 
         return proc.returncode, stdout, stderr
 
+    async def copy_to_container(self, container_id, src, dst):
+        """Copy artifacts from src to container:dst.
+
+        This method allows copying the contents of src to the dst. Files will
+        be copied from the local machine to the container. The "src" argument
+        can be a file or a directory.
+
+        :param str container_id: string with the container identification.
+        :param str src: what file or directory you are trying to copy.
+        :param str dst: the destination inside the container.
+        :rtype: tuple with returncode, stdout and stderr.
+        """
+        try:
+            return await self.execute("cp", src, f"{container_id}:{dst}")
+        except PodmanException as ex:
+            error = f"Failed copying data to container {container_id}"
+            raise PodmanException(error) from ex
+
     async def get_python_version(self, image):
         """Return the current Python version installed in an image.
 


Test patch: