From 8da3e2bf9f438f3d2418df1c36199453bc71293e Mon Sep 17 00:00:00 2001 From: moisses89 <7888669+moisses89@users.noreply.github.com> Date: Thu, 22 Feb 2024 13:56:44 +0100 Subject: [PATCH] Setup project --- .dockerignore | 12 + .env.sample | 7 + .env.test | 9 + .gitattributes | 1 + .github/CODEOWNERS | 3 + .github/ISSUE_TEMPLATE/bug_report.md | 30 ++ .github/ISSUE_TEMPLATE/feature_request.md | 34 ++ .github/PULL_REQUEST_TEMPLATE.md | 10 + .github/dependabot.yml | 25 ++ .github/release.yml | 15 + .github/workflows/cla.yml | 36 ++ .github/workflows/python.yml | 121 ++++++ .gitignore | 127 +++++++ .pre-commit-config.yaml | 28 ++ README.md | 35 ++ config/__init__.py | 3 + config/celery_app.py | 41 ++ config/gunicorn.py | 8 + config/settings/__init__.py | 0 config/settings/base.py | 352 ++++++++++++++++++ config/settings/local.py | 49 +++ config/settings/production.py | 32 ++ config/settings/test.py | 42 +++ config/urls.py | 86 +++++ config/wsgi.py | 42 +++ docker-compose.yml | 60 +++ docker/nginx/nginx.conf | 68 ++++ docker/web/Dockerfile | 38 ++ docker/web/celery/scheduler/run.sh | 18 + docker/web/celery/worker/run.sh | 37 ++ docker/web/run_web.sh | 12 + gunicorn.conf.py | 24 ++ gunicorn_custom_workers.py | 27 ++ manage.py | 30 ++ requirements-dev.txt | 9 + requirements-test.txt | 12 + requirements.txt | 22 ++ run_tests.sh | 14 + safe_locking_service/__init__.py | 5 + .../locking_events/__init__.py | 0 safe_locking_service/locking_events/admin.py | 3 + safe_locking_service/locking_events/apps.py | 7 + .../locking_events/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../locking_events/migrations/__init__.py | 0 safe_locking_service/locking_events/models.py | 3 + safe_locking_service/locking_events/tasks.py | 1 + safe_locking_service/locking_events/tests.py | 3 + safe_locking_service/locking_events/urls.py | 9 + safe_locking_service/locking_events/views.py | 25 ++ safe_locking_service/static/.gitignore | 0 safe_locking_service/static/safe/favicon.png | Bin 0 -> 53183 bytes safe_locking_service/static/safe/logo.png | Bin 0 -> 3367 bytes safe_locking_service/static/safe/logo.svg | 1 + .../static/safe/safe_contract_logo.png | Bin 0 -> 755 bytes .../templates/drf-yasg/swagger-ui.html | 25 ++ safe_locking_service/utils/__init__.py | 0 safe_locking_service/utils/exceptions.py | 39 ++ safe_locking_service/utils/loggers.py | 60 +++ setup.cfg | 60 +++ 60 files changed, 1760 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.sample create mode 100644 .env.test create mode 100644 .gitattributes create mode 100644 .github/CODEOWNERS create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/dependabot.yml create mode 100644 .github/release.yml create mode 100644 .github/workflows/cla.yml create mode 100644 .github/workflows/python.yml create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 README.md create mode 100644 config/__init__.py create mode 100644 config/celery_app.py create mode 100644 config/gunicorn.py create mode 100644 config/settings/__init__.py create mode 100644 config/settings/base.py create mode 100644 config/settings/local.py create mode 100644 config/settings/production.py create mode 100644 config/settings/test.py create mode 100644 config/urls.py create mode 100644 config/wsgi.py create mode 100644 docker-compose.yml create mode 100644 docker/nginx/nginx.conf create mode 100644 docker/web/Dockerfile create mode 100755 docker/web/celery/scheduler/run.sh create mode 100755 docker/web/celery/worker/run.sh create mode 100755 docker/web/run_web.sh create mode 100644 gunicorn.conf.py create mode 100644 gunicorn_custom_workers.py create mode 100644 manage.py create mode 100644 requirements-dev.txt create mode 100644 requirements-test.txt create mode 100644 requirements.txt create mode 100755 run_tests.sh create mode 100644 safe_locking_service/__init__.py create mode 100644 safe_locking_service/locking_events/__init__.py create mode 100644 safe_locking_service/locking_events/admin.py create mode 100644 safe_locking_service/locking_events/apps.py create mode 100644 safe_locking_service/locking_events/management/__init__.py create mode 100644 safe_locking_service/locking_events/management/commands/__init__.py create mode 100644 safe_locking_service/locking_events/migrations/__init__.py create mode 100644 safe_locking_service/locking_events/models.py create mode 100644 safe_locking_service/locking_events/tasks.py create mode 100644 safe_locking_service/locking_events/tests.py create mode 100644 safe_locking_service/locking_events/urls.py create mode 100644 safe_locking_service/locking_events/views.py create mode 100644 safe_locking_service/static/.gitignore create mode 100644 safe_locking_service/static/safe/favicon.png create mode 100755 safe_locking_service/static/safe/logo.png create mode 100644 safe_locking_service/static/safe/logo.svg create mode 100644 safe_locking_service/static/safe/safe_contract_logo.png create mode 100644 safe_locking_service/templates/drf-yasg/swagger-ui.html create mode 100644 safe_locking_service/utils/__init__.py create mode 100644 safe_locking_service/utils/exceptions.py create mode 100644 safe_locking_service/utils/loggers.py create mode 100644 setup.cfg diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0a407ba --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.cache +.dockerignore +.gitignore +.git +.github +.env +.pylintrc +__pycache__ +*.pyc +*.egg-info +.idea/ +.vscode diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..86879c9 --- /dev/null +++ b/.env.sample @@ -0,0 +1,7 @@ +PYTHONPATH=/app/ +DEBUG=0 +DJANGO_SETTINGS_MODULE=config.settings.test +DJANGO_SECRET_KEY=t3st-s3cr3t#-!k3y +ETH_HASH_BACKEND=pysha3 + +DATABASE_URL=psql://postgres:postgres@db:5432/postgres diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..fabd26c --- /dev/null +++ b/.env.test @@ -0,0 +1,9 @@ +PYTHONPATH=/app/ +DEBUG=0 +DJANGO_SETTINGS_MODULE=config.settings.test +DJANGO_SECRET_KEY=t3st-s3cr3t#-!k3y +ETH_HASH_BACKEND=pysha3 + +DATABASE_URL=psql://postgres:postgres@db:5432/postgres +# Only required for testing +ETHEREUM_MAINNET_NODE=https://ethereum.publicnode.com diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..176a458 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..26d7482 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,3 @@ +# These owners will be the default owners for everything in +# the repo. Unless a later match takes precedence. +* @safe-global/core-api diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..5504f1c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,30 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: bug + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Do POST on '...' + - Provide `json` you are submitting to the service (if it applies) +2. Then GET on '....' +3. Links to issues in other repos (if possible) + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Environment (please complete the following information):** + - Staging or production? + - Which chain? + - OS: [e.g. iOS] + - Browser [e.g. chrome, safari] + - Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..67a2881 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,34 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: enhancement + +--- + +# What is needed? +A clear and concise description of what you want to happen. + +# Background +More information about the feature needed + +# Related issues +Paste here the related links for the issues on the clients/safe project if applicable. Please provide at least one of the following: +- Links to epics in your repository +- Images taken from mocks +- Gitbook or any form of written documentation links, etc. Any of these alternatives will help us contextualise your request. + +# Endpoint +If applicable, description on the endpoint and the result you expect: + +## URL +`GET /api/v1/safes/
/creation/` + +## Response +``` +{ + created: "", + transactionHash: "", + creator: "" +} +``` diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..32d5f19 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,10 @@ +### Make sure these boxes are checked! 📦✅ + +- [ ] You ran `./run_tests.sh` +- [ ] You ran `pre-commit run -a` + +### What was wrong? 👾 + +Closes # + +### How was it fixed? 🎯 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..2a5f193 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,25 @@ +version: 2 +updates: + - package-ecosystem: pip + directory: "/" + schedule: + interval: weekly + day: monday + reviewers: + - "safe-global/safe-services" + + - package-ecosystem: docker + directory: "/docker/web" + schedule: + interval: weekly + day: monday + reviewers: + - "safe-global/safe-services" + + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + day: monday + reviewers: + - "safe-global/safe-services" diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000..1c0307d --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,15 @@ +changelog: + categories: + - title: 🏕 Features + labels: + - '*' + exclude: + labels: + - dependencies + - breaking_change + - title: 🛠 Breaking Changes + labels: + - breaking_change + - title: 👒 Dependencies + labels: + - dependencies diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml new file mode 100644 index 0000000..c12a7e7 --- /dev/null +++ b/.github/workflows/cla.yml @@ -0,0 +1,36 @@ +name: "CLA Assistant" +on: + issue_comment: + types: [ created ] + pull_request_target: + types: [ opened,closed,synchronize ] + +jobs: + CLAssistant: + runs-on: ubuntu-latest + steps: + - name: "CLA Assistant" + if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target' + # Beta Release + uses: cla-assistant/github-action@v2.3.1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # the below token should have repo scope and must be manually added by you in the repository's secret + PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} + with: + path-to-signatures: 'signatures/version1/cla.json' + path-to-document: 'https://safe.global/cla/' + # branch should not be protected + branch: 'cla-signatures' + allowlist: hectorgomezv,moisses89,luarx,fmrsabino,luarx,rmeissner,Uxio0,*bot # may need to update this expression if we add new bots + + #below are the optional inputs - If the optional inputs are not given, then default values will be taken + #remote-organization-name: enter the remote organization name where the signatures should be stored (Default is storing the signatures in the same repository) + #remote-repository-name: enter the remote repository name where the signatures should be stored (Default is storing the signatures in the same repository) + #create-file-commit-message: 'For example: Creating file for storing CLA Signatures' + #signed-commit-message: 'For example: $contributorName has signed the CLA in #$pullRequestNo' + #custom-notsigned-prcomment: 'pull request comment with Introductory message to ask new contributors to sign' + #custom-pr-sign-comment: 'The signature to be committed in order to sign the CLA' + #custom-allsigned-prcomment: 'pull request comment when all contributors has signed, defaults to **CLA Assistant Lite bot** All Contributors have signed the CLA.' + #lock-pullrequest-aftermerge: false - if you don't want this bot to automatically lock the pull request after merging (default - true) + #use-dco-flag: true - If you are using DCO instead of CLA diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml new file mode 100644 index 0000000..68e2fc1 --- /dev/null +++ b/.github/workflows/python.yml @@ -0,0 +1,121 @@ +name: Python CI +on: + push: + branches: + - main + - develop + pull_request: + release: + types: [ released ] + +jobs: + linting: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11"] + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: pip install pre-commit + - name: Run pre-commit + run: pre-commit run --all-files + + test-app: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11"] + steps: + - name: Setup and run ganache + run: | + docker run --detach --publish 8545:8545 --network-alias ganache -e DOCKER=true trufflesuite/ganache:latest --defaultBalanceEther 10000 --gasLimit 10000000 -a 30 --chain.chainId 1337 --chain.networkId 1337 -d + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + cache-dependency-path: 'requirements*.txt' + - name: Install dependencies + run: | + pip install wheel + pip install -r requirements-test.txt + env: + PIP_USE_MIRRORS: true + - name: Run tests and coverage + run: | + python manage.py check + # python manage.py makemigrations --check --dry-run + coverage run --source=$SOURCE_FOLDER -m pytest -rxXs --reruns 3 + env: + SOURCE_FOLDER: safe_locking_service + DJANGO_SETTINGS_MODULE: config.settings.test + ETHEREUM_MAINNET_NODE: ${{ secrets.ETHEREUM_MAINNET_NODE }} + ETHEREUM_NODES_URLS: http://localhost:8545 + ETH_HASH_BACKEND: pysha3 + - name: Coveralls + uses: coverallsapp/github-action@v2 + docker-deploy: + runs-on: ubuntu-latest + needs: + - linting + - test-app + if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' || (github.event_name == 'release' && github.event.action == 'released') + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-qemu-action@v3 + with: + platforms: arm64 + - uses: docker/setup-buildx-action@v3 + - name: Dockerhub login + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USER }} + password: ${{ secrets.DOCKER_PASSWORD }} + - name: Deploy main + if: github.ref == 'refs/heads/main' + uses: docker/build-push-action@v5 + with: + context: . + file: docker/web/Dockerfile + push: true + tags: safeglobal/safe-locking-service:staging + platforms: | + linux/amd64 + linux/arm64 + cache-from: type=gha + cache-to: type=gha,mode=max + - name: Deploy Develop + if: github.ref == 'refs/heads/develop' + uses: docker/build-push-action@v5 + with: + context: . + file: docker/web/Dockerfile + push: true + tags: safeglobal/safe-locking-service:develop + platforms: | + linux/amd64 + linux/arm64 + cache-from: type=gha + cache-to: type=gha,mode=max + - name: Deploy Tag + if: (github.event_name == 'release' && github.event.action == 'released') + uses: docker/build-push-action@v5 + with: + context: . + file: docker/web/Dockerfile + push: true + tags: | + safeglobal/safe-locking-service:${{ github.event.release.tag_name }} + safeglobal/safe-locking-service:latest + platforms: | + linux/amd64 + linux/arm64 + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3297b71 --- /dev/null +++ b/.gitignore @@ -0,0 +1,127 @@ +# 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/ +*.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/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# 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/ + +# Django +staticfiles/ +safe_locking_service/media + +### VisualStudioCode template +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# Provided default Pycharm Run/Debug Configurations should be tracked by git +# In case of local modifications made by Pycharm, use update-index command +# for each changed file, like this: +# git update-index --assume-unchanged .idea/safe_price_service.iml +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff: +.idea/ +.vscode/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..4a96652 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,28 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +repos: + - repo: https://github.com/PyCQA/isort + rev: 5.12.0 + hooks: + - id: isort + - repo: https://github.com/psf/black + rev: 23.11.0 + hooks: + - id: black + - repo: https://github.com/PyCQA/flake8 + rev: 6.1.0 + hooks: + - id: flake8 + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: check-docstring-first + - id: check-merge-conflict + - id: debug-statements + - id: detect-private-key + - id: requirements-txt-fixer + - id: trailing-whitespace + - id: end-of-file-fixer + types: [python] + - id: check-yaml + - id: check-added-large-files diff --git a/README.md b/README.md new file mode 100644 index 0000000..99f54b4 --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +[![Python CI](https://github.com/safe-global/safe-locking-service/actions/workflows/python.yml/badge.svg?branch=main)](https://github.com/safe-global/safe-locking-service/actions/workflows/python.yml) +[![Coverage Status](https://coveralls.io/repos/github/safe-global/safe-locking-service/badge.svg?branch=main)](https://coveralls.io/github/safe-global/safe-locking-service?branch=main) +[![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)](https://github.com/pre-commit/pre-commit) +![Python 3.11](hhttps://img.shields.io/badge/Python-3.11-blue.svg) +![Django 5](https://img.shields.io/badge/Django-5-blue.svg) +[![Docker Image Version (latest semver)](https://img.shields.io/docker/v/safeglobal/safe-locking-service?label=Docker&sort=semver)](https://hub.docker.com/r/safeglobal/safe-locking-service) + +# Safe Locking Service + +Keeps track emitted events by Safe token locking contract. https://github.com/safe-global/safe-locking + +## Configuration +```bash +cp .env.sample .env +``` + +Configure environment variables on `.env`: + +- `DJANGO_SECRET_KEY`: **IMPORTANT: Update it with a secure generated string**. +- `ETHEREUM_NODES_URLS`: RPC node url. + +## Execution + +```bash +docker compose build +docker compose up +``` + +Then go to http://localhost:8000 to see the service documentation. + +## Endpoints +To be defined + +## Contributors +[See contributors](https://github.com/safe-global/safe-locking-service/graphs/contributors) diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..6d300d0 --- /dev/null +++ b/config/__init__.py @@ -0,0 +1,3 @@ +from .celery_app import app as celery_app + +__all__ = ("celery_app",) diff --git a/config/celery_app.py b/config/celery_app.py new file mode 100644 index 0000000..9da2788 --- /dev/null +++ b/config/celery_app.py @@ -0,0 +1,41 @@ +import os + +from celery import Celery +from celery.signals import setup_logging + + +@setup_logging.connect +def on_celery_setup_logging(**kwargs): + """ + Use Django logging instead of celery logger + :param kwargs: + :return: + """ + from logging.config import dictConfig + + from django.conf import settings + + # Patch all the code to use Celery logger (if not just logs inside tasks.py are displayed with the + # task_id and task_name). This way every log will have the context information + if not settings.CELERY_ALWAYS_EAGER: + for _, logger in settings.LOGGING["loggers"].items(): + key = "handlers" + if key in logger: + logger[key] = ["celery_console"] + dictConfig(settings.LOGGING) + + +# set the default Django settings module for the 'celery' program. +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.local") + +app = Celery("safe_locking_service") + +# Using a string here means the worker doesn't have to serialize +# the configuration object to child processes. +# - namespace='CELERY' means all celery-related configuration keys +# should have a `CELERY_` prefix. +app.config_from_object("django.conf:settings") +# app.config_from_object("django.conf:settings", namespace="CELERY") + +# Load task modules from all registered Django app configs. +app.autodiscover_tasks() diff --git a/config/gunicorn.py b/config/gunicorn.py new file mode 100644 index 0000000..247ed43 --- /dev/null +++ b/config/gunicorn.py @@ -0,0 +1,8 @@ +""" +Store gunicorn variables in this file, so they can be read by Django +""" +import os + +gunicorn_request_timeout = os.environ.get("WEB_WORKER_TIMEOUT", 60) +gunicorn_worker_connections = os.environ.get("WEB_WORKER_CONNECTIONS", 1000) +gunicorn_workers = os.environ.get("WEB_CONCURRENCY", 2) diff --git a/config/settings/__init__.py b/config/settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/settings/base.py b/config/settings/base.py new file mode 100644 index 0000000..97eb158 --- /dev/null +++ b/config/settings/base.py @@ -0,0 +1,352 @@ +""" +Base settings to build other settings files upon. +""" + +from pathlib import Path + +import environ +from corsheaders.defaults import default_headers as default_cors_headers + +from ..gunicorn import ( + gunicorn_request_timeout, + gunicorn_worker_connections, + gunicorn_workers, +) + +ROOT_DIR = Path(__file__).resolve(strict=True).parent.parent.parent +APPS_DIR = ROOT_DIR / "safe_locking_service" + +env = environ.Env() + +READ_DOT_ENV_FILE = env.bool("DJANGO_READ_DOT_ENV_FILE", default=False) +DOT_ENV_FILE = env("DJANGO_DOT_ENV_FILE", default=None) +if READ_DOT_ENV_FILE or DOT_ENV_FILE: + DOT_ENV_FILE = DOT_ENV_FILE or ".env" + # OS environment variables take precedence over variables from .env + env.read_env(str(ROOT_DIR / DOT_ENV_FILE)) + +# GENERAL +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#debug +DEBUG = env.bool("DEBUG", False) +# Local time zone. Choices are +# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name +# though not all of them may be available with every OS. +# In Windows, this must be set to your system time zone. +TIME_ZONE = "UTC" +# https://docs.djangoproject.com/en/dev/ref/settings/#language-code +LANGUAGE_CODE = "en-us" +# https://docs.djangoproject.com/en/dev/ref/settings/#site-id +SITE_ID = 1 +# https://docs.djangoproject.com/en/dev/ref/settings/#use-i18n +USE_I18N = True +# https://docs.djangoproject.com/en/dev/ref/settings/#use-tz +USE_TZ = True +# https://docs.djangoproject.com/en/3.2/ref/settings/#force-script-name +FORCE_SCRIPT_NAME = env("FORCE_SCRIPT_NAME", default=None) + +# GUNICORN +GUNICORN_REQUEST_TIMEOUT = gunicorn_request_timeout +GUNICORN_WORKER_CONNECTIONS = gunicorn_worker_connections +GUNICORN_WORKERS = gunicorn_workers + +# DATABASES +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#databases +DB_STATEMENT_TIMEOUT = env.int("DB_STATEMENT_TIMEOUT", 60_000) +DATABASES = { + "default": env.db("DATABASE_URL"), +} +DATABASES["default"]["ATOMIC_REQUESTS"] = False +DATABASES["default"]["ENGINE"] = "django_db_geventpool.backends.postgresql_psycopg2" +DATABASES["default"]["CONN_MAX_AGE"] = 0 +DB_MAX_CONNS = env.int("DB_MAX_CONNS", default=50) +DATABASES["default"]["OPTIONS"] = { + # https://github.com/jneight/django-db-geventpool#settings + "MAX_CONNS": DB_MAX_CONNS, + "REUSE_CONNS": env.int("DB_REUSE_CONNS", default=DB_MAX_CONNS), + "options": f"-c statement_timeout={DB_STATEMENT_TIMEOUT}", +} + +# URLS +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#root-urlconf +ROOT_URLCONF = "config.urls" +# https://docs.djangoproject.com/en/dev/ref/settings/#wsgi-application +WSGI_APPLICATION = "config.wsgi.application" + +# APPS +# ------------------------------------------------------------------------------ +DJANGO_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.sites", + "django.contrib.messages", + "django.contrib.staticfiles", + # 'django.contrib.humanize', # Handy template tags +] +THIRD_PARTY_APPS = [ + "django_extensions", + "corsheaders", + "rest_framework", + "drf_yasg", +] +LOCAL_APPS = [ + "safe_locking_service.locking_events.apps.LockingEventsConfig", +] +# https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps +INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS + +# MIDDLEWARE +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#middleware +MIDDLEWARE = [ + "safe_locking_service.utils.loggers.LoggingMiddleware", + "corsheaders.middleware.CorsMiddleware", + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.http.ConditionalGetMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +# STATIC +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#static-root +STATIC_ROOT = str(ROOT_DIR / "staticfiles") + +# https://docs.djangoproject.com/en/dev/ref/settings/#static-url +STATIC_URL = "static/" +# https://docs.djangoproject.com/en/dev/ref/contrib/staticfiles/#std:setting-STATICFILES_DIRS +STATICFILES_DIRS = [ + str(APPS_DIR / "static"), +] +# https://docs.djangoproject.com/en/dev/ref/contrib/staticfiles/#staticfiles-finders +STATICFILES_FINDERS = [ + "django.contrib.staticfiles.finders.FileSystemFinder", + "django.contrib.staticfiles.finders.AppDirectoriesFinder", +] + +# MEDIA +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#media-root +MEDIA_ROOT = str(APPS_DIR / "media") +# https://docs.djangoproject.com/en/dev/ref/settings/#media-url +MEDIA_URL = "/media/" + +# TEMPLATES +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#templates +TEMPLATES = [ + { + # https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-TEMPLATES-BACKEND + "BACKEND": "django.template.backends.django.DjangoTemplates", + # https://docs.djangoproject.com/en/dev/ref/settings/#template-dirs + "DIRS": [ + str(APPS_DIR / "templates"), + ], + "OPTIONS": { + # https://docs.djangoproject.com/en/dev/ref/settings/#template-debug + "debug": DEBUG, + # https://docs.djangoproject.com/en/dev/ref/settings/#template-loaders + # https://docs.djangoproject.com/en/dev/ref/templates/api/#loader-types + "loaders": [ + "django.template.loaders.filesystem.Loader", + "django.template.loaders.app_directories.Loader", + ], + # https://docs.djangoproject.com/en/dev/ref/settings/#template-context-processors + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.template.context_processors.i18n", + "django.template.context_processors.media", + "django.template.context_processors.static", + "django.template.context_processors.tz", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + + +# CORS +# ------------------------------------------------------------------------------ +CORS_ALLOW_ALL_ORIGINS = True +CORS_ALLOW_HEADERS = list(default_cors_headers) + [ + "if-match", + "if-modified-since", + "if-none-match", +] +CORS_EXPOSE_HEADERS = ["etag"] + +# FIXTURES +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#fixture-dirs +FIXTURE_DIRS = (str(APPS_DIR / "fixtures"),) + +# ADMIN +# ------------------------------------------------------------------------------ +# Django Admin URL regex. +ADMIN_URL = "admin/" + +# Celery +# ------------------------------------------------------------------------------ +INSTALLED_APPS += [ + "django_celery_beat", +] + +# http://docs.celeryproject.org/en/latest/userguide/configuration.html#std:setting-broker_url +CELERY_BROKER_URL = env("CELERY_BROKER_URL", default="django://") +# https://docs.celeryproject.org/en/stable/userguide/optimizing.html#broker-connection-pools +# https://docs.celeryq.dev/en/latest/userguide/optimizing.html#broker-connection-pools +# Configured to 0 due to connection issues https://github.com/celery/celery/issues/4355 +CELERY_BROKER_POOL_LIMIT = env.int("CELERY_BROKER_POOL_LIMIT", default=0) +# https://docs.celeryq.dev/en/stable/userguide/configuration.html#broker-heartbeat +CELERY_BROKER_HEARTBEAT = env.int("CELERY_BROKER_HEARTBEAT", default=0) + +# https://docs.celeryq.dev/en/stable/userguide/configuration.html#std-setting-broker_connection_max_retries +CELERY_BROKER_CONNECTION_MAX_RETRIES = env.int( + "CELERY_BROKER_CONNECTION_MAX_RETRIES", default=0 +) +# https://docs.celeryq.dev/en/stable/userguide/configuration.html#broker-channel-error-retry +CELERY_BROKER_CHANNEL_ERROR_RETRY = env.bool( + "CELERY_BROKER_CHANNEL_ERROR_RETRY", default=True +) +# http://docs.celeryproject.org/en/latest/userguide/configuration.html#std:setting-result_backend +CELERY_RESULT_BACKEND = env("CELERY_RESULT_BACKEND", default="redis://") +# http://docs.celeryproject.org/en/latest/userguide/configuration.html#std:setting-accept_content +CELERY_ACCEPT_CONTENT = ["json"] +# http://docs.celeryproject.org/en/latest/userguide/configuration.html#std:setting-task_serializer +CELERY_TASK_SERIALIZER = "json" +# http://docs.celeryproject.org/en/latest/userguide/configuration.html#std:setting-result_serializer +CELERY_RESULT_SERIALIZER = "json" +# We are not interested in keeping results of tasks +CELERY_IGNORE_RESULT = True +# http://docs.celeryproject.org/en/latest/userguide/configuration.html#std:setting-task_always_eager +CELERY_ALWAYS_EAGER = False +# https://docs.celeryproject.org/en/latest/userguide/configuration.html#task-default-priority +# Higher = more priority on RabbitMQ, opposite on Redis ¯\_(ツ)_/¯ +CELERY_TASK_DEFAULT_PRIORITY = 5 +# https://docs.celeryproject.org/en/stable/userguide/configuration.html#task-queue-max-priority +CELERY_TASK_QUEUE_MAX_PRIORITY = 10 +# https://docs.celeryproject.org/en/latest/userguide/configuration.html#broker-transport-options +CELERY_BROKER_TRANSPORT_OPTIONS = {} + +# https://docs.celeryq.dev/en/stable/userguide/configuration.html#std-setting-task_routes +# CELERY_ROUTES = ( +# [ +# ( +# "safe_locking_service.locking_events.tasks.*", +# {"queue": "events", "delivery_mode": "transient"}, +# ), +# ]) + +# Django REST Framework +# ------------------------------------------------------------------------------ +REST_FRAMEWORK = { + "PAGE_SIZE": 10, + "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.LimitOffsetPagination", + "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.AllowAny",), + "DEFAULT_RENDERER_CLASSES": ( + "djangorestframework_camel_case.render.CamelCaseJSONRenderer", + ), + "DEFAULT_PARSER_CLASSES": ( + "djangorestframework_camel_case.parser.CamelCaseJSONParser", + ), + "DEFAULT_AUTHENTICATION_CLASSES": ( + # 'rest_framework.authentication.BasicAuthentication', + "rest_framework.authentication.SessionAuthentication", + "rest_framework.authentication.TokenAuthentication", + ), + "DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.NamespaceVersioning", + # TODO Check this + "EXCEPTION_HANDLER": "safe_locking_service.utils.exceptions.custom_exception_handler", +} + +# LOGGING +# ------------------------------------------------------------------------------ +# See: https://docs.djangoproject.com/en/dev/ref/settings/#logging +# A sample logging configuration. The only tangible logging +# performed by this configuration is to send an email to +# the site admins bon every HTTP 500 error when DEBUG=False. +# See https://docs.djangoproject.com/en/dev/topics/logging for +# more details on how to customize your logging configuration. +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "filters": { + "require_debug_false": {"()": "django.utils.log.RequireDebugFalse"}, + "ignore_succeeded_none": { + "()": "safe_locking_service.utils.loggers.IgnoreSucceededNone" + }, + }, + "formatters": { + "short": {"format": "%(asctime)s %(message)s"}, + "verbose": { + "format": "%(asctime)s [%(levelname)s] [%(processName)s] %(message)s" + }, + }, + "handlers": { + "mail_admins": { + "level": "ERROR", + "filters": ["require_debug_false"], + "class": "django.utils.log.AdminEmailHandler", + }, + "console": { + "level": "DEBUG", + "class": "logging.StreamHandler", + "formatter": "verbose", + }, + "console_short": { + "class": "logging.StreamHandler", + "formatter": "short", + }, + }, + "loggers": { + "": { + "handlers": ["console"], + "level": "INFO", + }, + "web3.providers": { + "level": "DEBUG" if DEBUG else "WARNING", + }, + "django.geventpool": { + "level": "DEBUG" if DEBUG else "WARNING", + }, + "LoggingMiddleware": { + "handlers": ["console_short"], + "level": "INFO", + "propagate": False, + }, + "safe_locking_service": { + "level": "DEBUG" if DEBUG else "INFO", + "handlers": ["console"], + "propagate": False, + }, + "django.request": { + "handlers": ["mail_admins"], + "level": "ERROR", + "propagate": True, + }, + "django.security.DisallowedHost": { + "level": "ERROR", + "handlers": ["console", "mail_admins"], + "propagate": True, + }, + }, +} + +SWAGGER_SETTINGS = { + "SECURITY_DEFINITIONS": { + "api_key": {"type": "apiKey", "in": "header", "name": "Authorization"} + }, +} + +# Ethereum +ETHEREUM_NODE_URL = env("ETHEREUM_NODE_URL", default=None) diff --git a/config/settings/local.py b/config/settings/local.py new file mode 100644 index 0000000..28245f3 --- /dev/null +++ b/config/settings/local.py @@ -0,0 +1,49 @@ +from .base import * # noqa +from .base import env + +# GENERAL +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#debug +DEBUG = True +# https://docs.djangoproject.com/en/dev/ref/settings/#secret-key +SECRET_KEY = env( + "DJANGO_SECRET_KEY", + default="aHdCBMHXuxIxEhfRGFRp7Cp3N9CqEZEEAvwZVlBCazKExkEnzvVs4bYWC8Qqh9lg", +) +# https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts +ALLOWED_HOSTS = env.list("DJANGO_ALLOWED_HOSTS", default=["*"]) + +REDIS_URL = env.str("REDIS_URL", default=None) + +# CACHES +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#caches +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + } +} + +# django-debug-toolbar +# ------------------------------------------------------------------------------ +# https://django-debug-toolbar.readthedocs.io/en/latest/installation.html#prerequisites +INSTALLED_APPS += ["debug_toolbar"] # noqa F405 + +# https://django-debug-toolbar.readthedocs.io/en/latest/installation.html#middleware +MIDDLEWARE += [ # noqa F405 + "debug_toolbar.middleware.DebugToolbarMiddleware", + "debug_toolbar_force.middleware.ForceDebugToolbarMiddleware", +] +# https://django-debug-toolbar.readthedocs.io/en/latest/configuration.html#debug-toolbar-config +DEBUG_TOOLBAR_CONFIG = { + "DISABLE_PANELS": [ + "debug_toolbar.panels.redirects.RedirectsPanel", + ], + "SHOW_TEMPLATE_CONTEXT": True, +} +# https://django-debug-toolbar.readthedocs.io/en/latest/installation.html#internal-ips +INTERNAL_IPS = ["127.0.0.1", "10.0.2.2"] + +# CELERY +# ------------------------------------------------------------------------------ +CELERY_RESULT_BACKEND = env("CELERY_RESULT_BACKEND", default=REDIS_URL) diff --git a/config/settings/production.py b/config/settings/production.py new file mode 100644 index 0000000..699f42c --- /dev/null +++ b/config/settings/production.py @@ -0,0 +1,32 @@ +from .base import * # noqa +from .base import env + +# GENERAL +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#secret-key +SECRET_KEY = env("DJANGO_SECRET_KEY") +# https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts +ALLOWED_HOSTS = env.list("DJANGO_ALLOWED_HOSTS", default=["*"]) + +# DATABASES +# ------------------------------------------------------------------------------ +# DATABASES['default'] = env.db('DATABASE_URL') # noqa F405 +DATABASES["default"]["ATOMIC_REQUESTS"] = False # noqa F405 + +# SECURITY +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/middleware/#x-content-type-options-nosniff +SECURE_CONTENT_TYPE_NOSNIFF = env.bool( + "DJANGO_SECURE_CONTENT_TYPE_NOSNIFF", default=True +) +# https://docs.djangoproject.com/en/3.2/ref/settings/#csrf-trusted-origins +CSRF_TRUSTED_ORIGINS = env.list("CSRF_TRUSTED_ORIGINS", default=[]) + +# ADMIN +# ------------------------------------------------------------------------------ +# Django Admin URL regex. +ADMIN_URL = env("DJANGO_ADMIN_URL", default="admin/") + +# Gunicorn +# ------------------------------------------------------------------------------ +INSTALLED_APPS += ["gunicorn"] # noqa F405 diff --git a/config/settings/test.py b/config/settings/test.py new file mode 100644 index 0000000..51b02b8 --- /dev/null +++ b/config/settings/test.py @@ -0,0 +1,42 @@ +""" +With these settings, tests run faster. +""" + +from .base import * # noqa +from .base import env + +# GENERAL +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#debug +DEBUG = True +# https://docs.djangoproject.com/en/dev/ref/settings/#secret-key +SECRET_KEY = env( + "DJANGO_SECRET_KEY", + default="q8lVkJGsIiHcTSQKaWIBsMVPOGnCnF6f7NDGup8KdDNmviSaZVhP0Nq3q3MolmFU", +) +# https://docs.djangoproject.com/en/dev/ref/settings/#test-runner +TEST_RUNNER = "django.test.runner.DiscoverRunner" + +# CACHES +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#caches +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.dummy.DummyCache", + }, +} + +# PASSWORDS +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#password-hashers +PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"] + +LOGGING["loggers"] = { # noqa F405 + "safe_locking_service": { + "level": "DEBUG", + } +} + +ETHEREUM_TEST_PRIVATE_KEY = ( + "6370fd033278c143179d81c5526140625662b8daa446c22ee2d73db3707e620c" +) diff --git a/config/urls.py b/config/urls.py new file mode 100644 index 0000000..16d3a19 --- /dev/null +++ b/config/urls.py @@ -0,0 +1,86 @@ +from django.conf import settings +from django.conf.urls import include +from django.contrib import admin +from django.http import HttpResponse +from django.urls import path, re_path +from django.views import defaults as default_views + +from drf_yasg import openapi +from drf_yasg.views import get_schema_view +from rest_framework import permissions + +schema_view = get_schema_view( + openapi.Info( + title="Safe Locking Service API", + default_version="v1", + description="Get events emitted by Safe token locking contract.", + license=openapi.License(name="MIT License"), + ), + validators=["flex", "ssv"], + public=True, + permission_classes=[permissions.AllowAny], +) + +schema_cache_timeout = 60 * 5 # 5 minutes + +swagger_urlpatterns = [ + path( + "", + schema_view.with_ui("swagger", cache_timeout=schema_cache_timeout), + name="schema-swagger-ui", + ), + re_path( + r"^swagger(?P\.json|\.yaml)$", + schema_view.without_ui(cache_timeout=schema_cache_timeout), + name="schema-json", + ), + path( + "redoc/", + schema_view.with_ui("redoc", cache_timeout=schema_cache_timeout), + name="schema-redoc", + ), +] + +urlpatterns_v1 = [ + path( + "", + include("safe_locking_service.locking_events.urls", namespace="locking_events"), + ), +] + +urlpatterns = swagger_urlpatterns + [ + path(settings.ADMIN_URL, admin.site.urls), + path("api/v1/", include((urlpatterns_v1, "v1"))), + path("check/", lambda request: HttpResponse("Ok"), name="check"), +] + + +if settings.DEBUG: + # This allows the error pages to be debugged during development, just visit + # these url in browser to see how these error pages look like. + urlpatterns += [ + path( + "400/", + default_views.bad_request, + kwargs={"exception": Exception("Bad Request!")}, + ), + path( + "403/", + default_views.permission_denied, + kwargs={"exception": Exception("Permission Denied")}, + ), + path( + "404/", + default_views.page_not_found, + kwargs={"exception": Exception("Page not Found")}, + ), + path("500/", default_views.server_error), + ] + if "debug_toolbar" in settings.INSTALLED_APPS: + import debug_toolbar + + urlpatterns = [ + path("__debug__/", include(debug_toolbar.urls)), + ] + urlpatterns + +admin.autodiscover() diff --git a/config/wsgi.py b/config/wsgi.py new file mode 100644 index 0000000..7d5efff --- /dev/null +++ b/config/wsgi.py @@ -0,0 +1,42 @@ +""" +WSGI config for Safe Locking Service project. + +This module contains the WSGI application used by Django's development server +and any production WSGI deployments. It should expose a module-level variable +named ``application``. Django's ``runserver`` and ``runfcgi`` commands discover +this application via the ``WSGI_APPLICATION`` setting. + +Usually you will have the standard Django WSGI application here, but it also +might make sense to replace the whole Django WSGI application with a custom one +that later delegates to the Django one. For example, you could introduce WSGI +middleware here, or combine a Django application with an application of another +framework. + +""" +import os +import sys + +from django.core.wsgi import get_wsgi_application + +# This allows easy placement of apps within the interior +# safe_locking_service directory. +app_path = os.path.abspath( + os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir) +) +sys.path.append(os.path.join(app_path, "safe_locking_service")) + + +# We defer to a DJANGO_SETTINGS_MODULE already in the environment. This breaks +# if running multiple sites in the same mod_wsgi process. To fix this, use +# mod_wsgi daemon mode with each site in its own daemon process, or use +# os.environ["DJANGO_SETTINGS_MODULE"] = "config.settings.production" +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production") + +# This application object is used by any WSGI server configured to use this +# file. This includes Django's development server, if the WSGI_APPLICATION +# setting points here. +application = get_wsgi_application() + +# Apply WSGI middleware here. +# from helloworld.wsgi import HelloWorldApplication +# application = HelloWorldApplication(application) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..de667af --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,60 @@ +version: '3.5' + +volumes: + nginx-shared: + +services: + nginx: + image: nginx:alpine + hostname: nginx + ports: + - "8000:8000" + volumes: + - ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - nginx-shared:/nginx + depends_on: + - web + + web: + build: + context: . + dockerfile: docker/web/Dockerfile + env_file: + - .env + working_dir: /app + ports: + - "8888:8888" + volumes: + - nginx-shared:/nginx + command: docker/web/run_web.sh + + rabbitmq: + image: rabbitmq:alpine + ports: + - "5672:5672" + + db: + image: postgres:14-alpine + ports: + - "5432:5432" + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + + indexer-worker: &worker + build: + context: . + dockerfile: docker/web/Dockerfile + env_file: + - .env + environment: + RUN_MIGRATIONS: 1 + WORKER_QUEUES: "default,events" + depends_on: + - db + - rabbitmq + command: docker/web/celery/worker/run.sh + + scheduler: + <<: *worker + command: docker/web/celery/scheduler/run.sh diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf new file mode 100644 index 0000000..6b93ea7 --- /dev/null +++ b/docker/nginx/nginx.conf @@ -0,0 +1,68 @@ +# https://github.com/KyleAMathews/docker-nginx/blob/master/nginx.conf +# https://linode.com/docs/web-servers/nginx/configure-nginx-for-optimized-performance/ +# https://docs.gunicorn.org/en/stable/deploy.html + +worker_processes 1; + +events { + worker_connections 2000; # increase if you have lots of clients + accept_mutex off; # set to 'on' if nginx worker_processes > 1 + use epoll; # Enable epoll for Linux 2.6+ + # 'use kqueue;' to enable for FreeBSD, OSX +} + +http { + include mime.types; + # fallback in case we can't determine a type + default_type application/octet-stream; + sendfile on; + + upstream app_server { + # ip_hash; # For load-balancing + # + # fail_timeout=0 means we always retry an upstream even if it failed + # to return a good HTTP response + server unix:/nginx/gunicorn.socket fail_timeout=0; + + # for a TCP configuration + # server web:8000 fail_timeout=0; + keepalive 32; + } + + server { + access_log off; + listen 8000 deferred; + charset utf-8; + keepalive_timeout 75s; + + # https://thoughts.t37.net/nginx-optimization-understanding-sendfile-tcp-nodelay-and-tcp-nopush-c55cdd276765 + # tcp_nopush on; + # tcp_nodelay on; + + gzip on; + gzip_min_length 1000; + gzip_comp_level 2; + # text/html is always included by default + gzip_types text/plain text/css application/json application/javascript application/x-javascript text/javascript text/xml application/xml application/rss+xml application/atom+xml application/rdf+xml; + gzip_disable "MSIE [1-6]\."; + + location /static { + alias /nginx/staticfiles; + expires 365d; + } + + location / { + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Host $host; + # we don't want nginx trying to do something clever with + # redirects, we set the Host: header above already. + proxy_redirect off; + proxy_pass http://app_server/; + + proxy_set_header X-Forwarded-Host $server_name; + proxy_set_header X-Real-IP $remote_addr; + add_header Front-End-Https on; + } + } +} diff --git a/docker/web/Dockerfile b/docker/web/Dockerfile new file mode 100644 index 0000000..bb10c49 --- /dev/null +++ b/docker/web/Dockerfile @@ -0,0 +1,38 @@ +FROM python:3.11-slim + +ARG APP_HOME=/app +WORKDIR ${APP_HOME} +ENV PYTHONUNBUFFERED=1 + +# https://eth-hash.readthedocs.io/en/latest/quickstart.html#specify-backend-by-environment-variable +# `pysha3` is way faster than `pycryptodome` for CPython +ENV ETH_HASH_BACKEND=pysha3 + +COPY requirements.txt ./ +RUN set -ex \ + && buildDeps=" \ + build-essential \ + git \ + libssl-dev \ + libpq-dev \ + " \ + && apt-get update \ + && apt-get upgrade -y \ + && apt-get install -y --no-install-recommends $buildDeps tmux postgresql-client \ + && pip install -U --no-cache-dir wheel setuptools pip \ + && pip install --no-cache-dir -r requirements.txt \ + && apt-get purge -y --auto-remove $buildDeps \ + && rm -rf /var/lib/apt/lists/* + +# /nginx mount point must be created before so it doesn't have root permissions +# ${APP_HOME} root folder will not be updated by COPY --chown, so permissions need to be adjusted +RUN groupadd -g 999 python && \ + useradd -u 999 -r -g python python && \ + mkdir -p /nginx && \ + chown -R python:python /nginx ${APP_HOME} +COPY --chown=python:python . . + +# Use numeric ids so kubernetes identifies the user correctly +USER 999:999 + +RUN DJANGO_SETTINGS_MODULE=config.settings.production DJANGO_DOT_ENV_FILE=.env.sample python manage.py collectstatic --noinput diff --git a/docker/web/celery/scheduler/run.sh b/docker/web/celery/scheduler/run.sh new file mode 100755 index 0000000..cbb5a47 --- /dev/null +++ b/docker/web/celery/scheduler/run.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +set -euo pipefail + +# DEBUG set in .env +if [ ${DEBUG:-0} = 1 ]; then + log_level="debug" +else + log_level="info" +fi + +# Wait for migrations +sleep 10 + +echo "==> $(date +%H:%M:%S) ==> Running Celery beat <==" +exec celery -C -A config.celery_app beat \ + -S django_celery_beat.schedulers:DatabaseScheduler \ + --loglevel $log_level \ No newline at end of file diff --git a/docker/web/celery/worker/run.sh b/docker/web/celery/worker/run.sh new file mode 100755 index 0000000..3011fb4 --- /dev/null +++ b/docker/web/celery/worker/run.sh @@ -0,0 +1,37 @@ +#!/bin/bash + +set -euo pipefail + +MAX_MEMORY_PER_CHILD="${WORKER_MAX_MEMORY_PER_CHILD:-2097152}" +MAX_TASKS_PER_CHILD="${MAX_TASKS_PER_CHILD:-1000000}" +TASK_CONCURRENCY="${CELERYD_CONCURRENCY:-1000}" + +# DEBUG set in .env_docker_compose +if [ ${DEBUG:-0} = 1 ]; then + log_level="debug" +else + log_level="info" +fi + +if [ ${RUN_MIGRATIONS:-0} = 1 ]; then + echo "==> $(date +%H:%M:%S) ==> Migrating Django models... " + python manage.py migrate --noinput + + echo "==> $(date +%H:%M:%S) ==> Setting up service... " + python manage.py setup_service + + echo "==> $(date +%H:%M:%S) ==> Setting contracts... " + python manage.py update_safe_contracts_logo +fi + +echo "==> $(date +%H:%M:%S) ==> Check RPC connected matches previously used RPC... " +python manage.py check_chainid_matches + +echo "==> $(date +%H:%M:%S) ==> Running Celery worker with a max_memory_per_child of ${MAX_MEMORY_PER_CHILD} <==" +exec celery -C -A config.celery_app worker \ + --loglevel $log_level --pool=gevent \ + --concurrency=${TASK_CONCURRENCY} \ + --max-memory-per-child=${MAX_MEMORY_PER_CHILD} \ + --max-tasks-per-child=${MAX_TASKS_PER_CHILD} \ + --without-heartbeat --without-gossip \ + --without-mingle -Q "$WORKER_QUEUES" \ No newline at end of file diff --git a/docker/web/run_web.sh b/docker/web/run_web.sh new file mode 100755 index 0000000..9224f94 --- /dev/null +++ b/docker/web/run_web.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +set -euo pipefail + +echo "==> $(date +%H:%M:%S) ==> Collecting statics... " +DOCKER_SHARED_DIR=/nginx +rm -rf $DOCKER_SHARED_DIR/* +# STATIC_ROOT=$DOCKER_SHARED_DIR/staticfiles python manage.py collectstatic --noinput & +cp -r staticfiles/ $DOCKER_SHARED_DIR/ + +echo "==> $(date +%H:%M:%S) ==> Running Gunicorn... " +exec gunicorn --config gunicorn.conf.py --pythonpath "$PWD" -b unix:$DOCKER_SHARED_DIR/gunicorn.socket -b 0.0.0.0:8888 config.wsgi:application diff --git a/gunicorn.conf.py b/gunicorn.conf.py new file mode 100644 index 0000000..28d6c3c --- /dev/null +++ b/gunicorn.conf.py @@ -0,0 +1,24 @@ +from config.gunicorn import ( + gunicorn_request_timeout, + gunicorn_worker_connections, + gunicorn_workers, +) + +access_logfile = "-" +error_logfile = "-" +max_requests = 20_000 # Restart a worker after it has processed a given number of requests (for memory leaks) +max_requests_jitter = ( + 10_000 # Randomize max_requests to prevent all workers restarting at the same time +) +# graceful_timeout = 90 # https://stackoverflow.com/a/24305939 +keep_alive = 2 +log_file = "-" +log_level = "info" +logger_class = "safe_locking_service.utils.loggers.CustomGunicornLogger" +preload_app = False # Load application code before the worker processes are forked (problems with gevent patching) +# For timeout to work with gevent, a custom GeventWorker needs to be used +timeout = gunicorn_request_timeout + +worker_class = "gunicorn_custom_workers.MyGeventWorker" # "gevent" +worker_connections = gunicorn_worker_connections +workers = gunicorn_workers diff --git a/gunicorn_custom_workers.py b/gunicorn_custom_workers.py new file mode 100644 index 0000000..17844c0 --- /dev/null +++ b/gunicorn_custom_workers.py @@ -0,0 +1,27 @@ +import gevent +from gunicorn.workers.ggevent import GeventWorker + + +class MyGeventWorker(GeventWorker): + def patch_psycopg2(self) -> bool: + try: + from psycogreen.gevent import patch_psycopg + + patch_psycopg() + self.log.info("Patched Psycopg2 for gevent") + return True + except ImportError: + self.log.info("Cannot patch psycopg2 for gevent, install psycogreen") + return False + + def patch(self): + super().patch() + self.log.info("Patched all for gevent") + self.patch_psycopg2() + + def handle_request(self, listener_name, req, sock, addr): + try: + with gevent.Timeout(self.cfg.timeout): + super().handle_request(listener_name, req, sock, addr) + except gevent.Timeout: + self.log.error("TimeoutError on %s", req.path) diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..4702e46 --- /dev/null +++ b/manage.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python +import os +import sys +from pathlib import Path + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.local") + + try: + from django.core.management import execute_from_command_line + except ImportError: + # The above import may fail for some other reason. Ensure that the + # issue is really that Django is missing to avoid masking other + # exceptions on Python 2. + try: + import django # noqa + except ImportError: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) + raise + + # This allows easy placement of apps within the interior + # safe_locking_service directory. + current_path = Path(__file__).parent.resolve() + sys.path.append(str(current_path / "safe_locking_service")) + + execute_from_command_line(sys.argv) diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..a2d8fcd --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,9 @@ +-r requirements.txt +-r requirements-test.txt +flake8 +ipdb +ipython +isort +pre-commit +pylint +pylint-django diff --git a/requirements-test.txt b/requirements-test.txt new file mode 100644 index 0000000..a410fd1 --- /dev/null +++ b/requirements-test.txt @@ -0,0 +1,12 @@ +-r requirements.txt +coverage==7.4.1 +django-stubs==4.2.7 +django-test-migrations==1.3.0 +factory-boy==3.3.0 +faker==23.2.1 +mypy==1.8.0 +pytest==8.0.1 +pytest-django==4.8.0 +pytest-env==1.1.3 +pytest-rerunfailures==13.0 +pytest-sugar==1.0.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a8c6274 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,22 @@ +cachetools==5.3.2 +celery==5.3.6 +django==4.2.10 +django-cache-memoize==0.2.0 +django-celery-beat==2.5.0 +django-cors-headers==4.3.1 +django-db-geventpool==4.0.2 +django-debug-toolbar +django-debug-toolbar-force +django-environ==0.11.2 +django-extensions==3.2.3 +djangorestframework==3.14.0 +djangorestframework-camel-case==1.4.2 +docutils==0.20.1 +drf-yasg[validation]==1.21.7 +gunicorn[gevent]==21.2.0 +hexbytes==0.3.1 +packaging>=21 +psycopg2==2.9.9 +requests==2.31.0 +safe-eth-py[django]==6.0.0b16 +web3==6.15.1 diff --git a/run_tests.sh b/run_tests.sh new file mode 100755 index 0000000..f2cda5e --- /dev/null +++ b/run_tests.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +set -euo pipefail + +export DJANGO_SETTINGS_MODULE=config.settings.test +export DJANGO_DOT_ENV_FILE=.env.test +docker compose -f docker-compose.yml -f docker-compose.dev.yml build --force-rm db redis ganache rabbitmq +docker compose -f docker-compose.yml -f docker-compose.dev.yml up --no-start db redis ganache rabbitmq +docker compose -f docker-compose.yml -f docker-compose.dev.yml start db redis ganache rabbitmq + +sleep 10 + +python manage.py check +pytest -rxXs diff --git a/safe_locking_service/__init__.py b/safe_locking_service/__init__.py new file mode 100644 index 0000000..3181f24 --- /dev/null +++ b/safe_locking_service/__init__.py @@ -0,0 +1,5 @@ +__version__ = "0.0.1" +__version_info__ = tuple( + int(num) if num.isdigit() else num + for num in __version__.replace("-", ".", 1).split(".") +) diff --git a/safe_locking_service/locking_events/__init__.py b/safe_locking_service/locking_events/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/safe_locking_service/locking_events/admin.py b/safe_locking_service/locking_events/admin.py new file mode 100644 index 0000000..4185d36 --- /dev/null +++ b/safe_locking_service/locking_events/admin.py @@ -0,0 +1,3 @@ +# from django.contrib import admin + +# Register your models here. diff --git a/safe_locking_service/locking_events/apps.py b/safe_locking_service/locking_events/apps.py new file mode 100644 index 0000000..f3c839b --- /dev/null +++ b/safe_locking_service/locking_events/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class LockingEventsConfig(AppConfig): + name = "safe_locking_service.locking_events" + verbose_name = "Safe Locking Service" + default_auto_field = "django.db.models.BigAutoField" diff --git a/safe_locking_service/locking_events/management/__init__.py b/safe_locking_service/locking_events/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/safe_locking_service/locking_events/management/commands/__init__.py b/safe_locking_service/locking_events/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/safe_locking_service/locking_events/migrations/__init__.py b/safe_locking_service/locking_events/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/safe_locking_service/locking_events/models.py b/safe_locking_service/locking_events/models.py new file mode 100644 index 0000000..0b4331b --- /dev/null +++ b/safe_locking_service/locking_events/models.py @@ -0,0 +1,3 @@ +# from django.db import models + +# Create your models here. diff --git a/safe_locking_service/locking_events/tasks.py b/safe_locking_service/locking_events/tasks.py new file mode 100644 index 0000000..3017f69 --- /dev/null +++ b/safe_locking_service/locking_events/tasks.py @@ -0,0 +1 @@ +# Expected to add indexing tasks diff --git a/safe_locking_service/locking_events/tests.py b/safe_locking_service/locking_events/tests.py new file mode 100644 index 0000000..a79ca8b --- /dev/null +++ b/safe_locking_service/locking_events/tests.py @@ -0,0 +1,3 @@ +# from django.test import TestCase + +# Create your tests here. diff --git a/safe_locking_service/locking_events/urls.py b/safe_locking_service/locking_events/urls.py new file mode 100644 index 0000000..c54b99b --- /dev/null +++ b/safe_locking_service/locking_events/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from . import views + +app_name = "tokens" + +urlpatterns = [ + path("about/", views.AboutView.as_view(), name="about"), +] diff --git a/safe_locking_service/locking_events/views.py b/safe_locking_service/locking_events/views.py new file mode 100644 index 0000000..6d664b9 --- /dev/null +++ b/safe_locking_service/locking_events/views.py @@ -0,0 +1,25 @@ +from django.utils.decorators import method_decorator +from django.views.decorators.cache import cache_page + +from rest_framework.generics import GenericAPIView +from rest_framework.response import Response + +from safe_locking_service import __version__ + + +class AboutView(GenericAPIView): + """ + Returns information and configuration of the service + """ + + @method_decorator(cache_page(5 * 60)) # 5 minutes + def get(self, request, format=None): + content = { + "name": "Safe Locking Service", + "version": __version__, + "api_version": request.version, + "secure": request.is_secure(), + "host": request.get_host(), + "headers": [x for x in request.META.keys() if "FORWARD" in x], + } + return Response(content) diff --git a/safe_locking_service/static/.gitignore b/safe_locking_service/static/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/safe_locking_service/static/safe/favicon.png b/safe_locking_service/static/safe/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..2ba2897e6f530da1afedc75a55770c32d0e7b2fd GIT binary patch literal 53183 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 ScgAOQSwCUE#WLgHzWyJ}Mdthf literal 0 HcmV?d00001 diff --git a/safe_locking_service/static/safe/logo.png b/safe_locking_service/static/safe/logo.png new file mode 100755 index 0000000000000000000000000000000000000000..5cbcc554e9b81ded351ac655756a8c0230c3638d GIT binary patch literal 3367 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 literal 0 HcmV?d00001 diff --git a/safe_locking_service/static/safe/logo.svg b/safe_locking_service/static/safe/logo.svg new file mode 100644 index 0000000..df57691 --- /dev/null +++ b/safe_locking_service/static/safe/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/safe_locking_service/static/safe/safe_contract_logo.png b/safe_locking_service/static/safe/safe_contract_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..14eb5bb0204a16d5146c21ddc61d958a0e55fe7e GIT binary patch literal 755 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE8Azrw%`pX1HUT~%uK)l47ZMhF^yrbVpKo?f z_K_n;IyyTP6%{{x_)t|%z1i2w*M|Ois;S80 z=mH1-<-bpADQ1;_YWn&`!8}i^6Xt)E=RV&5yS4btB~Kp<*J*KTa}+(y1=A&#%C`$w zyi{M%%gB?!z$|fqfz7}n_&}O%f~nktBNYwiaso@1xA=1hT19OecB+1?ysV9Pk%FjwM&p~A|Dm@*RupFp`Ixo)#3HLUzl(Jm&- z`0t0xoUVqzi^uVB@DZN0-Alr(`UdSL5Os@F#^2QRjU(8}FZvc(gNImG_@K*K1 z27!idku@jX7^X!;#ipq+be|d!=mpR^3xq?CV%6y