Repository: NucciTheBoss/cleantest (language: python)

Issue:
Add ability to pull charm libraries for Charmhub
Another requested feature was to add the ability to pull charm libraries from charmhub rather than having to inject them via the `PYTHONPATH` option for the `StartEnv` hook. I agree with this sentiment as a bunch of extra logic is added trying to pull non-pip installed libraries from the host system.

Wondering how I could implement this, either adding charm libs as an option for `StartEnv` hooks, or creating a special package class that allows for multiple kinds of packages to be pulled in depending on the package manager. Either way, charm libraries need to be pullable from inside the testing environment.


Discussion / hints:
This might require a larger refactor of the `cleantest` code base.

Reason saying is that I will need to change the structure of the hooks to support adding "package" data structures. For example, I could have package data structures for `snaps`, `deb`, `pip`, `charm-libs`, etc that support consuming various packaging standards. For example, the pip hook could execute handlers that install pip on the system, read requirements.txt files, pull from PyPI, or more. This would require me needing to change the import structure to something like the following:

```python
from cleantest import Configure
from cleantest.hooks import ...
from cleantest.pkg import ...
```

The packaging data structures could be expanded out using plugin architecture so other users can add their own packaging standards.
1. Move `hooks` into their own separate module.
2. Create `pkg` module.
3. Add `Pip` to pkg module.
4. Add `Charmlib` to pkg module.
5. Update documentation.

Fix patch:
diff --git a/.gitignore b/.gitignore
index 24d083c..4c77119 100644
--- a/.gitignore
+++ b/.gitignore
@@ -74,3 +74,6 @@ venv.bak/
 
 # mkdocs documentation
 /site
