diff --git a/iem-api/.python-version b/iem-api/.python-version index f69abe410a3c3fe2bf44a95f192f3c915ef19e1a..0a590336d5996461bb614a2b45ad675376e2d4d6 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 635043b24d1dd1182614380f044d470f0af21803..eb07ab2e9dc86885354370d98f927176d6701405 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 0000000000000000000000000000000000000000..a3dd79d71dd647b0bf2b82cc61266647e7d979bc --- /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 0000000000000000000000000000000000000000..86a197bf1b6772c1eda0f7ac9d77918997b54a4a --- /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 0000000000000000000000000000000000000000..245caaf060f0a8a06f315a8ba009f65b1e429d18 --- /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 0000000000000000000000000000000000000000..8d96d78ffb43f7e6e74d5dab1726cd346f6d23ba --- /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 0000000000000000000000000000000000000000..7a1ca118cbc13a3b9c05bb00e579c9a043a49a3c --- /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 3fb84f4b479de3ea5635bd2ca4b3138d7bed5a1a..01bf7c09da67b33a9c27f6594a0ebec5562b0d86 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 08f25cb557bd109225b33d981be86000d83c3214..6eac90bca0006b3105982302190202fec96974b7 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 e4ad215022efdf497b2358ff720cde87a7f155f2..d5a40dc2c717d248a082dc76b47e7d85a323ade9 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 2f4979e53ff3085ed75c5d8bf5e6ab17a120314b..80ada9c155c49e20b31ffcf719d132dc66973758 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 5ec5df2b1914207333f99027ccb25371cea6b9ea..683afb2738b6b896dcf2b49840d983047148bfba 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 0000000000000000000000000000000000000000..2f5c3078d02d507c7dd8a41c16ce3a24b897dac3 --- /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 0000000000000000000000000000000000000000..c4368a5642c47e33f57f5df4e56bd874aebcccad --- /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 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/iem-api/tests/it/__init__.py b/iem-api/tests/it/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 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 0000000000000000000000000000000000000000..eb545cb33c427accf7ed2829ea71529ae9c50629 --- /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 Binary files /dev/null and b/iem-api/tests/resources/aws.zip differ diff --git a/iem-api/tests/resources/docker.zip b/iem-api/tests/resources/docker.zip new file mode 100644 index 0000000000000000000000000000000000000000..888efb0b9697997713ed9d800eb25fcf8bf989c9 Binary files /dev/null and b/iem-api/tests/resources/docker.zip differ diff --git a/iem-api/tests/resources/dummy.zip b/iem-api/tests/resources/dummy.zip new file mode 100644 index 0000000000000000000000000000000000000000..20d2bc346232701a6a840e0834e7709fdc67bab3 Binary files /dev/null and b/iem-api/tests/resources/dummy.zip differ diff --git a/iem-api/tests/resources/openstack.zip b/iem-api/tests/resources/openstack.zip new file mode 100644 index 0000000000000000000000000000000000000000..7d2d24ddd670766af89958f489b8cfceff66c99a Binary files /dev/null and b/iem-api/tests/resources/openstack.zip differ diff --git a/iem-api/tests/unit/__init__.py b/iem-api/tests/unit/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/iem-api/tests/unit/test_iem.py b/iem-api/tests/unit/test_iem.py new file mode 100644 index 0000000000000000000000000000000000000000..97fa04dad9e5c134fc3c5ed2996bc7a5ebdf6746 --- /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 0000000000000000000000000000000000000000..7d05433f46c71e7afa50338565d3fbf90d9ea5ad --- /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 0000000000000000000000000000000000000000..6683e8b6e740dca89d0d0c98358ba7f0b4e23c87 --- /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)