From 6a53c6e6703ee25be5e3692849d05ec602c5341f Mon Sep 17 00:00:00 2001 From: Stefan Haun <tux@netz39.de> Date: Tue, 22 Dec 2020 14:37:45 +0100 Subject: [PATCH 01/11] Add endpoints to upload/download application/sepa for members --- OAS3.yml | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 78 insertions(+), 1 deletion(-) diff --git a/OAS3.yml b/OAS3.yml index 16c65a6..501f221 100644 --- a/OAS3.yml +++ b/OAS3.yml @@ -200,6 +200,84 @@ paths: '404': $ref: '#/components/responses/NotFound' + /document/{id}/{type}: + parameters: + - in: path + name: id + required: true + schema: + type: string + description: Entity ID + - in: path + name: type + required: true + schema: + type: string + enum: [application, sepa] + description: Type of document to upload + - in: header + name: Authentication + schema: + type: string + description: Authentication token + post: + summary: Upload a PDF document for a member + description: Note that the entry must be updated with the URI obtained from this call + tags: + - document + requestBody: + description: The document + content: + 'application/pdf': + schema: + type: string + format: binary + responses: + '201': + description: File has been stored ("created") locally, returns the URI for downloading the file + content: + text/plain: + schema: + type: string + format: uri + '303': + description: The file is already in storage, returns the URI for downloading the file + content: + text/plain: + schema: + type: string + format: uri + '401': + $ref: '#/components/responses/AuthenticationRequired' + '403': + $ref: '#/components/responses/NotAllowed' + '405': + $ref: '#/components/responses/InvalidInput' + '500': + $ref: '#/components/responses/InternalError' + get: + summary: Get a PDF document for a member + tags: + - document + responses: + '200': + description: Returns PDF data + content: + 'application/pdf': + schema: + type: string + format: binary + '404': + $ref: '#/components/responses/NotFound' + '401': + $ref: '#/components/responses/AuthenticationRequired' + '403': + $ref: '#/components/responses/NotAllowed' + '405': + $ref: '#/components/responses/InvalidInput' + '500': + $ref: '#/components/responses/InternalError' + components: schemas: health: @@ -236,4 +314,3 @@ components: schema: type: string example: error message - From 186e5ac2abafe3c2e603294627d3a98a4205a624 Mon Sep 17 00:00:00 2001 From: Stefan Haun <tux@netz39.de> Date: Thu, 11 Feb 2021 20:58:17 +0100 Subject: [PATCH 02/11] Add GitPython requirement --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 757931f..b803341 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ tornado==6.0.4 isodate==0.6.0 pytest==5.4.1 +GitPython==3.1.12 \ No newline at end of file From 234421fb6467055c9fce9c2a0806aae1bec8144b Mon Sep 17 00:00:00 2001 From: Stefan Haun <tux@netz39.de> Date: Thu, 11 Feb 2021 20:58:42 +0100 Subject: [PATCH 03/11] Add the GitManager with configuration and repository setup --- gitmgr.py | 150 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 gitmgr.py diff --git a/gitmgr.py b/gitmgr.py new file mode 100644 index 0000000..eef02d7 --- /dev/null +++ b/gitmgr.py @@ -0,0 +1,150 @@ +import git +import os +import shutil +import tempfile + +from util import load_env + + +class GitManagerConfiguration: + @staticmethod + def from_environment(): + origin = load_env("GIT_ORIGIN", None) + wc_path = load_env("GIT_WC_PATH", None) + git_pw = load_env("GIT_PASSWORD", None) + + return GitManagerConfiguration(origin=origin, + git_pw=git_pw, + wc_path=wc_path) + + def __init__(self, origin, git_pw=None, wc_path=None): + if not origin: + raise ValueError("Git origin cannot be empty!") + + self._origin = origin + self._git_pw = git_pw + self._wc_path = wc_path + + @property + def origin(self): + return self._origin + + @property + def git_pw(self): + return self._git_pw + + @property + def wc_path(self): + return self._wc_path + + +class GitManager: + def __init__(self, configuration): + if configuration is None: + raise ValueError("GitManager must be initialized with a configuration!") + + self._configuration = configuration + self._wc = None + + @property + def configuration(self): + return self._configuration + + def _setup_wc(self): + if self._wc is not None: + return + + _wc = self.configuration.wc_path + + if _wc is None: + _wc = tempfile.mkdtemp(prefix='entities_git_') + + if not os.path.isdir(_wc): + raise ValueError("Configured directory for the working copy does not exist!") + + self._wc = _wc + + def _teardown_wc(self): + if self._wc is None: + return + + if self.configuration.wc_path is not None: + print("NOTE: Not tearing down externally configured working copy.") + return + + shutil.rmtree(self._wc) + + self._wc = None + + def _assert_wc(self): + """Assert working copy matches origin and is a valid repository. + + A failed assertion will throw exceptions and lead to service abort, + as this error is not recoverable. + + Returns False if the WC path is an empty directory""" + + # Check if WC is empty + if not os.listdir(self._wc): + return False + + # Create a repository object + # This fails if there is no valid repository + repo = git.Repo(self._wc) + + # Assert that this is not a bare repo + if repo.bare: + raise ValueError("WC path points to a bare git repository!") + + origin = repo.remote('origin') + if self.configuration.origin not in origin.urls: + raise ValueError("Origin URL does not match!") + + # We're good here. + return True + + def _askpass_script(self): + # Passwords are impossible to store in scripts, as they may contain any character ... + # We convert the password into a list of integers and create a little script + # that reconstructs the password and writes it to the console. + # Python will be installed anyways. + + pw_chars = [ord(c) for c in self.configuration.git_pw] + + script = "#!/usr/bin/env python3\n" + script += "l = %s\n" % str(list(pw_chars)) + script += "p = [chr(c) for c in l]\n" + script += f"print(\"\".join(p))\n" + return script + + def _init_repo(self): + # Assert working copy is valid, + # return false if cloning is necessary + if not self._assert_wc(): + print("Cloning new git working copy ...") + + # Create a temporary script file for GIT_ASKPASS + with tempfile.NamedTemporaryFile(mode='w+t') as askpass: + askpass.write(self._askpass_script()) + askpass.file.close() + os.chmod(path=askpass.name, mode=0o700) + self.repo = git.Repo.clone_from(url=self.configuration.origin, + to_path=self._wc, + env={'GIT_ASKPASS': askpass.name}) + else: + print("Reusing existing git working copy ...") + self.repo = git.Repo(self._wc) + + def setup(self): + self._setup_wc() + self._init_repo() + + def teardown(self): + self._teardown_wc() + + def printout(self): + print("Git Manager:") + print(f"\tGit origin is %s" % self.configuration.origin) + print(f"\tUsing working copy path %s" % self._wc) + if not self._wc == self.configuration.wc_path: + print("\tUsing a temporary working copy.") From f9a7d7fe2184618c9bda1e1054506f043aabe5fc Mon Sep 17 00:00:00 2001 From: Stefan Haun <tux@netz39.de> Date: Thu, 11 Feb 2021 20:59:18 +0100 Subject: [PATCH 04/11] Setup the Git Manager in the main application --- app.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app.py b/app.py index 7bff5b8..0347d04 100644 --- a/app.py +++ b/app.py @@ -12,6 +12,7 @@ import json import util from auth import AuthProvider +from gitmgr import GitManagerConfiguration, GitManager startup_timestamp = datetime.now() @@ -83,10 +84,16 @@ def main(): # Setup auth_provider = AuthProvider.from_environment() + gitcfg = GitManagerConfiguration.from_environment() + gitmgr = GitManager(configuration=gitcfg) + gitmgr.setup() + gitmgr.printout() + util.run_tornado_server(make_app(auth_provider), server_port=port) # Teardown + gitmgr.teardown() print("Server stopped") From 78671e9ad9fd4fb60d23f851fa5aa5ec8bda3f62 Mon Sep 17 00:00:00 2001 From: Stefan Haun <tux@netz39.de> Date: Thu, 11 Feb 2021 21:06:59 +0100 Subject: [PATCH 05/11] Add Git configuration variables to README --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a6d6afb..2baa98e 100644 --- a/README.md +++ b/README.md @@ -9,4 +9,7 @@ Query and manipulate the Netz39 entities database. The service is configured via the following environment variables: * `PORT`: Service port. defaults to 8080 * `AUTH`: Authentication tokens, defaults to None. Example Configuration : `AUTH={"token_1": "user_1", "token_2": "user_2"}` - +* `GIT_ORIGIN`: URL for the origin Git repository, including the user name +* `GIT_PASSWORD`: The git password for the user encoded in the origin URL +* `GIT_PULL_INTV`: Time interval between automated pull operations (default: 30s) +* `GIT_WC_PATH`: Set a path for the working copy. Will create a temporary checkout if not provided. From 50ebba0df62343ac2d9ced589a7e82fdfe6e9333 Mon Sep 17 00:00:00 2001 From: Stefan Haun <tux@netz39.de> Date: Fri, 12 Feb 2021 16:33:11 +0100 Subject: [PATCH 06/11] Add git pull with cooldown interval --- gitmgr.py | 42 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/gitmgr.py b/gitmgr.py index eef02d7..43506e2 100644 --- a/gitmgr.py +++ b/gitmgr.py @@ -2,6 +2,7 @@ import git import os import shutil import tempfile +import time from util import load_env @@ -12,18 +13,21 @@ class GitManagerConfiguration: origin = load_env("GIT_ORIGIN", None) wc_path = load_env("GIT_WC_PATH", None) git_pw = load_env("GIT_PASSWORD", None) + pull_intv = load_env("GIT_PULL_INTV", None) return GitManagerConfiguration(origin=origin, git_pw=git_pw, - wc_path=wc_path) + wc_path=wc_path, + pull_intv=pull_intv) - def __init__(self, origin, git_pw=None, wc_path=None): + def __init__(self, origin, git_pw=None, wc_path=None, pull_intv=None): if not origin: raise ValueError("Git origin cannot be empty!") self._origin = origin self._git_pw = git_pw self._wc_path = wc_path + self._pull_intv = 30 if pull_intv is None else int(pull_intv) @property def origin(self): @@ -37,6 +41,10 @@ class GitManagerConfiguration: def wc_path(self): return self._wc_path + @property + def pull_intv(self): + return self._pull_intv + class GitManager: def __init__(self, configuration): @@ -45,6 +53,7 @@ class GitManager: self._configuration = configuration self._wc = None + self._last_pull = 0 @property def configuration(self): @@ -138,6 +147,7 @@ class GitManager: def setup(self): self._setup_wc() self._init_repo() + self.pull(force=True) def teardown(self): self._teardown_wc() @@ -148,3 +158,31 @@ class GitManager: print(f"\tUsing working copy path %s" % self._wc) if not self._wc == self.configuration.wc_path: print("\tUsing a temporary working copy.") + + @property + def head_sha(self): + return None if self.repo is None else self.repo.head.object.hexsha + + def pull(self, force=False): + """Pull from origin. + + Arguments: + `force` -- Do a pull even though the pull interval has not elapsed + + Returns: True if pull was executed + """ + + if not force and (time.time() - self._last_pull < self.configuration.pull_intv): + return False + + self._last_pull = time.time() + + old_head = self.head_sha + + # get the origin + # (We verified during initialization that this origin exists.) + origin = self.repo.remote('origin') + + origin.pull(rebase=True) + + return self.head_sha != old_head From b161d0d7e98c4cac0d5f3ac03dd3628f1915546b Mon Sep 17 00:00:00 2001 From: Stefan Haun <tux@netz39.de> Date: Fri, 19 Feb 2021 16:56:52 +0100 Subject: [PATCH 07/11] Show git head in health infos --- app.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/app.py b/app.py index 0347d04..cd31e35 100644 --- a/app.py +++ b/app.py @@ -20,8 +20,9 @@ startup_timestamp = datetime.now() class HealthHandler(tornado.web.RequestHandler, metaclass=ABCMeta): # noinspection PyAttributeOutsideInit - def initialize(self): + def initialize(self, sources=None): self.git_version = self._load_git_version() + self.sources = sources @staticmethod def _load_git_version(): @@ -53,6 +54,12 @@ class HealthHandler(tornado.web.RequestHandler, metaclass=ABCMeta): health['timestamp'] = isodate.datetime_isoformat(datetime.now()) health['uptime'] = isodate.duration_isoformat(datetime.now() - startup_timestamp) + if self.sources: + for s in self.sources: + h = s() + if h is not None: + health = {**health, **h} + self.set_header("Content-Type", "application/json") self.write(json.dumps(health, indent=4)) self.set_status(200) @@ -70,10 +77,11 @@ class Oas3Handler(tornado.web.RequestHandler, metaclass=ABCMeta): self.finish() -def make_app(_auth_provider=None): +def make_app(_auth_provider=None, gitmgr=None): version_path = r"/v[0-9]" return tornado.web.Application([ - (version_path + r"/health", HealthHandler), + (version_path + r"/health", HealthHandler, + {"sources": [lambda: {"git-head": gitmgr.head_sha}] if gitmgr else None}), (version_path + r"/oas3", Oas3Handler), ]) @@ -89,7 +97,7 @@ def main(): gitmgr.setup() gitmgr.printout() - util.run_tornado_server(make_app(auth_provider), + util.run_tornado_server(make_app(auth_provider, gitmgr), server_port=port) # Teardown From b7836163efaa2807523efe2e633995b50da6179a Mon Sep 17 00:00:00 2001 From: Stefan Haun <tux@netz39.de> Date: Mon, 28 Sep 2020 00:04:51 +0200 Subject: [PATCH 08/11] Add AuthenticatedHandler base class --- app.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/app.py b/app.py index cd31e35..5dc8a51 100644 --- a/app.py +++ b/app.py @@ -18,6 +18,26 @@ from gitmgr import GitManagerConfiguration, GitManager startup_timestamp = datetime.now() +class AuthenticatedHandler(tornado.web.RequestHandler, metaclass=ABCMeta): + # noinspection PyAttributeOutsideInit + def initialize(self, auth_provider=None): + self.auth_provider = auth_provider + + def prepare(self): + if self.auth_provider is None: + return + + # check authentication + auth_hdr = "Authentication" + if auth_hdr not in self.request.headers: + raise tornado.web.HTTPError(401, reason="authentication not provided") + + tk = self.request.headers[auth_hdr] + + if not self.auth_provider.validate_token(tk): + raise tornado.web.HTTPError(403, reason="invalid authentication token provided") + + class HealthHandler(tornado.web.RequestHandler, metaclass=ABCMeta): # noinspection PyAttributeOutsideInit def initialize(self, sources=None): From 053e931b801a855df846d094f37d3eb676d824d4 Mon Sep 17 00:00:00 2001 From: Stefan Haun <tux@netz39.de> Date: Mon, 28 Sep 2020 00:07:00 +0200 Subject: [PATCH 09/11] Add routing for entities handlers --- app.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app.py b/app.py index 5dc8a51..88014a2 100644 --- a/app.py +++ b/app.py @@ -103,6 +103,8 @@ def make_app(_auth_provider=None, gitmgr=None): (version_path + r"/health", HealthHandler, {"sources": [lambda: {"git-head": gitmgr.head_sha}] if gitmgr else None}), (version_path + r"/oas3", Oas3Handler), + (version_path + r"/entities", AllEntitiesHandler, {"auth_provider": _auth_provider}), + (version_path + r"/entity/{.*}", SingleEntityHandler, {"auth_provider": _auth_provider}), ]) From 96ffd20e86ba10a0da3ebb82c5ef383e3145a39c Mon Sep 17 00:00:00 2001 From: Stefan Haun <tux@netz39.de> Date: Mon, 28 Sep 2020 00:07:29 +0200 Subject: [PATCH 10/11] Add SingleEntityHandler stub --- app.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app.py b/app.py index 88014a2..07b93b9 100644 --- a/app.py +++ b/app.py @@ -97,6 +97,18 @@ class Oas3Handler(tornado.web.RequestHandler, metaclass=ABCMeta): self.finish() +class SingleEntityHandler(AuthenticatedHandler, metaclass=ABCMeta): + # noinspection PyAttributeOutsideInit + def initialize(self, auth_provider=None): + super().initialize(auth_provider) + + def post(self, identifier): + pass + + def get(self, identifier): + pass + + def make_app(_auth_provider=None, gitmgr=None): version_path = r"/v[0-9]" return tornado.web.Application([ From 305e1cf242162a8c1668b66ce3f572c7d7c6ae2e Mon Sep 17 00:00:00 2001 From: Stefan Haun <tux@netz39.de> Date: Mon, 28 Sep 2020 00:07:44 +0200 Subject: [PATCH 11/11] Add AllEntitiesHandler stub --- app.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app.py b/app.py index 07b93b9..691e2d4 100644 --- a/app.py +++ b/app.py @@ -97,6 +97,18 @@ class Oas3Handler(tornado.web.RequestHandler, metaclass=ABCMeta): self.finish() +class AllEntitiesHandler(AuthenticatedHandler, metaclass=ABCMeta): + # noinspection PyAttributeOutsideInit + def initialize(self, auth_provider=None): + super().initialize(auth_provider) + + def post(self): + pass + + def get(self): + pass + + class SingleEntityHandler(AuthenticatedHandler, metaclass=ABCMeta): # noinspection PyAttributeOutsideInit def initialize(self, auth_provider=None):