From 4a7fb922b47bed633964852880ea0e4a78c7a8ac Mon Sep 17 00:00:00 2001
From: "Diaz de Arcaya Serrano, Josu" <josu.diazdearcaya@tecnalia.com>
Date: Fri, 19 May 2023 09:04:09 +0200
Subject: [PATCH] y3

---
 iem-api/.python-version                |   2 +-
 iem-api/Dockerfile                     |  24 ++--
 iem-api/certs/config                   |   3 +
 iem-api/certs/id_rsa                   |  27 ++++
 iem-api/certs/id_rsa.pub               |   1 +
 iem-api/db/.gitignore                  |   1 +
 iem-api/logging.ini                    |  31 ++++
 iem-api/main.py                        |  62 ++++----
 iem-api/src/core/engine.py             |  64 +++++----
 iem-api/src/core/iem.py                | 191 +++++++++++++------------
 iem-api/src/core/persistence.py        |  84 +----------
 iem-api/src/core/utils.py              |  32 ++++-
 iem-api/src/resources/id_iem           |  38 +++++
 iem-api/src/resources/id_iem.pub       |   1 +
 iem-api/tests/__init__.py              |   0
 iem-api/tests/it/__init__.py           |   0
 iem-api/tests/it/test_it_iem.py        |  83 +++++++++++
 iem-api/tests/resources/aws.zip        | Bin 0 -> 1248 bytes
 iem-api/tests/resources/docker.zip     | Bin 0 -> 5400 bytes
 iem-api/tests/resources/dummy.zip      | Bin 0 -> 3700 bytes
 iem-api/tests/resources/openstack.zip  | Bin 0 -> 3735 bytes
 iem-api/tests/unit/__init__.py         |   0
 iem-api/tests/unit/test_iem.py         |  31 ++++
 iem-api/tests/unit/test_main.py        | 106 ++++++++++++++
 iem-api/tests/unit/test_persistence.py |  32 +++++
 25 files changed, 571 insertions(+), 242 deletions(-)
 create mode 100644 iem-api/certs/config
 create mode 100644 iem-api/certs/id_rsa
 create mode 100644 iem-api/certs/id_rsa.pub
 create mode 100644 iem-api/db/.gitignore
 create mode 100644 iem-api/logging.ini
 create mode 100644 iem-api/src/resources/id_iem
 create mode 100644 iem-api/src/resources/id_iem.pub
 create mode 100644 iem-api/tests/__init__.py
 create mode 100644 iem-api/tests/it/__init__.py
 create mode 100644 iem-api/tests/it/test_it_iem.py
 create mode 100644 iem-api/tests/resources/aws.zip
 create mode 100644 iem-api/tests/resources/docker.zip
 create mode 100644 iem-api/tests/resources/dummy.zip
 create mode 100644 iem-api/tests/resources/openstack.zip
 create mode 100644 iem-api/tests/unit/__init__.py
 create mode 100644 iem-api/tests/unit/test_iem.py
 create mode 100644 iem-api/tests/unit/test_main.py
 create mode 100644 iem-api/tests/unit/test_persistence.py

diff --git a/iem-api/.python-version b/iem-api/.python-version
index f69abe4..0a59033 100644
--- a/iem-api/.python-version
+++ b/iem-api/.python-version
@@ -1 +1 @@
-3.9.7
+3.9.10
diff --git a/iem-api/Dockerfile b/iem-api/Dockerfile
index 635043b..eb07ab2 100644
--- a/iem-api/Dockerfile
+++ b/iem-api/Dockerfile
@@ -1,17 +1,16 @@
 FROM hashicorp/terraform:1.1.4
 
-ARG API_KEY
+COPY requirements.txt /tmp/requirements.txt
+RUN apk add py3-pip cargo g++ python3-dev file libffi-dev openssl-dev bash python3=3.9.16-r0 gnupg
+RUN pip3 install -r /tmp/requirements.txt
+# install docker stack
+RUN apk add docker docker-compose
 
-ENV API_KEY=$API_KEY
+ENV API_KEY=changeme
 ENV IEM_HOME=/opt/iem/
+ENV DOCKERIZED=true
 
 COPY src/resources/ansible.cfg /etc/ansible/ansible.cfg
-COPY requirements.txt /tmp/requirements.txt
-COPY src ${IEM_HOME}src
-COPY main.py ${IEM_HOME}main.py
-
-RUN apk add py3-pip cargo g++ python3-dev file libffi-dev openssl-dev bash python3=3.9.13-r1 gnupg
-RUN pip3 install -r /tmp/requirements.txt
 
 # RUN adduser -h ${IEM_HOME} -S -D iem
 COPY certs/config ${IEM_HOME}.ssh/config
@@ -28,7 +27,14 @@ RUN ansible-galaxy collection install community.general
 COPY roles.yml /tmp/roles.yml
 RUN ansible-galaxy install -r /tmp/roles.yml
 
+RUN mkdir -p ${IEM_HOME}db && \
+    mkdir -p ${IEM_HOME}deployments
+
+COPY src ${IEM_HOME}src
+COPY main.py ${IEM_HOME}main.py
+COPY logging.ini ${IEM_HOME}logging.ini
+
 ENTRYPOINT ["/usr/bin/env"]
 WORKDIR ${IEM_HOME}
-CMD /usr/bin/uvicorn main:app --host 0.0.0.0
+CMD /usr/bin/uvicorn main:app --host 0.0.0.0 --log-level info
 EXPOSE 8000
