From 8afb8593d65077568150c9cceca1d37ab308c065 Mon Sep 17 00:00:00 2001 From: adriano Date: Mon, 15 Jan 2024 09:32:53 -0300 Subject: [PATCH] feat: Initial commit for Flask API --- .gitignore | 132 +++++++++++++++++ app.py | 6 + app/__init__.py | 29 ++++ app/controllers/__init__.py | 0 app/controllers/container.py | 268 +++++++++++++++++++++++++++++++++++ app/utils/authorization.py | 21 +++ requirements.txt | 29 ++++ 7 files changed, 485 insertions(+) create mode 100644 .gitignore create mode 100644 app.py create mode 100644 app/__init__.py create mode 100644 app/controllers/__init__.py create mode 100644 app/controllers/container.py create mode 100644 app/utils/authorization.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ab9463e --- /dev/null +++ b/.gitignore @@ -0,0 +1,132 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + + +.idea \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..f127253 --- /dev/null +++ b/app.py @@ -0,0 +1,6 @@ +from app import app +from os import environ + +if __name__ == '__main__': + SERVER_HOST = environ.get('SERVER_HOST', 'localhost') + app.run(host=SERVER_HOST, port=5500, debug=(not environ.get('ENV') == 'DEVELOPMENT'),threaded=True) \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..78ae028 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,29 @@ +from flask import Flask, Blueprint +from flask_restplus import Api +from werkzeug.middleware.proxy_fix import ProxyFix +from app.controllers.container import api as home_ns +from app.utils.authorization import token_required + +app = Flask(__name__) +app.wsgi_app = ProxyFix(app.wsgi_app) +blueprint = Blueprint('api', __name__) +app.register_blueprint(blueprint) + +authorizations = { + 'apikey': { + 'name': "X-API-KEY", + 'in': "header", + 'type': "apiKey", + 'description': "Insert your Token here!" + } +} +api = Api(app, + title='Proxmox container rest API', + version='1.0', + description='The Proxmox Container Management API allows users to interact with Proxmox using its REST API for container management. Key functionalities include creating, updating, editing, deleting, and retrieving information about containers.', + prefix='/api', + authorizations=authorizations, + security='apiKey') + + +api.add_namespace(home_ns, path='/container') diff --git a/app/controllers/__init__.py b/app/controllers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/controllers/container.py b/app/controllers/container.py new file mode 100644 index 0000000..fa877e1 --- /dev/null +++ b/app/controllers/container.py @@ -0,0 +1,268 @@ + +from app.utils.authorization import token_required +from flask_restplus import Resource, Namespace, fields +from flask import request, jsonify +import requests +import time +from flask_restx import fields +from functools import wraps +import os +from dotenv import load_dotenv +load_dotenv() + + +api = Namespace('Container', description='Containers creation and manager') + +API_BASE_URL = f'https://{os.getenv("PROXMOX_NODE_IP")}:8006/api2/json/nodes/{os.getenv("PROXMOX_NODE_NAME")}/lxc' +API_AUTH = f'https://{os.getenv("PROXMOX_NODE_IP")}:8006/api2/json/access/ticket' +CREDENTIAL = { + "username": f"{os.getenv('API_USER')}@{os.getenv('PROXMOX_NODE_NAME')}", "password": os.getenv("API_USER_PASSWORD")} + + +def get_data(endpoint, data): + response = requests.post(endpoint, data=data, verify=False) + response.raise_for_status() + return response.json()["data"] + + +# Get ticket and CSRFPreventionToken +ticket = get_data(API_AUTH, CREDENTIAL)["ticket"] +csrf_token = get_data(API_AUTH, CREDENTIAL)["CSRFPreventionToken"] + + +# Function to retrieve information about all containers +def list_all_containers(): + endpoint = f"{API_BASE_URL}" + response = requests.get( + endpoint, cookies={"PVEAuthCookie": ticket}, verify=False) + response.raise_for_status() + + containers = response.json()["data"] + return containers + + +# Function to retrieve information about a specific container by ID +def get_container_info(container_id): + endpoint = f"{API_BASE_URL}/{container_id}/config" + response = requests.get( + endpoint, cookies={"PVEAuthCookie": ticket}, verify=False) + response.raise_for_status() + is_container_locked(container_id) + container_info = response.json()["data"] + + return container_info + + +# Function to check if a container is locked +def is_container_locked(container_id): + endpoint = f"{API_BASE_URL}/{container_id}/status/current" + response = requests.get( + endpoint, cookies={"PVEAuthCookie": ticket}, verify=False) + response.raise_for_status() + if 'lock' in response.json()["data"]: + return {"locked": True} + else: + return {"locked": False} + + +# Decorator for handling exceptions in routes +def handle_exceptions(func): + @wraps(func) + def decorated_function(*args, **kwargs): + try: + return func(*args, **kwargs) + except requests.HTTPError as e: + return {"error": f"HTTP error occurred: {str(e)}"}, e.response.status_code + except requests.RequestException as e: + return {"error": f"Request error occurred: {str(e)}"}, 500 + except Exception as e: + return {"error": f"Internal server error occurred: {str(e)}"}, 500 + return decorated_function + + +@api.route('/') +class ContainerListAll(Resource): + @api.response(200, "Success") + @api.doc('some operation', security='apikey') + @token_required + @handle_exceptions + def get(self): + return list_all_containers(), 200 + + +@api.route('/') +class ContainerIdInfo(Resource): + @api.response(200, "Success") + @api.doc('some operation', security='apikey') + @token_required + @api.doc(params={'id': 'The container id'},) + @handle_exceptions + def get(self, id): + return get_container_info(id), 200 + + +@api.route('//') +class ContainerId(Resource): + @api.response(200, "Success") + @api.doc('some operation', security='apikey') + @token_required + @api.doc(params={'id': 'The container id'},) + @api.doc(params={'command': 'start, stop, delete'},) + @handle_exceptions + def get(self, id: int, command: str): + + commands = ['start', 'stop', 'delete'] + + if (command not in commands): + return {"error": f"Bad Request: Invalid command passed in your route'{command}'. Valid commands are {', '.join(commands)}"}, 400 + + if command in ["start", "stop"]: + endpoint = f"{API_BASE_URL}/{id}/status/{command}" + response = requests.post(endpoint, cookies={"PVEAuthCookie": ticket}, headers={ + "CSRFPreventionToken": csrf_token}, verify=False) + response.raise_for_status() + + elif command == "delete": + endpoint = f"{API_BASE_URL}/{id}" + response = requests.delete(endpoint, cookies={"PVEAuthCookie": ticket}, headers={ + "CSRFPreventionToken": csrf_token}, verify=False) + response.raise_for_status() + + return {'message': 'success'}, 200 + + +@api.route('//edit') +class ContainerIdEdit(Resource): + + payload_model = api.model('ContainerEditModel', { + "nameserver": fields.String(example="8.8.8.8,4.4.4.4"), + "searchdomain": fields.String(example="hittelco.com.br"), + }) + + @api.response(200, "Success") + @api.doc('some operation', security='apikey') + @token_required + @api.doc(params={'id': 'The container id'},) + @api.expect(payload_model, validate=True) + @handle_exceptions + def put(self, id: int): + data = request.json + endpoint = f"{API_BASE_URL}/{id}/config" + response = requests.put(endpoint, cookies={"PVEAuthCookie": ticket}, headers={ + "CSRFPreventionToken": csrf_token}, data=data, verify=False) + response.raise_for_status() + + return {'message': 'success'}, 200 + + +@api.route('/create/up') +class ContainerCreateUp(Resource): + + payload_model = api.model('ContainerCreateUpModel', { + "net0": fields.String(example="name=tnetVM_ID,bridge=vmbr0"), + "ostemplate": fields.String(example="local:vztmpl/ubuntu-22.04-standard_22.04-1_amd64.tar.zst"), + "storage": fields.String(example="local"), + "cores": fields.String(example="1"), + "cpuunits": fields.String(example="512"), + "memory": fields.String(example="512"), + "swap": fields.String(example="0"), + "password": fields.String(example="91472432"), + "hostname": fields.String(example="ctnodeapi"), + "nameserver": fields.String(example="8.8.8.8,4.4.4.4"), + "searchdomain": fields.String(example="hittelco.com.br"), + }) + + @api.response(201, "Success") + + @api.doc('some operation', security='apikey') + @token_required + @api.expect(payload_model, validate=True) + @handle_exceptions + def post(self,): + data = request.json + + vm_id = 1 + + vm_id_list = sorted( + list(map(lambda x: int(x.get("vmid")), list_all_containers()))) + print('vm_id_list: ', vm_id_list) + + if (len(vm_id_list) > 0): + vm_id = vm_id_list[-1] + 1 + + for key, value in data.items(): + if key == 'vmid': + return {"error": f'Bad Request: The "vmid" property is automatically setting'}, 400 + + if key in ["cores", "cpuunits", "memory", "swap"]: + data[key] = int(value) + + if key == 'hostname': + data[key] = value+f'{vm_id}' + + if 'VM_ID' in value: + data[key] = value.replace('VM_ID', f'{vm_id}') + + aux_data = { + "vmid": vm_id + } + + data = {**data, **aux_data} + + # Create container + endpoint = f"{API_BASE_URL}" + response = requests.post(endpoint, cookies={"PVEAuthCookie": ticket}, headers={ + "CSRFPreventionToken": csrf_token}, data=data, verify=False) + response.raise_for_status() + print(f'Container id {vm_id} created!') + + # Check each 3 seconds if the container is locked before start + while is_container_locked(vm_id)['locked']: + print('*********** LOCKED ************') + time.sleep(5) + + # Start container + endpoint = f"{API_BASE_URL}/{vm_id}/status/start" + response = requests.post(endpoint, cookies={"PVEAuthCookie": ticket}, headers={ + "CSRFPreventionToken": csrf_token}, verify=False) + response.raise_for_status() + print(f'Container id {vm_id} started!') + + return {'message': 'success', 'id': vm_id}, 201 + + +@api.route('/create') +class ContainerCreate(Resource): + + payload_model = api.model('ContainerCreateModel', { + "net0": fields.String(example="name=tnet538,bridge=vmbr0"), + "ostemplate": fields.String(example="local:vztmpl/ubuntu-22.04-standard_22.04-1_amd64.tar.zst"), + "storage": fields.String(example="local"), + "vmid": fields.String(example="538"), + "cores": fields.String(example="1"), + "cpuunits": fields.String(example="512"), + "memory": fields.String(example="512"), + "swap": fields.String(example="0"), + "password": fields.String(example="988325936"), + "hostname": fields.String(example="ctnodeapi538"), + "nameserver": fields.String(example="8.8.8.8,4.4.4.4"), + "searchdomain": fields.String(example="hittelco.com.br"), + }) + + @api.response(201, "Success") + @api.doc('some operation', security='apikey') + @token_required + @api.expect(payload_model, validate=True) + @handle_exceptions + def post(self,): + data = request.json + + for key, value in data.items(): + if key in ["cores", "cpuunits", "memory", "swap", "vmid"]: + data[key] = int(value) + + endpoint = f"{API_BASE_URL}" + response = requests.post(endpoint, cookies={"PVEAuthCookie": ticket}, headers={ + "CSRFPreventionToken": csrf_token}, data=data, verify=False) + response.raise_for_status() + return {'message': 'success'}, 201 diff --git a/app/utils/authorization.py b/app/utils/authorization.py new file mode 100644 index 0000000..cfb568b --- /dev/null +++ b/app/utils/authorization.py @@ -0,0 +1,21 @@ +from functools import wraps +from flask import request +import os + + +def token_required(f): + @wraps(f) + def decorated(*args, **kwargs): + print('request.headers: ', request.headers) + + token = None + if 'x-api-key' in request.headers: + token = request.headers['x-api-key'] + if not token: + return {'message': "Token not found"}, 401 + if token != os.getenv('TOKEN'): + return {'message': "Invalid token"}, 401 + + return f(*args, **kwargs) + + return decorated diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..10a98d3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,29 @@ +aniso8601==8.0.0 +attrs>=22.2.0 +blinker==1.7.0 +certifi==2020.6.20 +chardet==3.0.4 +click==7.1.2 +Flask==1.1.2 +flask-restplus==0.13.0 +flask-restx==1.3.0 +Flask-Testing==0.8.0 +idna==2.10 +importlib-metadata==1.7.0 +importlib-resources==6.1.1 +itsdangerous==1.1.0 +Jinja2==2.11.2 +jsonschema==3.2.0 +jsonschema-specifications==2023.12.1 +MarkupSafe==1.1.1 +pkgutil_resolve_name==1.3.10 +pyrsistent==0.16.0 +python-dotenv==1.0.0 +pytz==2020.1 +referencing==0.32.1 +requests==2.24.0 +rpds-py==0.16.2 +six==1.15.0 +urllib3==1.25.9 +Werkzeug==0.16.1 +zipp==3.1.0