+
+# Secrets
+*.secret
diff --git a/setup.py b/setup.py
index 3b07623..82c4658 100644
--- a/setup.py
+++ b/setup.py
@@ -7,8 +7,8 @@
 
 setup(
     name="cleantest",
-    version="0.1.0",
-    description="Clean tests for developers in a hurry.",
+    version="0.2.0",
+    description="Clean tests for developers in a hurry",
     author="Jason C. Nucciarone",
     author_email="jason.nucciarone@canonical.com",
     license="Apache-2.0",


Test patch:
diff --git a/src/cleantest/_utils/__init__.py b/src/cleantest/_utils/__init__.py
new file mode 100644
index 0000000..3dbedc6
--- /dev/null
+++ b/src/cleantest/_utils/__init__.py
@@ -0,0 +1,5 @@
+#!/usr/bin/env python3
+# Copyright 2022 Canonical Ltd.
+# See LICENSE file for licensing details.
+
+from .detect_os import detect_os_variant
diff --git a/src/cleantest/_utils/detect_os.py b/src/cleantest/_utils/detect_os.py
new file mode 100644
index 0000000..9186221
--- /dev/null
+++ b/src/cleantest/_utils/detect_os.py
@@ -0,0 +1,31 @@
+#!/usr/bin/env python3
+# Copyright 2022 Canonical Ltd.
+# See LICENSE file for licensing details.
+
+"""Detect operating system of test environment."""
+
+import platform
+
+
+class UnknownOSError(Exception):
+    ...
+
+
+def detect_os_variant() -> str:
+    try:
+        dispatch = {
+            "linux": _determine_linux,
+            "darwin": lambda: "darwin",
+            "windows": lambda: "windows",
+            "java": lambda: "java",
+        }
+        return dispatch[platform.system().lower()]()
+    except KeyError:
+        raise UnknownOSError("Could not determine base operating system.")
+
+
+def _determine_linux() -> str:
+    os_release_data = [l.strip() for l in open("/etc/os-release", "rt")]
+    for l in os_release_data:
+        if l.startswith("ID="):
+            return l.split("=")[-1].lower()
diff --git a/src/cleantest/control/configurator.py b/src/cleantest/control/configurator.py
index f1d110a..6f3f02a 100644
--- a/src/cleantest/control/configurator.py
+++ b/src/cleantest/control/configurator.py
@@ -25,7 +25,7 @@ class HookRegistry(BaseModel):
     stop_testlet: Deque[StopTestletHook] = deque()
 
 
-class Configure(object):
+class Configure:
 
     __hook_registry = HookRegistry()
     __metadata = set()
diff --git a/src/cleantest/hooks/startenv.py b/src/cleantest/hooks/startenv.py
index 5beb9f3..c124127 100644
--- a/src/cleantest/hooks/startenv.py
+++ b/src/cleantest/hooks/startenv.py
@@ -13,7 +13,4 @@
 
 class StartEnvHook(BaseModel):
     name: str = "default"
-    packages: List[str] | None = None
-    requirements: str | List[str] | None = None
-    constraints: str | List[str] | None = None
-    python_path: List[str] | None = None
+    packages: List[object] = []
diff --git a/src/cleantest/pkg/__init__.py b/src/cleantest/pkg/__init__.py
index 2fef9b8..7463d93 100644
--- a/src/cleantest/pkg/__init__.py
+++ b/src/cleantest/pkg/__init__.py
@@ -2,5 +2,5 @@
 # Copyright 2022 Canonical Ltd.
 # See LICENSE file for licensing details.
 
-from .charmlib import CharmLibManager
-from .pip import PipManager
+from .charmlib import Charmlib
+from .pip import Pip
diff --git a/src/cleantest/pkg/_base.py b/src/cleantest/pkg/_base.py
new file mode 100644
index 0000000..6790b37
--- /dev/null
+++ b/src/cleantest/pkg/_base.py
@@ -0,0 +1,50 @@
+#!/usr/bin/env python3
+# Copyright 2022 Canonical Ltd.
+# See LICENSE file for licensing details.
+
+"""Base package class for installing packages inside remote processes."""
+
+from __future__ import annotations
+
+import hashlib
+import os
+import pathlib
+import pickle
+import tempfile
+import uuid
+from abc import ABC, abstractmethod
+from typing import Dict
+
+
+class PackageError(Exception):
+    ...
+
+
+class Package(ABC):
+    @abstractmethod
+    def _run(self) -> None:
+        ...
+
+    @abstractmethod
+    def _setup(self) -> None:
+        ...
+
+    @classmethod
+    def _load(cls, _manager: str, hash: str) -> object:
+        if type(_manager) == str and os.path.isfile(_manager):
+            fin = pathlib.Path(_manager)
+            if hash != hashlib.sha224(fin.read_bytes()).hexdigest():
+                raise PackageError("SHA224 hashes do not match. Will not load untrusted object.")
+
+            return cls(_manager=pickle.loads(fin.read_bytes()))
+        else:
+            raise PackageError(f"Invalid type {type(_manager)} received. Type must be bytes.")
+
+    def _dump(self) -> Dict[str, str]:
+        """Return a path to a pickled object and hash for verification."""
+        filepath = os.path.join(tempfile.gettempdir(), f"{uuid.uuid4()}.pkl")
+        data = pickle.dumps(self)
+        hash = hashlib.sha224(data).hexdigest()
+        fout = pathlib.Path(filepath)
+        fout.write_bytes(pickle.dumps(self))
+        return {"path": filepath, "hash": hash}
diff --git a/src/cleantest/pkg/_manager.py b/src/cleantest/pkg/_manager.py
deleted file mode 100644
index c7aa516..0000000
--- a/src/cleantest/pkg/_manager.py
+++ /dev/null
@@ -1,14 +0,0 @@
-#!/usr/bin/env python3
-# Copyright 2022 Canonical Ltd.
-# See LICENSE file for licensing details.
-
-"""Manage package classes sent to test environment provider."""
-
-import inspect
-
-
-class Pkg:
-    @staticmethod
-    def construct() -> None:
-        # TODO: Construct file that can be sent to remote process
-        ...
diff --git a/src/cleantest/pkg/charmlib.py b/src/cleantest/pkg/charmlib.py
index 7d74674..c06926a 100644
--- a/src/cleantest/pkg/charmlib.py
+++ b/src/cleantest/pkg/charmlib.py
@@ -6,55 +6,99 @@
 
 from __future__ import annotations
 
-import hashlib
-import pathlib
-import pickle
-import uuid
+import json
+import subprocess
 from shutil import which
-from typing import List, Tuple
+from typing import List
 
+from cleantest._utils import detect_os_variant
+from cleantest.pkg._base import Package, PackageError
 
-class CharmLibManagerError(Exception):
-    ...
 
+class Charmlib(Package):
+    def __init__(
+        self,
+        auth_token_path: str = None,
+        charmlibs: str | List[str] = None,
+        _manager: object | None = None,
+    ) -> None:
+        if _manager is None:
+            if auth_token_path is not None:
+                self._auth_token = open(auth_token_path, "rt").read()
+            else:
+                raise PackageError(
+                    (
+                        "No file path to authentication token passed. ",
+                        "Cannot authenticate with Charmhub.",
+                    )
+                )
 
-# NOTE: After class has been constructed, serialize and wormhole over to remote agent.
-class CharmLibManager:
-    def __init__(self, charmlibs: str | List[str]) -> None:
-        raise NotImplementedError("CharmLibManager not available until cleantest-0.2.0")
-        self.__charmlib_store = set()
-        if type(charmlibs) == str:
-            self.__charmlib_store.add(charmlibs)
-        elif type(charmlibs) == list:
-            for lib in charmlibs:
-                self.__charmlib_store.add(lib)
+            self._charmlib_store = set()
+            if type(charmlibs) == str:
+                self._charmlib_store.add(charmlibs)
+            elif type(charmlibs) == list:
+                for lib in charmlibs:
+                    self._charmlib_store.add(lib)
+            else:
+                raise PackageError(
+                    f"{type(charmlibs)} is invalid. charmlibs must either be str or List[str]."
+                )
         else:
-            raise CharmLibManagerError(
-                f"{type(charmlibs)} is invalid. charmlibs must either be str or List[str]."
-            )
+            self._auth_token = _manager._auth_token
+            self._charmlib_store = _manager._charmlib_store
+
+    def _run(self) -> None:
+        self._setup()
+        self.__handle_charm_lib_install()
+        print(json.dumps({"PYTHONPATH": "/root/lib"}))
 
     def _setup(self) -> None:
-        # TODO: Check if charmcraft is installed. If not, install it.
-        # Requires snap and snapd to be available on the host system.
-        ...
-
-    def _run(self, provider: str) -> None:
-        # TODO: Invoke __handle_charm_lib_install.
-        ...
-
-    def _dump(self) -> Tuple[str, str]:
-        """Return a path to a pickled object and hash for verification."""
-        filepath = f"/tmp/{uuid.uuid4()}.dat"
-        data = pickle.dumps(self)
-        hash = hashlib.sha224(data).hexdigest()
-        fout = pathlib.Path(filepath)
-        fout.write_bytes(pickle.dumps(self))
-        return filepath, hash
-
-    def _verify(self, hash: str) -> bool:
-        # TODO: Validate hash of sent charmlib manager
-        ...
+        os_variant = detect_os_variant()
+
+        if which("snap") is None:
+            if os_variant == "ubuntu":
+                cmd = ["apt", "install", "-y", "snapd"]
+                try:
+                    subprocess.run(
+                        cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True
+                    )
+                except subprocess.CalledProcessError:
+                    raise PackageError(
+                        f"Failed to install snapd using the following command: {' '.join(cmd)}."
+                    )
+            else:
+                raise NotImplementedError(
+                    f"Support for {os_variant.capitalize()} not available yet."
+                )
+
+        if which("charmcraft") is None:
+            cmd = ["snap", "install", "charmcraft", "--classic"]
+            try:
+                subprocess.run(
+                    cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True
+                )
+            except subprocess.CalledProcessError:
+                raise PackageError(
+                    f"Failed to install charmcraft using the following command: {' '.join(cmd)}"
+                )
 
     def __handle_charm_lib_install(self) -> None:
-        # TODO: Handle placing charm libraries on site-packages.
-        ...
+        env = {"CHARMCRAFT_AUTH": self._auth_token}
+        for charm in self._charmlib_store:
+            cmd = ["/snap/bin/charmcraft", "fetch-lib", charm]
+            try:
+                subprocess.run(
+                    cmd,
+                    stdout=subprocess.DEVNULL,
+                    stderr=subprocess.DEVNULL,
+                    check=True,
+                    env=env,
+                    cwd="/root",
+                )
+            except subprocess.CalledProcessError:
+                raise PackageError(
+                    (
+                        f"Failed to install charm library {charm} "
+                        f"using the following command: {' '.join(cmd)}"
+                    )
+                )
diff --git a/src/cleantest/pkg/pip.py b/src/cleantest/pkg/pip.py
index 7bb8c34..b51b245 100644
--- a/src/cleantest/pkg/pip.py
+++ b/src/cleantest/pkg/pip.py
@@ -4,7 +4,163 @@
 
 """Manager for installing pip packages inside remote processes."""
 
+from __future__ import annotations
 
-class PipManager:
-    def __init__(self) -> None:
-        raise NotImplementedError("PipManager not availbe until cleantest-0.2.0")
+import pathlib
+import subprocess
+from shutil import which
+from typing import List
+
+from cleantest._utils import detect_os_variant
+from cleantest.pkg._base import Package, PackageError
+
+
+class Pip(Package):
+    def __init__(
+        self,
+        packages: str | List[str] = None,
+        requirements: str | List[str] = None,
+        constraints: str | List[str] = None,
+        _manager: object | None = None,
+    ) -> None:
+        if _manager is None:
+            self.__lint_inputs(packages, requirements, constraints)
+
+            self._package_store = set()
+            if type(packages) == str:
+                self._package_store.add(packages)
+            elif type(packages) == list:
+                [self._package_store.add(p) for p in packages]
+
+            self._requirements_store = []
+            if type(requirements) == str:
+                self._requirements_store.append(requirements)
+            elif type(requirements) == list:
+                self._requirements_store.extend(requirements)
+
+            self._constraints_store = []
+            if type(constraints) == str:
+                self._constraints_store.append(constraints)
+            elif type(constraints) == list:
+                self._constraints_store.extend(constraints)
+
+            self.__load_file_data()
+        else:
+            self._package_store = _manager._package_store
+            self._requirements_store = _manager._requirements_store
+            self._constraints_store = _manager._constraints_store
+
+    def _run(self) -> None:
+        self._setup()
+        self.__handle_pip_install()
+
+    def _setup(self) -> None:
+        os_variant = detect_os_variant()
+
+        if which("pip") is None:
+            if os_variant == "ubuntu":
+                cmd = ["apt", "install", "-y", "python3-pip"]
+                try:
+                    subprocess.run(
+                        cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True
+                    )
+                except subprocess.CalledProcessError:
+                    raise PackageError(
+                        f"Failed to isntall pip using the following command: {' '.join(cmd)}"
+                    )
+            else:
+                raise NotImplementedError(
+                    f"Support for {os_variant.capitalize()} not available yet."
+                )
+
+    def __handle_pip_install(self) -> None:
+        for package in self._package_store:
+            cmd = ["python3", "-m", "pip", "install", package]
+            try:
+                subprocess.run(
+                    cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True
+                )
+            except subprocess.CalledProcessError:
+                raise PackageError(
+                    f"Failed to install package {package} "
+                    f"using the following command {' '.join(cmd)}"
+                )
+
+        if self._constraints_store:
+            for r, c in zip(self._requirements_store, self._constraints_store):
+                r_file = pathlib.Path(pathlib.Path.home().joinpath("requirements.txt"))
+                c_file = pathlib.Path(pathlib.Path.home().joinpath("constraints.txt"))
+                r_file.touch()
+                c_file.touch()
+                with r_file.open(mode="w") as fout:
+                    fout.writelines(r)
+                with c_file.open(mode="w") as fout:
+                    fout.writelines(c)
+                cmd = ["python3", "-m", "pip", "install", "-r", str(r_file), "-c", str(c_file)]
+                try:
+                    subprocess.run(
+                        cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True
+                    )
+                except subprocess.CalledProcessError:
+                    raise PackageError(
+                        (
+                            f"Failed to install packages listed in requirements.txt file {r} "
+                            f"with constraints.txt file {c} using the "
+                            f"following command: {' '.join(cmd)}"
+                        )
+                    )
+        else:
+            for r in self._requirements_store:
+                r_file = pathlib.Path(pathlib.Path.home().joinpath("requirements.txt"))
+                r_file.touch()
+                with r_file.open(mode="w") as fout:
+                    fout.writelines(r)
+                cmd = ["python3", "-m", "pip", "install", "-r", str(r_file)]
+                try:
+                    subprocess.run(
+                        cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True
+                    )
+                except subprocess.CalledProcessError:
+                    raise PackageError(
+                        (
+                            f"Failed to install packages listed in requirements.txt file {r} "
+                            f"using the following command: {' '.join(cmd)}"
+                        )
+                    )
+
+    def __lint_inputs(
+        self,
+        packages: str | List[str] | None,
+        requirements: str | List[str] | None,
+        constraints: str | List[str] | None,
+    ) -> None:
+        lint_rules = [
+            lambda: True if packages is None and requirements is None else False,
+            lambda: True if requirements is None and constraints is not None else False,
+            lambda: True
+            if type(requirements) == list
+            and type(constraints) == list
+            and len(requirements) != len(constraints)
+            else False,
+        ]
+        for expr in lint_rules:
+            if expr():
+                raise PackageError(
+                    "Lint rule failed. ",
+                    f"Ensure passed arguments to {self.__class__.__name__} are correct.",
+                )
+
+    def __load_file_data(self) -> None:
+        for i in range(0, len(self._requirements_store)):
+            file = pathlib.Path(self._requirements_store[i])
+            if file.exists():
+                self._requirements_store[i] = [l.strip() for l in file.open()]
+            else:
+                raise PackageError(f"Requirements file {self._requirements_store[i]} not found.")
+
+        for i in range(0, len(self._constraints_store)):
+            file = pathlib.Path(self._constraints_store[i])
+            if file.exists():
+                self._constraints_store[i] = [l.strip() for l in file.open()]
+            else:
+                raise PackageError(f"Constraints file {self._constraints_store[i]} not found.")
diff --git a/src/cleantest/provider/_base/base.py b/src/cleantest/provider/_base/base.py
deleted file mode 100644
index 84edb20..0000000
--- a/src/cleantest/provider/_base/base.py
+++ /dev/null
@@ -1,67 +0,0 @@
-#!/usr/bin/env python3
-# Copyright 2022 Canonical Ltd.
-# See LICENSE file for licensing details.
-
-"""Abstractions and utilities for test environment providers."""
-
-from __future__ import annotations
-
-import inspect
-import re
-import tempfile
-from abc import ABC, abstractmethod
-from typing import Any, Callable, List
-
-from pydantic import BaseModel
-
-
-class Result(BaseModel):
-    exit_code: int | None = None
-    stdout: Any | None = None
-    stderr: Any | None = None
-
-
-class Provider(ABC):
-    """Abstract super-class for all test environment providers."""
-
-    @abstractmethod
-    def _execute(self) -> Any:
-        pass
-
-    @abstractmethod
-    def _process(self) -> Result:
-        pass
-
-    @abstractmethod
-    def _handle_start_env_hooks(self) -> None:
-        pass
-
-    def _construct(self, func: Callable, remove: List[re.Pattern] | None) -> str:
-        """Construct Python source file to be run in subroutine.
-
-        Args:
-            func (Callable): TODO
-            remove (List[re.Pattern]): TODO
-
-        Returns:
-            str: TODO
-
-        Future:
-            This will need more advanced logic if tests accept arguments.
-        """
-        src = inspect.getsource(func)
-        if remove is not None:
-            for pattern in remove:
-                src = re.sub(pattern, "", src)
-
-        with tempfile.TemporaryFile(mode="w+t") as f:
-            content = [
-                "#!/usr/bin/env python3\n",
-                f"{src}\n",
-                f"{func.__name__}()\n",
-            ]
-            f.writelines(content)
-            f.seek(0)
-            scriptlet = f.read()
-
-        return scriptlet
diff --git a/src/cleantest/provider/_base/__init__.py b/src/cleantest/provider/_handler/__init__.py
similarity index 73%
rename from src/cleantest/provider/_base/__init__.py
rename to src/cleantest/provider/_handler/__init__.py
index a3cbacc..9250f8d 100644
--- a/src/cleantest/provider/_base/__init__.py
+++ b/src/cleantest/provider/_handler/__init__.py
@@ -2,4 +2,4 @@
 # Copyright 2022 Canonical Ltd.
 # See LICENSE file for licensing details.
 
-from .base import Provider, Result
+from .lxd_handler import LXDHandler
diff --git a/src/cleantest/provider/_handler/base_handler.py b/src/cleantest/provider/_handler/base_handler.py
new file mode 100644
index 0000000..d0fbdde
--- /dev/null
+++ b/src/cleantest/provider/_handler/base_handler.py
@@ -0,0 +1,95 @@
+#!/usr/bin/env python3
+# Copyright 2022 Canonical Ltd.
+# See LICENSE file for licensing details.
+
+"""Abstractions and utilities for test environment providers."""
+
+from __future__ import annotations
+
+import inspect
+import re
+import tempfile
+from abc import ABC, abstractmethod
+from typing import Any, Callable, List
+
+from pydantic import BaseModel
+
+
+class HandlerError(Exception):
+    ...
+
+
+class Result(BaseModel):
+    exit_code: int | None = None
+    stdout: Any | None = None
+    stderr: Any | None = None
+
+
+class Handler(ABC):
+    """Abstract super-class for all test environment providers."""
+
+    @abstractmethod
+    def run() -> Result:
+        ...
+
+    @abstractmethod
+    def _execute(self) -> Any:
+        ...
+
+    @abstractmethod
+    def _process() -> Result:
+        ...
+
+    @abstractmethod
+    def _handle_start_env_hooks(self) -> None:
+        ...
+
+    def _construct_testlet(self, func: Callable, remove: List[re.Pattern] | None) -> str:
+        """Construct Python source file to be run in subroutine.
+
+        Args:
+            func (Callable): TODO
+            remove (List[re.Pattern]): TODO
+
+        Returns:
+            str: TODO
+
+        Future:
+            This will need more advanced logic if tests accept arguments.
+        """
+        try:
+            src = inspect.getsource(func)
+            if remove is not None:
+                for pattern in remove:
+                    src = re.sub(pattern, "", src)
+
+            with tempfile.TemporaryFile(mode="w+t") as f:
+                content = [
+                    "#!/usr/bin/env python3\n",
+                    f"{src}\n",
+                    f"{func.__name__}()\n",
+                ]
+                f.writelines(content)
+                f.seek(0)
+                scriptlet = f.read()
+
+            return scriptlet
+        except OSError:
+            raise HandlerError(f"Could not locate source code for testlet {func.__name__}.")
+
+    def _construct_pkg_installer(self, pkg: object, file_path: str, hash: str) -> str:
+        src = inspect.getsourcefile(pkg.__class__)
+        if src is None:
+            raise HandlerError(f"Could not get the source file of object {pkg.__class__.__name__}")
+
+        with tempfile.TemporaryFile(mode="w+t") as f:
+            content = [
+                f"{open(src, 'rt').read()}\n",
+                f"holder = {pkg.__class__.__name__}._load('{file_path}', '{hash}')\n",
+                "holder._run()\n",
+            ]
+            f.writelines(content)
+            f.seek(0)
+            pkg_installer = f.read()
+
+        return pkg_installer
diff --git a/src/cleantest/provider/_handler/lxd_handler.py b/src/cleantest/provider/_handler/lxd_handler.py
new file mode 100644
index 0000000..61a753c
--- /dev/null
+++ b/src/cleantest/provider/_handler/lxd_handler.py
@@ -0,0 +1,125 @@
+#!/usr/bin/env python3
+# Copyright 2022 Canonical Ltd.
+# See LICENSE file for licensing details.
+
+"""Handler for LXD-based test environments."""
+
+import json
+import os
+import re
+from typing import Any, Callable, Dict, List
+
+from pydantic import BaseModel
+
+from cleantest.pkg._base import Package
+from cleantest.provider._handler.base_handler import Handler, Result
+from cleantest.provider.data.lxd_data import LXDConfig
+
+
+class Instance(BaseModel):
+    name: str
+    image: str
+    exists: bool = False
+
+
+class LXDHandler(Handler):
+    def __init__(self, attr: Dict[str, Any], func: Callable, threaded: bool) -> None:
+        [setattr(self, k, v) for k, v in attr.items()]
+        self.func = func
+        self.threaded = threaded
+
+    @classmethod
+    def serial(cls, lxd, func: Callable) -> object:
+        return cls(lxd.__dict__, func, False)
+
+    @classmethod
+    def parallel(cls, lxd, func: Callable) -> object:
+        return cls(lxd.__dict__, func, True)
+
+    def run(self) -> Result:
+        instances = []
+        for i in self._image:
+            instances.append(Instance(name=f"{self._name}-{i}", image=i))
+
+        self._build(self._check_exists(instances))
+        self._handle_start_env_hooks(instances)
+        result = self._execute(
+            self._construct_testlet(self.func, [re.compile("@lxd(.*)")]), instances
+        )
+        if self._preserve is False:
+            self._teardown(instances)
+
+        return self._process(result)
+
+    def _check_exists(self, instances: List[Instance]) -> List[Instance]:
+        exists_status = []
+        for i in instances:
+            if self._client.instances.exists(i.name):
+                i.exists = True
+                exists_status.append(i)
+            else:
+                exists_status.append(i)
+
+        return exists_status
+
+    def _build(self, instances: List[Instance]) -> None:
+        for i in instances:
+            if i.exists is False:
+                config = self._data.get_config(i.image)
+                config.name = i.name
+                self._client.instances.create(config.dict(), wait=True)
+                instance = self._client.instances.get(i.name)
+                instance.start(wait=True)
+                self._init(instance, config)
+            else:
+                if (tmp := self._client.instances.get(i.name)).status.lower() == "stopped":
+                    tmp.start(wait=True)
+
+    def _init(self, instance: Any, config: LXDConfig) -> None:
+        if "ubuntu" in config.source.alias:
+            instance.execute(["apt", "install", "-y", "python3-pip"])
+            instance.execute(["pip", "install", "cleantest"])
+        else:
+            NotImplementedError(f"{config.source.alias} injection not supported yet.")
+
+    def _execute(self, test: str, instances: List[Instance]) -> Any:
+        for i in instances:
+            instance = self._client.instances.get(i.name)
+            instance.files.put("/root/test", test)
+            instance.execute(["chmod", "+x", "/root/test"])
+            return instance.execute(["/root/test"], environment=self._env.dump())
+
+    def _process(self, result: Any) -> Result:
+        return Result(exit_code=result.exit_code, stdout=result.stdout, stderr=result.stderr)
+
+    def _teardown(self, instances: List[Instance]) -> None:
+        for i in instances:
+            instance = self._client.instances.get(i.name)
+            instance.stop(wait=True)
+            instance.delete(wait=True)
+
+    def _handle_start_env_hooks(self, instances: List[Instance]) -> None:
+        startenvhooks = self._cleanconfig.get_start_env_hooks()
+        while len(startenvhooks) > 0:
+            hook = startenvhooks.pop()
+            for i in instances:
+                instance = self._client.instances.get(i.name)
+                if hook.packages is not None:
+                    for pkg in hook.packages:
+                        self.__handle_package_install(instance, pkg)
+
+    def __handle_package_install(self, instance: Any, pkg: Package) -> None:
+        dispatch = {"charmlib": lambda x: self._env.add(json.loads(x))}
+
+        dump_data = pkg._dump()
+        remote_file_path = f"/root/{os.path.basename(dump_data['path'])}"
+        instance.files.put(remote_file_path, open(dump_data["path"], "rb").read())
+        instance.files.put(
+            "/root/install",
+            self._construct_pkg_installer(pkg, remote_file_path, dump_data["hash"]),
+        )
+        instance.execute(["chmod", "+x", "/root/install"])
+        result = instance.execute(["/root/install"])
+
+        if (tmp := pkg.__class__.__name__.lower()) in dispatch:
+            dispatch[tmp](result.stdout)
diff --git a/src/cleantest/provider/data/env_data.py b/src/cleantest/provider/data/env_data.py
index e7353f6..491e9ac 100644
--- a/src/cleantest/provider/data/env_data.py
+++ b/src/cleantest/provider/data/env_data.py
@@ -9,14 +9,20 @@
 
 
 class EnvDataStore:
-    def __init__(self) -> None:
-        self.__env = {}
 
-    def append(self, env_var: str, value: str) -> None:
-        if env_var not in self.__env.keys():
-            self.__env.update({env_var: []})
+    __env = {}
 
-        self.__env[env_var].append(value)
+    def __new__(cls) -> object:
+        if not hasattr(cls, "instance"):
+            cls.instance = super(EnvDataStore, cls).__new__(cls)
+        return cls.instance
+
+    @property
+    def _raw_env(self) -> Dict[str, Any]:
+        return self.__env
+
+    def add(self, env_mapping: Dict[str, Any]) -> None:
+        self.__env.update(env_mapping)
 
     def get(self, env_var: str) -> Any | None:
         """Retrieve environment variable from store.
@@ -33,6 +39,12 @@ def get(self, env_var: str) -> Any | None:
         except KeyError:
             return None
 
-    @property
-    def _raw_env(self) -> Dict[str, Any]:
-        return self.__env
+    def dump(self) -> Dict[str, Any]:
+        result = {}
+        for k, v in self.__env.items():
+            if type(v) == list:
+                result.update({k: os.pathsep.join(v)})
+            else:
+                result.update({k: v})
+
+        return result
diff --git a/src/cleantest/provider/data/lxd_data.py b/src/cleantest/provider/data/lxd_data.py
index 8815ead..295d72a 100644
--- a/src/cleantest/provider/data/lxd_data.py
+++ b/src/cleantest/provider/data/lxd_data.py
@@ -92,11 +92,11 @@ def __init__(self) -> None:
         self.add_config(self.__defaults.bionic_amd64)
 
     @property
-    def _config(self) -> List[LXDConfig]:
+    def _raw_config(self) -> List[LXDConfig]:
         return self.__config_registry
 
     @property
-    def _defaults(self) -> Defaults:
+    def _raw_defaults(self) -> Defaults:
         return self.__defaults
 
     def get_config(self, config_name: str) -> LXDConfig:
diff --git a/src/cleantest/provider/lxd.py b/src/cleantest/provider/lxd.py
index b7aee93..e262740 100644
--- a/src/cleantest/provider/lxd.py
+++ b/src/cleantest/provider/lxd.py
@@ -6,8 +6,6 @@
 
 from __future__ import annotations
 
-import os
-import re
 from types import NoneType
 from typing import Any, Callable, Dict, List, Tuple
 
@@ -15,16 +13,10 @@
 from pylxd import Client
 
 from cleantest.control.configurator import Configure
-from cleantest.provider._base import Provider, Result
+from cleantest.provider._handler import LXDHandler
 from cleantest.provider.data import EnvDataStore, LXDDataStore
 
 
-class Instance(BaseModel):
-    name: str
-    image: str
-    exists: bool = False
-
-
 class LXDClientConfig(BaseModel):
     endpoint: Any | None = None
     version: str = "1.0"
@@ -34,7 +26,7 @@ class LXDClientConfig(BaseModel):
     project: str = "default"
 
 
-class lxd(Provider):
+class lxd:
     def __init__(
         self,
         name: str = "test",
@@ -50,18 +42,18 @@ def __init__(
         self._env = env
         self._data = data
 
-        if isinstance(image, str):
+        if type(image) == str:
             self._image = [image]
         else:
             self._image = image
 
-        if isinstance(image_config, dict):
+        if type(image_config) == dict:
             self._data.add_config(image_config)
-        elif isinstance(image_config, list):
+        elif type(image_config) == list:
             for c in image_config:
                 self._data.add_config(c)
 
-        if isinstance(client_config, NoneType):
+        if type(client_config) == NoneType:
             self._client = Client(project="default")
         else:
             self._client = Client(
@@ -77,124 +69,7 @@ def __init__(
 
     def __call__(self, func: Callable) -> Callable:
         def wrapper(*args, **kwargs) -> None:
-            instances = []
-            for i in self._image:
-                instances.append(Instance(name=f"{self._name}-{i}", image=i))
-
-            self._build(self._check_exists(instances))
-            self._handle_start_env_hooks(instances)
-            result = self._execute(self._construct(func, [re.compile("@lxd(.*)")]), instances)
-            if self._preserve is False:
-                self._teardown(instances)
-
-            return self._process(result)
+            handler = LXDHandler.serial(self, func)
+            return handler.run()
 
         return wrapper
-
-    def _check_exists(self, instances: List[Instance]) -> List[Instance]:
-        exists_status = []
-        for i in instances:
-            if self._client.instances.exists(i.name):
-                i.exists = True
-                exists_status.append(i)
-            else:
-                exists_status.append(i)
-
-        return exists_status
-
-    def _build(self, instances: List[Instance]) -> None:
-        for i in instances:
-            if i.exists is False:
-                config = self._data.get_config(i.image)
-                config.name = i.name
-                self._client.instances.create(config.dict(), wait=True)
-                self._client.instances.get(i.name).start(wait=True)
-            else:
-                if (tmp := self._client.instances.get(i.name)).status.lower() == "stopped":
-                    tmp.start(wait=True)
-
-    def _handle_start_env_hooks(self, instances: List[Instance]) -> None:
-        startenvhooks = self._cleanconfig.get_start_env_hooks()
-        while len(startenvhooks) > 0:
-            hook = startenvhooks.pop()
-            for i in instances:
-                instance = self._client.instances.get(i.name)
-                instance.execute(["apt-get", "install", "-y", "-qq", "python3-pip"])
-
-                if hook.packages is not None:
-                    for package in hook.packages:
-                        instance.execute(["python3", "-m", "pip", "install", package])
-
-                if hook.requirements is not None:
-                    requirements = (
-                        [hook.requirements]
-                        if isinstance(hook.requirements, str)
-                        else hook.requirements
-                    )
-
-                    if hook.constraints is not None:
-                        constraints = (
-                            [hook.constraints]
-                            if isinstance(hook.constraints, str)
-                            else hook.constraints
-                        )
-                        req_and_con = zip(requirements, constraints)
-                        for req, con in req_and_con:
-                            instance.files.put("/tmp/requirements.txt", open(req).read())
-                            instance.files.put("/tmp/constraints.txt", open(con).read())
-                            instance.execute(
-                                [
-                                    "python3",
-                                    "-m",
-                                    "pip",
-                                    "install",
-                                    "-r",
-                                    "/tmp/requirements.txt",
-                                    "-c",
-                                    "/tmp/constraints.txt",
-                                ]
-                            )
-                    else:
-                        for req in requirements:
-                            instance.files.put("/tmp/requirements.txt", open(req).read())
-                            instance.execute(
-                                [
-                                    "python3",
-                                    "-m",
-                                    "pip",
-                                    "install",
-                                    "-r",
-                                    "/tmp/requirements.txt",
-                                ]
-                            )
-
-                if hook.python_path is not None:
-                    for lib in hook.python_path:
-                        base = os.path.basename(lib)
-                        lib_path = os.path.join("/root", base)
-                        instance.execute(["mkdir", "-p", lib_path])
-                        instance.files.recursive_put(lib, lib_path)
-                        self._env.append("PYTHONPATH", lib_path)
-
-    def _execute(self, test: str, instances: List[Instance]) -> Any:
-        for i in instances:
-            instance = self._client.instances.get(i.name)
-            instance.files.put("/root/test", test)
-            instance.execute(["chmod", "+x", "/root/test"])
-            if self._env.get("PYTHONPATH") is None:
-                result = instance.execute(
-                    ["/root/test"], environment={"PYTHONPATH": self._env.get("PYTHONPATH")}
-                )
-            else:
-                result = instance.execute(["/root/test"])
-
-            return result
-
-    def _process(self, result: Any) -> Result:
-        return Result(exit_code=result.exit_code, stdout=result.stdout, stderr=result.stderr)
-
-    def _teardown(self, instances: List[Instance]) -> None:
-        for i in instances:
-            instance = self._client.instances.get(i.name)
-            instance.stop(wait=True)
-            instance.delete(wait=True)
diff --git a/tests/lxd/local/test_lxd_local.py b/tests/lxd/local/test_lxd_local.py
index 5f0c2bb..93d83f9 100644
--- a/tests/lxd/local/test_lxd_local.py
+++ b/tests/lxd/local/test_lxd_local.py
@@ -8,17 +8,23 @@
 
 from cleantest import Configure
 from cleantest.hooks import StartEnvHook
+from cleantest.pkg import Charmlib, Pip
 from cleantest.provider import lxd
 
 # Define the hooks and register them.
 root = os.path.dirname(os.path.realpath(__file__))
-simpleconfig = Configure()
+cleanconfig = Configure()
 startenvhook = StartEnvHook(
     name="my_start_hook",
-    requirements=os.path.join(root, "requirements.txt"),
-    python_path=[os.path.join(root, "vendor")],
+    packages=[
+        Charmlib(
+            auth_token_path=os.path.join(root, "charmhub.secret"),
+            charmlibs=["charms.operator_libs_linux.v0.apt"],
+        ),
+        Pip(requirements=os.path.join(root, "requirements.txt")),
+    ],
 )
-simpleconfig.register_hook(startenvhook)
+cleanconfig.register_hook(startenvhook)
 
 # Define the testlets.
 @lxd(image="jammy-amd64", preserve=False)
@@ -51,6 +57,7 @@ def install_snapd():
         print("tabulate is installed.", file=sys.stdout)
     except ImportError:
         print("Failed to import tabulate package.", file=sys.stderr)
+        sys.exit(1)
 
     sys.exit(0)