diff --git a/iem-api/certs/config b/iem-api/certs/config
new file mode 100644
index 0000000..a3dd79d
--- /dev/null
+++ b/iem-api/certs/config
@@ -0,0 +1,3 @@
+Host *
+    StrictHostKeyChecking no
+    UserKnownHostsFile=/dev/null
\ No newline at end of file
diff --git a/iem-api/certs/id_rsa b/iem-api/certs/id_rsa
new file mode 100644
index 0000000..86a197b
--- /dev/null
+++ b/iem-api/certs/id_rsa
@@ -0,0 +1,27 @@
+-----BEGIN RSA PRIVATE KEY-----
+MIIEpAIBAAKCAQEA1FrTNE42EgZr9WJNMtvpKFHYhPUJ4lzEp83EM0jYY3TyjmIe
+ThMuqMLAHCk22fl4a8PttucggJ5ZWKhcJh623/y8AybJcmqZgq9a41Q609dmirf0
+7frCl+6zL8Mqy2Le2BD4eRADcq11s8r8Ys6J+EBPHQgEnK9CeZLSc/WFRlVr4bOD
+s0bEouDxjTAMYjYcpsCwqYgGdIXI9WWsnt3RvcEe8CaiTqoyDN8ZtgkG6MweSrTQ
+js8ySHO6o25cOoF7aT9Ihhf32I+KUanNIOvk3RAw2z1FK5xkFbbqMggZqz7rJn3M
+sn2dDiCQi2CWox2OYXV/jJKLC3UFuOX64fS9cwIDAQABAoIBAQCs69Tm1/Vx0ibh
+aA4DJ06C1bsh8cP9v5soJgfp1xzWSGooBcA1xasOI6B6jhkrgNlNr/uIIEe4VLne
+1yJKrGIwnUagrloGQMYGxDKXwYQx80p+FXRuwe7p96eUcjIL8tQSUCd1tdOI87VQ
+FjBVaWiybfO+aUQQLytLgoK7iKfhb7vO+9F+ZK1iDjBDNxFuiOM5zoeWOI7boYkD
+2yXIkwoBePS2rosbPLa649sVakKex2WhQdUFst4Zba2RhnWQBXUY44LvEK5TzScF
+FyYphPOUSplbzzM2+fuOna91NIWmJyHmf15lj7X9kC66XFIZMlvapksB8stEpDiA
+4al3IdBJAoGBAPPuM3xkr/kQYYn7E42fgpmINZ78V48gMLhpyUOabZtF8inMyMPB
+q7kfHns8Vx0ET8grSNr3qwDDV82lwvGqRCFChASMdQRR9LanydkDSeqpuZyQtVlt
+A/65YUdcNY7Vy+M+fRh5Srh/6qcO3beLeLWXbJ4RHBP/OEmHuF4mLfgVAoGBAN7c
+qdxTOhXPvOU69Bs5rJdfo6qBI1Yg8MCGctsUwPFpw1kW773ROOPa6XI6D74Dsdg8
+ypZ+IC3pRVtx61Xi3NOwxWNTTG+dyUgTSFz+WKjywXZXeHIbWngiFqk8JFYQWPzk
+6YaJk4tZhk2YuNNaCCYRgQqyWv8coEurRlMXZHlnAoGBALcJwdaQ0z8oXJimL4jw
+7ZX5kIrpPWanuAdZUe4Jfj+qX8mf4fKKbCowQLYmlBOw/ZDtcfDlMYsUCdnFjZ+7
+rP3sJJYpM1F3khJRm3PdNOUCUMY8C+i7lejZADcE6SdyJFkztbjcowYI7nJHBHZL
+ENvqcVW27wPOWlVKozz6lzn1AoGALVwmaoS6DtRwcwuzwZLUkR7TNhIAujgMKHN1
+DyhDOR+4tfpYI39hH+dfmnM83wTrfsKozUawkAepqToflySMo72X/2Zl6VXpMPVT
+xjGyo/h87fRRvI/asxblG9702luLcTW6XjrEQBmhn0uVWtc5T15CsIWqxb/y1FPx
+BVp+hcMCgYAlJXbjzjbbDoIOCsXPSPe9voBL8zVwp0aNuvQcuB/vCt1n1c1DWuPr
+AGMy/fRwY0Znag+ODMuulm7RgXUQy6ifJHiz9cKVGg/mGifaJSjgC+1AI9HFlij3
+asM5CueU0gK974rDxQkwmIWpRH57+kf6s8tGDrPPvqX9S4p3oxFlTw==
+-----END RSA PRIVATE KEY-----
diff --git a/iem-api/certs/id_rsa.pub b/iem-api/certs/id_rsa.pub
new file mode 100644
index 0000000..245caaf
--- /dev/null
+++ b/iem-api/certs/id_rsa.pub
@@ -0,0 +1 @@
+ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDUWtM0TjYSBmv1Yk0y2+koUdiE9QniXMSnzcQzSNhjdPKOYh5OEy6owsAcKTbZ+Xhrw+225yCAnllYqFwmHrbf/LwDJslyapmCr1rjVDrT12aKt/Tt+sKX7rMvwyrLYt7YEPh5EANyrXWzyvxizon4QE8dCAScr0J5ktJz9YVGVWvhs4OzRsSi4PGNMAxiNhymwLCpiAZ0hcj1Zaye3dG9wR7wJqJOqjIM3xm2CQbozB5KtNCOzzJIc7qjblw6gXtpP0iGF/fYj4pRqc0g6+TdEDDbPUUrnGQVtuoyCBmrPusmfcyyfZ0OIJCLYJajHY5hdX+MkosLdQW45frh9L1z josu@WKM0092A
diff --git a/iem-api/db/.gitignore b/iem-api/db/.gitignore
new file mode 100644
index 0000000..8d96d78
--- /dev/null
+++ b/iem-api/db/.gitignore
@@ -0,0 +1 @@
+iem.db
diff --git a/iem-api/logging.ini b/iem-api/logging.ini
new file mode 100644
index 0000000..7a1ca11
--- /dev/null
+++ b/iem-api/logging.ini
@@ -0,0 +1,31 @@
+[loggers]
+keys=root,src,uvicorn
+
+[handlers]
+keys=stream_handler
+
+[formatters]
+keys=formatter
+
+[logger_root]
+level=INFO
+handlers=stream_handler
+
+[logger_src]
+level=INFO
+handlers=stream_handler
+qualname=src
+propagate=0
+
+[logger_uvicorn]
+level=INFO
+handlers=stream_handler
+qualname=uvicorn
+propagate=0
+
+[handler_stream_handler]
+class=StreamHandler
+formatter=formatter
+
+[formatter_formatter]
+format=%(asctime)s %(name)-12s %(levelname)-8s %(message)s
diff --git a/iem-api/main.py b/iem-api/main.py
index 3fb84f4..01bf7c0 100755
--- a/iem-api/main.py
+++ b/iem-api/main.py
@@ -1,33 +1,36 @@
+#!/usr/bin/env python3
+
 import json
 import logging
-import os
+from typing import List
 
-from fastapi import FastAPI, BackgroundTasks, status, Security, Depends, HTTPException
+import uvicorn
+from fastapi import BackgroundTasks, Depends, FastAPI, HTTPException, Security, status
 from fastapi.openapi.utils import get_openapi
-from fastapi.security.api_key import APIKeyHeader, APIKey
-from typing import List
+from fastapi.security.api_key import APIKey, APIKeyHeader
 
 from src.core.iem import Iem
-from src.core.persistence import Sqlite
+from src.core.persistence import Persistence
 from src.core.utils import (
     BaseResponse,
-    DeploymentResponse,
-    DeploymentRequest,
     DeleteDeploymentRequest,
+    DeploymentRequest,
+    DeploymentResponse,
+    SelfHealingRequest,
 )
 
-LOGGER = logging.getLogger("iem")
-
 api_key_header = APIKeyHeader(name="x-api-key", auto_error=False)
 
 app = FastAPI(
-    title="IaC Execution Manager", version="0.1.15", description="IaC Execution Manager"
+    title="IaC Execution Manager", version="1.0.0", description="IaC Execution Manager"
 )
 
+logging.config.fileConfig("logging.ini")
+
 
 async def get_api_key(api_key_query: str = Security(api_key_header)):
 
-    if Sqlite().valid_api_key(api_key_query=api_key_query):
+    if Persistence().valid_api_key(api_key_query=api_key_query):
         return api_key_query
     else:
         raise HTTPException(
@@ -37,7 +40,7 @@ async def get_api_key(api_key_query: str = Security(api_key_header)):
 
 
 @app.get("/", tags=["greeting"])
-async def read_root(api_key: APIKey = Depends(get_api_key)):
+async def read_root(_: APIKey = Depends(get_api_key)):
     return {
         "message": "Hello from the IaC Execution Manager!",
         "version": app.version,
@@ -48,11 +51,7 @@ async def read_root(api_key: APIKey = Depends(get_api_key)):
 
 @app.get("/deployments/", response_model=List[DeploymentResponse], tags=["deployments"])
 async def read_status(
-    start: int = 0,
-    count: int = 25,
-    start_date: str = "1970-01-01",
-    end_date: str = "2100-01-01",
-    api_key: APIKey = Depends(get_api_key),
+    _: APIKey = Depends(get_api_key),
 ):
     all_deployments = Iem(credentials=None).get_all_deployments()
     return list(all_deployments)
@@ -65,9 +64,7 @@ async def read_status(
 )
 async def read_status_deployment(
     deployment_id: str,
-    start: int = 0,
-    count: int = 1,
-    api_key: APIKey = Depends(get_api_key),
+    _: APIKey = Depends(get_api_key),
 ):
     deployment = Iem().get_deployment(deployment_id=deployment_id)
     return list(deployment)
@@ -82,11 +79,10 @@ async def read_status_deployment(
 async def deploy(
     d: DeploymentRequest,
     background_tasks: BackgroundTasks,
-    api_key: APIKey = Depends(get_api_key),
+    _: APIKey = Depends(get_api_key),
 ):
-    logging.warning(d)
     i = Iem(credentials=d.credentials)
-    background_tasks.add_task(i.deploy, d.deployment_id, d.repository, d.commit)
+    background_tasks.add_task(i.deploy, d.deployment_id, d.bundle.base64)
     return BaseResponse(message="Deployment Request Created")
 
 
@@ -99,15 +95,29 @@ async def deploy(
 async def undeploy(
     d: DeleteDeploymentRequest,
     background_tasks: BackgroundTasks,
-    api_key: APIKey = Depends(get_api_key),
+    _: APIKey = Depends(get_api_key),
 ):
-    logging.warning(d)
     i = Iem(credentials=d.credentials)
     background_tasks.add_task(i.destroy, d.deployment_id)
     return BaseResponse(message="Undeployment Request Created")
 
 
-if os.getenv("STAGE") == "dev":
+@app.post(
+    "/self-healing/{strategy}",
+    status_code=status.HTTP_201_CREATED,
+    response_model=BaseResponse,
+    tags=["deployments"],
+)
+async def deploy(
+    d: SelfHealingRequest,
+    background_tasks: BackgroundTasks,
+    _: APIKey = Depends(get_api_key),
+):
+    return BaseResponse(message="Deployment Request Created")
+
+
+if __name__ == "__main__":
+    uvicorn.run("main:app", host="127.0.0.1", port=8000, log_level="info")
     with open("../openapi.json", "w") as f:
         json.dump(
             get_openapi(
diff --git a/iem-api/src/core/engine.py b/iem-api/src/core/engine.py
index 08f25cb..6eac90b 100644
--- a/iem-api/src/core/engine.py
+++ b/iem-api/src/core/engine.py
@@ -1,12 +1,13 @@
 import logging
 import os
 import subprocess
-
+import time
 from abc import ABC, abstractmethod
-from jinja2 import Template
 from subprocess import CalledProcessError
 
-LOGGER = logging.getLogger("iem")
+from jinja2 import Template
+
+LOGGER = logging.getLogger(__name__)
 
 
 class Factory:
@@ -58,15 +59,16 @@ class Terraform(Engine):
             )
             output = subprocess.run(
                 ["terraform", "apply", "-auto-approve"],
-                check=True,
                 cwd=self._repo_path,
                 env=self._env,
-                # capture_output=True,
+                capture_output=True,
             )
+            LOGGER.info(output.stdout.decode("utf-8"))
+            output.check_returncode()
             return "CREATED", output.stdout, output.stderr
         except CalledProcessError as e:
             LOGGER.exception(e)
-            return "ERROR", None, None
+            raise e
 
     def destroy(self):
         try:
@@ -75,12 +77,14 @@ class Terraform(Engine):
                 check=True,
                 cwd=self._repo_path,
                 env=self._env,
-                # capture_output=True,
+                capture_output=True,
             )
+            LOGGER.info(output.stdout.decode("utf-8"))
+            output.check_returncode()
             return "DESTROYED", output.stdout, output.stderr
         except CalledProcessError as e:
             LOGGER.exception(e)
-            return "ERROR", None, None
+            raise e
 
     def output(self):
         try:
@@ -120,26 +124,30 @@ class Ansible(Engine):
 
     def apply(self):
         LOGGER.info("About to apply ansible")
-        try:
-            output = subprocess.run(
-                ["ansible", "all", "-i", "inventory", "-m", "wait_for_connection"],
-                check=True,
-                cwd=self._repo_path,
-                env=self._env,
-                capture_output=True,
-            )
-            LOGGER.info("All hosts in the inventory are reachable.")
-            output = subprocess.run(
-                ["ansible-playbook", "-i", "inventory", "main.yml"],
-                check=True,
-                cwd=self._repo_path,
-                env=self._env,
-                capture_output=True,
-            )
-            return "CREATED", output.stdout, output.stderr
-        except CalledProcessError as e:
-            LOGGER.exception(e.output)
-            raise e
+        for i in range(2):
+            try:
+                output = subprocess.run(
+                    ["ansible", "all", "-i", "inventory", "-m", "wait_for_connection"],
+                    check=True,
+                    cwd=self._repo_path,
+                    env=self._env,
+                    capture_output=True,
+                )
+                LOGGER.info("All hosts in the inventory are reachable.")
+                output = subprocess.run(
+                    ["ansible-playbook", "-i", "inventory", "main.yml"],
+                    check=True,
+                    cwd=self._repo_path,
+                    env=self._env,
+                    capture_output=True,
+                )
+                LOGGER.info(output.stdout.decode("utf-8"))
+                return "CREATED", output.stdout, output.stderr
+            except CalledProcessError as e:
+                LOGGER.exception(e.output)
+                if i == 1:
+                    raise e
+                time.sleep(10)
 
     def destroy(self):
         LOGGER.info("Nothing to be seen here.")
diff --git a/iem-api/src/core/iem.py b/iem-api/src/core/iem.py
index e4ad215..d5a40dc 100644
--- a/iem-api/src/core/iem.py
+++ b/iem-api/src/core/iem.py
@@ -1,54 +1,39 @@
-import git
+import base64
+import binascii
 import json
 import logging
 import os
-import subprocess
+import shutil
+from io import BytesIO
+from subprocess import CalledProcessError
+from zipfile import BadZipFile, ZipFile
 
-from git import GitCommandError, InvalidGitRepositoryError
 from omegaconf import OmegaConf
-from subprocess import CalledProcessError
 
 from src.core.engine import Factory
-from src.core.persistence import Sqlite
-from src.core.utils import DeploymentResponse, Credentials
+from src.core.persistence import Persistence
+from src.core.utils import Credentials, DeploymentResponse
 
-LOGGER = logging.getLogger("iem")
+LOGGER = logging.getLogger(__name__)
 
 
 class Iem:
     def __init__(self, credentials: Credentials = None):
-        logging.basicConfig(
-            level=logging.INFO, format="%(asctime)s %(levelname)-8s %(message)s"
-        )
-
         self._credentials = credentials
-        # if credentials.aws:
-        #    self._aws_access_key_id = credentials.aws.access_key_id
-        #    self._aws_secret_access_key = credentials.aws.secret_access_key
 
-        # if credentials.openstack:
-        #    self._os_username = credentials.openstack.user_name
-        #    self._os_password = credentials.openstack.password
-        #    self._os_auth_url = credentials.openstack.auth_url
-        #    self._os_project_name = credentials.openstack.project_name
+        self._path_deployments = f"{os.environ['IEM_HOME']}deployments/"
 
-        # Check IEM_HOME variable
-        if os.getenv("IEM_HOME") is None:
-            LOGGER.error("Please define IEM_HOME environment variable.")
-            exit(-1)
-        self._iem_home = os.getenv("IEM_HOME")
-
-        self._persistence = Sqlite()
+        self._persistence = Persistence()
 
     def get_all_deployments(self):
         rows = self._persistence.get_all_deployments()
         for r in rows:
             d = DeploymentResponse(
-                status_time=r[0],
-                deployment_id=r[1],
-                status=r[2],
-                stdout=r[3],
-                stderr=r[4],
+                status_time=r.status_time,
+                deployment_id=r.deployment_id,
+                status=r.status,
+                stdout=r.stdout,
+                stderr=r.stderr,
             )
             yield d
 
@@ -56,54 +41,87 @@ class Iem:
         row = self._persistence.get_deployment(deployment_id=deployment_id)
         if row:
             d = DeploymentResponse(
-                status_time=row[0],
-                deployment_id=row[1],
-                status=row[2],
-                stdout=row[3],
-                stderr=row[4],
+                status_time=row.status_time,
+                deployment_id=row.deployment_id,
+                status=row.status,
+                stdout=row.stdout,
+                stderr=row.stderr,
             )
             yield d
 
-    def deploy(self, deployment_id: str, repository: str, commit: str):
+    def _get_env(self, deployment_id: str, credentials: Credentials) -> dict:
+        my_env = os.environ.copy()
+        if credentials.aws:
+            my_env["AWS_ACCESS_KEY_ID"] = credentials.aws.access_key_id
+            my_env["AWS_SECRET_ACCESS_KEY"] = credentials.aws.secret_access_key
+        if credentials.openstack:
+            my_env["OS_USERNAME"] = credentials.openstack.user_name
+            my_env["OS_PASSWORD"] = credentials.openstack.password
+            my_env["OS_AUTH_URL"] = credentials.openstack.auth_url
+            my_env["OS_PROJECT_NAME"] = credentials.openstack.project_name
+        if credentials.azure:
+            my_env["ARM_CLIENT_ID"] = credentials.azure.arm_client_id
+            my_env["ARM_CLIENT_SECRET"] = credentials.azure.arm_client_secret
+            my_env["ARM_SUBSCRIPTION_ID"] = credentials.azure.arm_subscription_id
+            my_env["ARM_TENANT_ID"] = credentials.azure.arm_tenant_id
+        if credentials.vmware:
+            my_env["VSPHERE_USER"] = credentials.vmware.user_name
+            my_env["VSPHERE_PASSWORD"] = credentials.vmware.password
+            my_env["VSPHERE_SERVER"] = credentials.vmware.server
+            my_env[
+                "VSPHERE_ALLOW_UNVERIFIED_SSL"
+            ] = credentials.vmware.allow_unverified_ssl
+
+        my_env["DEPLOYMENT_ID"] = deployment_id
+
+        return my_env
+
+    def _apply_stage(self, my_env: dict, repo_path: str, stage: str):
+        conf = OmegaConf.load(f"{repo_path}/{stage}/config.yaml")
+
+        self.validate(env=my_env, io=conf.input)
+
+        LOGGER.info(f"About to run stage: {stage}")
+        my_eng = Factory().get_engine(conf.engine)(
+            repo_path=f"{repo_path}/{stage}", my_env=my_env
+        )
+        status, stdout, stderr = my_eng.apply()
+
+        if conf.output:
+            output = my_eng.output()
+            self.update_env(output=output, env=my_env, io=conf.output)
+            self.validate(env=my_env, io=conf.output)
+
+        return status, stdout, stderr
+
+    def deploy(self, deployment_id: str, bundle: str):
         function_name = "deploy"
-        LOGGER.info(f"Running {function_name} method")
+        LOGGER.info(f"Running {function_name} method.")
 
         self._persistence.insert_deployment(
             deployment_id=deployment_id, status="STARTED", stdout=None, stderr=None
         )
 
+        repo_path = f"{self._path_deployments}{deployment_id}"
+        if not os.path.exists(repo_path):
+            os.makedirs(repo_path)
+
         try:
-            repo_path = f"{self._iem_home}{deployment_id}"
-            self.get_repo(repo_path=repo_path, repository=repository, commit=commit)
-
-            LOGGER.info(f"About to deploy project {repo_path}")
-            my_env = os.environ.copy()
-            if self._credentials.aws:
-                my_env["AWS_ACCESS_KEY_ID"] = self._credentials.aws.access_key_id
-                my_env[
-                    "AWS_SECRET_ACCESS_KEY"
-                ] = self._credentials.aws.secret_access_key
-            if self._credentials.openstack:
-                my_env["OS_USERNAME"] = self._credentials.openstack.user_name
-                my_env["OS_PASSWORD"] = self._credentials.openstack.password
-                my_env["OS_AUTH_URL"] = self._credentials.openstack.auth_url
-                my_env["OS_PROJECT_NAME"] = self._credentials.openstack.project_name
+            LOGGER.info(f"Decompressing base64 bundle.")
+            bundle_decode = base64.b64decode(bundle)
+            ZipFile(BytesIO(bundle_decode)).extractall(repo_path)
 
+            LOGGER.info(f"Reading credentials.")
+            my_env = self._get_env(
+                deployment_id=deployment_id, credentials=self._credentials
+            )
+
+            LOGGER.info(f"About to deploy project {repo_path}.")
             project_conf = OmegaConf.load(f"{repo_path}/config.yaml")
             for stage in project_conf.iac:
-                conf = OmegaConf.load(f"{repo_path}/{stage}/config.yaml")
-
-                self.validate(env=my_env, io=conf.input)
-
-                my_eng = Factory().get_engine(conf.engine)(
-                    repo_path=f"{repo_path}/{stage}", my_env=my_env
+                status, stdout, stderr = self._apply_stage(
+                    my_env=my_env, repo_path=repo_path, stage=stage
                 )
-                status, stdout, stderr = my_eng.apply()
-
-                if conf.output:
-                    output = my_eng.output()
-                    self.update_env(output=output, env=my_env, io=conf.output)
-                    self.validate(env=my_env, io=conf.output)
 
             self._persistence.insert_deployment(
                 deployment_id=deployment_id,
@@ -111,12 +129,14 @@ class Iem:
                 stdout=stdout,
                 stderr=stderr,
             )
+            LOGGER.info(f"Deployment completed for project {repo_path}")
         except (
+            binascii.Error,
+            BadZipFile,
             CalledProcessError,
-            GitCommandError,
             FileNotFoundError,
-            InvalidGitRepositoryError,
             NameError,
+            KeyError,
         ) as e:
             LOGGER.exception(e)
             self._persistence.insert_deployment(
@@ -130,17 +150,11 @@ class Iem:
         function_name = "destroy"
         LOGGER.info(f"Running {function_name} method")
 
-        repo_path = f"{self._iem_home}{deployment_id}"
+        repo_path = f"{self._path_deployments}{deployment_id}"
 
-        my_env = os.environ.copy()
-        if self._credentials.aws:
-            my_env["AWS_ACCESS_KEY_ID"] = self._credentials.aws.access_key_id
-            my_env["AWS_SECRET_ACCESS_KEY"] = self._credentials.aws.secret_access_key
-        if self._credentials.openstack:
-            my_env["OS_USERNAME"] = self._credentials.openstack.user_name
-            my_env["OS_PASSWORD"] = self._credentials.openstack.password
-            my_env["OS_AUTH_URL"] = self._credentials.openstack.auth_url
-            my_env["OS_PROJECT_NAME"] = self._credentials.openstack.project_name
+        my_env = self._get_env(
+            deployment_id=deployment_id, credentials=self._credentials
+        )
 
         LOGGER.info(f"About to destroy project {repo_path}")
         project_conf = OmegaConf.load(f"{repo_path}/config.yaml")
@@ -153,6 +167,11 @@ class Iem:
             )
             status, stdout, stderr = my_eng.destroy()
 
+        if "/home" in repo_path:
+            LOGGER.exception(f"Cannot delete {repo_path}")
+        elif os.getenv("DOCKERIZED") == "true":
+            shutil.rmtree(repo_path)
+
         self._persistence.insert_deployment(
             deployment_id=deployment_id,
             status=status,
@@ -173,21 +192,3 @@ class Iem:
             if variable not in env:
                 LOGGER.error(f"Variable {variable} not found in environment.")
                 raise NameError
-
-    def get_repo(self, repo_path: str, repository: str, commit: str):
-        LOGGER.info(
-            f"About to download the project {repo_path} with repository {repository}"
-        )
-        if not os.path.isdir(repo_path):
-            os.makedirs(repo_path)
-            repo = git.Repo.clone_from(
-                url=repository, to_path=repo_path, no_checkout=True
-            )
-        else:
-            repo = git.Repo(repo_path)
-
-        repo.git.checkout(commit)
-
-        for submodule in repo.submodules:
-            submodule.update(init=True)
-            submodule.module().git.checkout()
diff --git a/iem-api/src/core/persistence.py b/iem-api/src/core/persistence.py
index 2f4979e..80ada9c 100644
--- a/iem-api/src/core/persistence.py
+++ b/iem-api/src/core/persistence.py
@@ -1,86 +1,16 @@
+import logging
 import os
-import sqlite3
-
-# from dataclasses import dataclass
 from datetime import datetime
+
 from ratelimiter import RateLimiter
+from sqlalchemy import Column, DateTime, Integer, String, create_engine, desc, select
+from sqlalchemy.orm import Session, declarative_base
 
-from sqlalchemy import create_engine
-from sqlalchemy import Column
-from sqlalchemy import DateTime, Integer, String
-from sqlalchemy import select, desc
-from sqlalchemy.orm import declarative_base, Session
+LOGGER = logging.getLogger(__name__)
 
 Base = declarative_base()
 
 
-class Sqlite:
-    def __init__(self):
-        self._db_name = "iem.db"
-
-        # create database if it does not exist
-        if not os.path.isfile(self._db_name):
-            self.__create_database()
-
-    def __create_database(self):
-        conn = sqlite3.connect(self._db_name)
-        with conn:
-            sql = """CREATE TABLE deployments (id INTEGER PRIMARY KEY AUTOINCREMENT,
-            status_time DATETIME DEFAULT CURRENT_TIMESTAMP,
-            deployment_id TEXT NOT NULL, status TEXT NOT NULL, stdout TEXT, stderr TEXT);"""
-            conn.execute(sql)
-
-    def insert_deployment(
-        self, deployment_id: str, status: str, stdout: str, stderr: str
-    ):
-        conn = sqlite3.connect(self._db_name)
-        with conn:
-            sql = """INSERT INTO deployments (deployment_id, status, stdout, stderr) VALUES (?,?,?,?)"""
-
-            cursor = conn.cursor()
-            cursor.execute(
-                sql,
-                (
-                    deployment_id,
-                    status,
-                    stdout,
-                    stderr,
-                ),
-            )
-            conn.commit()
-
-    def get_deployment(self, deployment_id: str):
-        conn = sqlite3.connect(self._db_name)
-        with conn:
-            sql = """SELECT status_time, deployment_id, status, stdout, stderr FROM deployments
-                     WHERE deployment_id=? ORDER BY id DESC LIMIT 1"""
-
-            cursor = conn.cursor()
-            cursor.execute(sql, (deployment_id,))
-            row = cursor.fetchone()
-
-            return row
-
-    def get_all_deployments(self):
-        conn = sqlite3.connect(self._db_name)
-        with conn:
-            sql = """SELECT status_time, deployment_id, status, stdout, stderr
-                     FROM deployments ORDER BY id DESC LIMIT 25 OFFSET 0"""
-
-            cursor = conn.cursor()
-            cursor.execute(sql)
-            rows = cursor.fetchall()
-
-            return rows
-
-    @RateLimiter(max_calls=10, period=1)
-    def valid_api_key(self, api_key_query: str):
-        if api_key_query == os.getenv("API_KEY"):
-            return True
-        else:
-            return False
-
-
 class Deployment(Base):
     __tablename__ = "deployments"
 
@@ -96,8 +26,8 @@ class Deployment(Base):
 
 
 class Persistence:
-    def __init__(self):
-        self._engine = create_engine("sqlite:///:memory:", future=True)
+    def __init__(self, engine_url="sqlite:///db/iem.db"):
+        self._engine = create_engine(url=engine_url, future=True)
         Base.metadata.create_all(self._engine)
 
     def insert_deployment(
diff --git a/iem-api/src/core/utils.py b/iem-api/src/core/utils.py
index 5ec5df2..683afb2 100644
--- a/iem-api/src/core/utils.py
+++ b/iem-api/src/core/utils.py
@@ -1,10 +1,7 @@
-import logging
-
 from datetime import datetime
-from pydantic import BaseModel
 from typing import Dict, Optional
 
-LOGGER = logging.getLogger("iem")
+from pydantic import BaseModel
 
 
 class BaseResponse(BaseModel):
@@ -42,16 +39,39 @@ class Openstack(BaseModel):
     user_domain_name: Optional[str]
 
 
+class Docker(BaseModel):
+    server: str
+    user_name: str
+    password: str
+
+
+class Vmware(BaseModel):
+    user_name: str
+    password: str
+    server: str
+    allow_unverified_ssl: Optional[str]
+
+
 class Credentials(BaseModel):
     aws: Optional[Aws] = None
     azure: Optional[Azure] = None
     openstack: Optional[Openstack] = None
+    vmware: Optional[Vmware] = None
+    docker: Optional[Docker] = None
+
+
+class Bundle(BaseModel):
+    base64: str
 
 
 class DeploymentRequest(BaseModel):
     deployment_id: str
-    repository: str
-    commit: str
+    credentials: Credentials
+    bundle: Bundle
+
+
+class SelfHealingRequest(BaseModel):
+    deployment_id: str
     credentials: Credentials
 
 
diff --git a/iem-api/src/resources/id_iem b/iem-api/src/resources/id_iem
new file mode 100644
index 0000000..2f5c307
--- /dev/null
+++ b/iem-api/src/resources/id_iem
@@ -0,0 +1,38 @@
+-----BEGIN OPENSSH PRIVATE KEY-----
+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
+-----END OPENSSH PRIVATE KEY-----
diff --git a/iem-api/src/resources/id_iem.pub b/iem-api/src/resources/id_iem.pub
new file mode 100644
index 0000000..c4368a5
--- /dev/null
+++ b/iem-api/src/resources/id_iem.pub
@@ -0,0 +1 @@
+ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDV3VeYPD1WKQpq2PztNUQ0IAnm4JCmCPJpCLcem9kO3DvNbVBN5Q0JF0nfqgc8b1wP7uBH1b3G8jaX7Zsq6CpNu4OvXKw/EBxO3pdiAx6v0mRZMLBx2qcBd1TB90sxQ+juY6//06c52OgSjQ3Cey8a8/GKdtTMxRKh+rAuAch2WjcZBmTZPe+9E3fOhibHg5UA3sLhFcAPuEuEQRbTDBSsCqdCqHXfIe4NiLqJYVLOHkHuHfvTRaLROh7zmf20FmIP7s1gYU3GpDKRgZeuo2ZM9791N98B0JgSnepbx2/MDuG2WvAFwHIK7WSNVxGuxdvXspUWfDGam/G4ZGuxzaxVHtwYgSyxNoAUXcv82GS/MNLXqpfapvPR/LGcWAEcg1FgzjkC8y4MBZ6tQDowAYr/BEnUI7xmtIm0ixBWdd2zGtyMoSbvqHN0DSsRM+YDkD4XMcfd9Kp9kiIWHl1N9RSeV3D1JNfDGNGAE1qym25Ni0zWNbSyEUxpLkXj4T2wEDM=
diff --git a/iem-api/tests/__init__.py b/iem-api/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/iem-api/tests/it/__init__.py b/iem-api/tests/it/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/iem-api/tests/it/test_it_iem.py b/iem-api/tests/it/test_it_iem.py
new file mode 100644
index 0000000..eb545cb
--- /dev/null
+++ b/iem-api/tests/it/test_it_iem.py
@@ -0,0 +1,83 @@
+import base64
+import logging
+import os
+import unittest
+import uuid
+
+from src.core.iem import Iem
+from src.core.utils import Aws, Credentials, Openstack
+
+LOGGER = logging.getLogger(__name__)
+
+
+class TestIem(unittest.TestCase):
+    def __init__(self, *args, **kwargs):
+        super(TestIem, self).__init__(*args, **kwargs)
+
+        # Check IEM_HOME variable
+        self._iem_home = os.environ["IEM_HOME"]
+
+    @unittest.skipUnless(os.getenv("AWS"), "Define AWS variable to execute")
+    def test_deploy_destroy_aws(self):
+
+        deployment_id = str(uuid.uuid4())
+        a = Iem(
+            Credentials(
+                aws=Aws(
+                    access_key_id=os.environ["AWS_ACCESS_KEY_ID"],
+                    secret_access_key=os.environ["AWS_SECRET_ACCESS_KEY"],
+                )
+            )
+        )
+        with open("tests/resources/aws.zip", "rb") as binary_file:
+            bundle = base64.b64encode(binary_file.read())
+
+        a.deploy(deployment_id=deployment_id, bundle=bundle)
+
+        a.destroy(deployment_id=deployment_id)
+
+    @unittest.skipUnless(
+        os.getenv("INTEGRATION"), "Define INTEGRATION variable to execute"
+    )
+    def test_deploy_destroy_openstack(self):
+
+        deployment_id = str(uuid.uuid4())
+        a = Iem(
+            Credentials(
+                openstack=Openstack(
+                    user_name=os.environ["OS_USERNAME"],
+                    password=os.environ["OS_PASSWORD"],
+                    auth_url=os.environ["OS_AUTH_URL"],
+                    project_name=os.environ["OS_PROJECT_NAME"],
+                )
+            )
+        )
+        with open("tests/resources/openstack.zip", "rb") as binary_file:
+            bundle = base64.b64encode(binary_file.read())
+
+        a.deploy(deployment_id=deployment_id, bundle=bundle)
+
+        a.destroy(deployment_id=deployment_id)
+
+    @unittest.skipUnless(
+        os.getenv("INTEGRATION"), "Define INTEGRATION variable to execute"
+    )
+    def test_deploy_destroy_docker(self):
+
+        deployment_id = str(uuid.uuid4())
+        a = Iem(
+            Credentials(
+                openstack=Openstack(
+                    user_name=os.environ["OS_USERNAME"],
+                    password=os.environ["OS_PASSWORD"],
+                    auth_url=os.environ["OS_AUTH_URL"],
+                    project_name=os.environ["OS_PROJECT_NAME"],
+                )
+            )
+        )
+        with open("tests/resources/docker.zip", "rb") as binary_file:
+            bundle = base64.b64encode(binary_file.read())
+
+        a.deploy(deployment_id=deployment_id, bundle=bundle)
+
+        a.destroy(deployment_id=deployment_id)
diff --git a/iem-api/tests/resources/aws.zip b/iem-api/tests/resources/aws.zip
new file mode 100644
index 0000000000000000000000000000000000000000..0cc3d7449f463683c68ed584cc500b8d1a9d4014
GIT binary patch
literal 1248
zcmWIWW@h1H00A%O@G!5g-~2KR3=AO5%^<^&oS&DLnXXrvn41$C!pXoaBl<mAUi5o%
zX$3a}Bg+eB1_m&ptE;QPm6@1q#igL2t5A|!RFs&OUzE$Gr>Dn-upq%XJPc$J40AEa
zFu>L7gUxpl{hkcMXy${=5Mf|o;9$u0j0}#R#2lK(z`)SOz`!7aVn%*xNkM5zv0h0U
z*tEG3Wr<aot~Kp7<T`91!1BBL=pyzR6Yhl;Ogv{V&{cAf-A!oM<Y1QnwrZW*Wy_Y8
ztiJm|Wp%5}P3`Tyw~u<4RtQgsNS>S(WaPGEr+rxGj`JLvreUs?hs2JR?0z(5$K&m-
zTaD|a1>;`%Tm2Ds{=2JB=&j2aMugi%y&{8!o2G?aVqjnhW@KOxMsa&`PG)Le3C!uY
zBFhrbVmjS*f@9wy10L7!maR`~B|})wq&?$^S!mSht-RH@Fi1ekF#R~^+71!TU;lsK
zEKCy2YUcTn&d{=cvv}EAm50w{y>*Nj?kfM$$#0NI+sLyj!F}UrvlCyI3da1IE%wVG
z?f-xAvf00Wl+9DSzuZt;&LCWLo!8p;me(!vGT#YLYt)mRa@8`dyl>?yL*bU`ibe}R
zCi|E~xg9vM=<W7=4PH4rcSWs`Xz%LX*1K)5w&&aJGH!AUcbO~~JX7iF?1Ryq8CQHu
ze(F4MarP{^^L?S$Yqr9lcexS4^Tjzlj8CT3!iRx@A(nxGK@>H3&{ATw*!SdgF^rUW
z`Rdis2dB<xYv^tadJq(FA+&&pzpuQctc;hh?|WHq&-aqXZnoW4>^s{OJd2)ApVnY{
zt#IkHrRmR>Mrlr2A||T3?5X(EqL(jU7BVsfcr!A|G2_Y{65wRa$iTp`r4htL$ttXn
ztb&n@5e8yr28e+S3<3;)9TOQCP_hOq12fia0WuF)#(<ayao|!E^N_O%D+4nqn_zV*
z#AM85i)?ZtBeF}85-}?SGbj<`F&Hz#kqvHOLNORQ`dJy6LD7$4G%Fh@B3Kz%8P+p0
JFi10lcmQ#daJ>Kk

literal 0
HcmV?d00001

diff --git a/iem-api/tests/resources/docker.zip b/iem-api/tests/resources/docker.zip
new file mode 100644
index 0000000000000000000000000000000000000000..888efb0b9697997713ed9d800eb25fcf8bf989c9
GIT binary patch
literal 5400
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
b8&7m47vRmx1~Nd9L69Mije%haABYD4)j5y@

literal 0
HcmV?d00001

diff --git a/iem-api/tests/resources/dummy.zip b/iem-api/tests/resources/dummy.zip
new file mode 100644
index 0000000000000000000000000000000000000000..20d2bc346232701a6a840e0834e7709fdc67bab3
GIT binary patch
literal 3700
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
R2JkTOFodx%FmQki7y#hT4>te+

literal 0
HcmV?d00001

diff --git a/iem-api/tests/resources/openstack.zip b/iem-api/tests/resources/openstack.zip
new file mode 100644
index 0000000000000000000000000000000000000000..7d2d24ddd670766af89958f489b8cfceff66c99a
GIT binary patch
literal 3735
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
HaDjLLJSJi(

literal 0
HcmV?d00001

diff --git a/iem-api/tests/unit/__init__.py b/iem-api/tests/unit/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/iem-api/tests/unit/test_iem.py b/iem-api/tests/unit/test_iem.py
new file mode 100644
index 0000000..97fa04d
--- /dev/null
+++ b/iem-api/tests/unit/test_iem.py
@@ -0,0 +1,31 @@
+import logging
+import os
+import unittest
+import uuid
+
+from src.core.iem import Iem
+from src.core.utils import Aws, Credentials, Openstack
+
+LOGGER = logging.getLogger(__name__)
+
+
+class TestIem(unittest.TestCase):
+    def __init__(self, *args, **kwargs):
+        super(TestIem, self).__init__(*args, **kwargs)
+
+        # Check IEM_HOME variable
+        self._iem_home = os.environ["IEM_HOME"]
+
+    def test_get_all_deployments(self):
+        i = Iem()
+        all_deployments = i.get_all_deployments()
+        self.assertGreaterEqual(len(list(all_deployments)), 0)
+
+    def test_get_deployment_ok(self):
+        i = Iem()
+        i.get_deployment(deployment_id="deployment2")
+
+    def test_get_deployment_not_ok(self):
+        i = Iem()
+        deployment_id = str(uuid.uuid4())
+        i.get_deployment(deployment_id=deployment_id)
diff --git a/iem-api/tests/unit/test_main.py b/iem-api/tests/unit/test_main.py
new file mode 100644
index 0000000..7d05433
--- /dev/null
+++ b/iem-api/tests/unit/test_main.py
@@ -0,0 +1,106 @@
+import base64
+import os
+import unittest
+import uuid
+from unittest.mock import Mock, patch
+
+from fastapi.testclient import TestClient
+
+from main import app
+
+
+class TestMain(unittest.TestCase):
+    def __init__(self, *args, **kwargs):
+        super(TestMain, self).__init__(*args, **kwargs)
+
+        self.client = TestClient(app)
+
+        # Check API_KEY variable
+        self._api_key = os.environ["API_KEY"]
+
+    def test_root_no_apikey(self):
+        response = self.client.get("/")
+        assert response.status_code == 403
+
+    def test_root(self):
+        response = self.client.get("/", headers={"x-api-key": self._api_key})
+        assert response.status_code == 200
+
+    def test_get_all_deployments(self):
+        response = self.client.get(
+            f"/deployments/", headers={"x-api-key": self._api_key}
+        )
+        assert response.status_code == 200
+
+    def test_get_one_deployment(self):
+        response = self.client.get(
+            f"/deployments/25/", headers={"x-api-key": self._api_key}
+        )
+        assert response.status_code == 200
+
+    @patch("subprocess.run")
+    def test_deployments(self, mock_run):
+        mock_run.return_value = Mock(returncode=0, stdout=b"{}", stderr=b"{}")
+        with open("tests/resources/dummy.zip", "rb") as binary_file:
+            bundle = base64.b64encode(binary_file.read())
+        response = self.client.post(
+            f"/deployments/",
+            headers={"x-api-key": self._api_key},
+            json={
+                "deployment_id": str(uuid.uuid4()),
+                "credentials": {
+                    "openstack": {
+                        "user_name": "string",
+                        "password": "string",
+                        "auth_url": "string",
+                        "project_name": "string",
+                        "region_name": "string",
+                        "domain_name": "string",
+                        "project_domain_name": "string",
+                        "user_domain_name": "string",
+                    }
+                },
+                "bundle": {"base64": bundle.decode("utf-8")},
+            },
+        )
+        assert response.status_code == 201
+
+    @patch("subprocess.run")
+    def test_undeploy(self, mock_run):
+        mock_run.return_value = Mock(returncode=0, stdout=b"{}", stderr=b"{}")
+
+        deployment_id = str(uuid.uuid4())
+
+        with open("tests/resources/dummy.zip", "rb") as binary_file:
+            bundle = base64.b64encode(binary_file.read())
+        response = self.client.post(
+            f"/deployments/",
+            headers={"x-api-key": self._api_key},
+            json={
+                "deployment_id": deployment_id,
+                "credentials": {
+                    "openstack": {
+                        "user_name": "string",
+                        "password": "string",
+                        "auth_url": "string",
+                        "project_name": "string",
+                        "region_name": "string",
+                        "domain_name": "string",
+                        "project_domain_name": "string",
+                        "user_domain_name": "string",
+                    }
+                },
+                "bundle": {"base64": bundle.decode("utf-8")},
+            },
+        )
+        assert response.status_code == 201
+
+        response = self.client.post(
+            f"/undeploy/",
+            headers={"x-api-key": self._api_key},
+            json={
+                "deployment_id": deployment_id,
+                "credentials": {},
+            },
+        )
+        assert response.status_code == 202
diff --git a/iem-api/tests/unit/test_persistence.py b/iem-api/tests/unit/test_persistence.py
new file mode 100644
index 0000000..6683e8b
--- /dev/null
+++ b/iem-api/tests/unit/test_persistence.py
@@ -0,0 +1,32 @@
+import unittest
+import uuid
+
+from src.core.persistence import Persistence
+
+
+class TestPersistence(unittest.TestCase):
+    @classmethod
+    def setUpClass(self):
+        self.engine_url = "sqlite:///:memory:"
+
+    def test_insert_deployment(self):
+        Persistence(engine_url=self.engine_url).insert_deployment(
+            "deployment1", "STARTED", "stdout", "stderr"
+        )
+
+    def test_get_deployment(self):
+        p = Persistence(engine_url=self.engine_url)
+        p.insert_deployment("deployment2", "STOPPED", "stdout", "stderr")
+        p.insert_deployment("deployment2", "STARTED", "stdout", "stderr")
+        d = p.get_deployment("deployment2")
+        self.assertEqual(d.status, "STARTED")
+
+    def test_get_deployment_not_ok(self):
+        row = Persistence(engine_url=self.engine_url).get_deployment(str(uuid.uuid4()))
+        self.assertIsNone(row)
+
+    def test_get_all_deployments(self):
+        persistence = Persistence(engine_url=self.engine_url)
+        persistence.insert_deployment("deployment3", "STARTED", "stdout", "stderr")
+        rows = persistence.get_all_deployments()
+        self.assertGreaterEqual(len(list(rows)), 1)
-- 
GitLab