This repository has been archived by the owner on Oct 31, 2023. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 284
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Load app definition files from Golem releases CDN (#5114)
Adds logic for downloading app definition JSON files from CDN in Task API AppManager. The definition files are distinguished based on their file name (which contains the hash of the file's contents). Updating apps is done on AppManager init and then periodically (once a day using DailyJobsService). For every new definition downloaded an RPC event is fired (evt.apps.new_definition).
- Loading branch information
Showing
7 changed files
with
258 additions
and
15 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,118 @@ | ||
import abc | ||
import datetime | ||
import logging | ||
from pathlib import Path | ||
import typing | ||
import xml.etree.ElementTree as xml | ||
|
||
from dataclasses import dataclass | ||
import dateutil.parser as date_parser | ||
import requests | ||
|
||
from golem.apps import save_app_to_json_file, AppDefinition | ||
from golem.core.variables import APP_DEFINITIONS_CDN_URL | ||
|
||
logger = logging.getLogger(__name__) | ||
|
||
|
||
class FromXml(abc.ABC): | ||
""" Base class for objects which can be parsed from XML. This is used to | ||
provide basic support for handling XML objects with namespaces. """ | ||
def __init__(self, ns_map: typing.Dict[str, str]): | ||
self._namespace_map = ns_map | ||
|
||
def _get_element( | ||
self, | ||
element: xml.Element, | ||
name: str): | ||
key, _ = list(self._namespace_map.items())[0] | ||
return element.find(f'{key}:{name}', self._namespace_map) | ||
|
||
def _get_elements( | ||
self, | ||
element: xml.Element, | ||
name: str | ||
) -> typing.List[xml.Element]: | ||
key, _ = list(self._namespace_map.items())[0] | ||
return element.findall(f'{key}:{name}', self._namespace_map) | ||
|
||
|
||
@dataclass | ||
class Contents(FromXml): | ||
""" Represents a single `Contents` entry in a bucket listing. Such an entry | ||
corresponds to an object stored within that bucket. """ | ||
etag: str | ||
key: str | ||
last_modified: datetime.datetime | ||
size: int # size in bytes | ||
|
||
def __init__(self, root: xml.Element, ns_map: typing.Dict[str, str]): | ||
super().__init__(ns_map) | ||
self.key = self._get_element(root, 'Key').text | ||
self.etag = self._get_element(root, 'ETag').text | ||
self.size = int(self._get_element(root, 'Size').text) | ||
self.last_modified = date_parser.isoparse( | ||
self._get_element(root, 'LastModified').text) | ||
|
||
|
||
@dataclass | ||
class ListBucketResult(FromXml): | ||
""" Contains metadata about objects stored in an S3 bucket. """ | ||
contents: typing.List[Contents] | ||
|
||
def __init__(self, root: xml.Element): | ||
namespace_map = {'ns': _get_namespace(root)} | ||
super().__init__(namespace_map) | ||
|
||
self.contents = [ | ||
Contents(e, self._namespace_map) | ||
for e in self._get_elements(root, 'Contents')] | ||
|
||
|
||
def _get_namespace(element: xml.Element): | ||
""" Hacky way of extracting the namespace from an XML element. | ||
This assumes the document uses Clark's notation for tags | ||
(i.e. {uri}local_part or local_part for empty namespace). """ | ||
tag = element.tag | ||
return tag[tag.find("{")+1:tag.rfind("}")] | ||
|
||
|
||
def get_bucket_listing() -> ListBucketResult: | ||
response = requests.get(APP_DEFINITIONS_CDN_URL) | ||
response.raise_for_status() | ||
root: xml.Element = xml.fromstring(response.content) | ||
return ListBucketResult(root) | ||
|
||
|
||
def download_definition( | ||
key: str, | ||
destination: Path) -> AppDefinition: | ||
logger.debug( | ||
'download_definition. key=%s, destination=%s', key, destination) | ||
response = requests.get(f'{APP_DEFINITIONS_CDN_URL}{key}') | ||
response.raise_for_status() | ||
definition = AppDefinition.from_json(response.text) | ||
save_app_to_json_file(definition, destination) | ||
return definition | ||
|
||
|
||
def download_definitions(app_dir: Path) -> typing.List[AppDefinition]: | ||
""" Download app definitions from Golem Factory CDN. Only downloads | ||
definitions which are not already present locally. | ||
:param: app_dir: path to directory containing local app definitions. | ||
:return: list of newly downloaded app definitions. """ | ||
new_definitions = [] | ||
bucket_listing = get_bucket_listing() | ||
logger.debug( | ||
'download_definitions. app_dir=%s, bucket_listing=%r', | ||
app_dir, | ||
bucket_listing | ||
) | ||
|
||
for metadata in bucket_listing.contents: | ||
definition_path = app_dir / metadata.key | ||
if not (definition_path).exists(): | ||
new_definitions.append( | ||
download_definition(metadata.key, definition_path)) | ||
|
||
return new_definitions |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,59 @@ | ||
from mock import Mock, patch | ||
|
||
import requests | ||
|
||
import golem.apps.downloader as downloader | ||
from golem.testutils import TempDirFixture | ||
|
||
ROOT_PATH = 'golem.apps.downloader' | ||
|
||
APP_KEY = 'test-app_0.1.0_asdf1234.json' | ||
BUCKET_LISTING_XML = f'''<?xml version="1.0" encoding="UTF-8"?> | ||
<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/"> | ||
<Name>golem-app-definitions</Name> | ||
<Contents> | ||
<Key>{APP_KEY}</Key> | ||
<LastModified>2020-02-28T08:49:34.000Z</LastModified> | ||
<ETag>"1c5dbeaaf0589820b799448664d24864"</ETag> | ||
<Size>357</Size> | ||
<StorageClass>STANDARD</StorageClass> | ||
</Contents> | ||
</ListBucketResult> | ||
''' | ||
|
||
|
||
class TestAppDownloader(TempDirFixture): | ||
|
||
@patch(f'{ROOT_PATH}.get_bucket_listing') | ||
@patch(f'{ROOT_PATH}.download_definition') | ||
def test_download_definitions(self, download_mock, bucket_listing_mock): | ||
apps_path = self.new_path / 'apps' | ||
apps_path.mkdir(exist_ok=True) | ||
existing_app_path = apps_path / APP_KEY | ||
existing_app_path.touch() | ||
new_app_key = 'downloaded_app.json' | ||
metadata = [ | ||
Mock(spec=downloader.Contents, key=APP_KEY), | ||
Mock(spec=downloader.Contents, key=new_app_key), | ||
] | ||
bucket_listing_mock.return_value = Mock( | ||
spec=downloader.ListBucketResult, contents=metadata) | ||
|
||
new_definitions = downloader.download_definitions(apps_path) | ||
|
||
self.assertEqual(len(new_definitions), 1) | ||
download_mock.assert_called_once_with( | ||
new_app_key, apps_path / new_app_key) | ||
self.assertEqual(download_mock.call_count, 1) | ||
|
||
@patch('requests.get') | ||
def test_get_bucket_listing(self, mock_get): | ||
response = Mock(spec=requests.Response) | ||
response.status_code = 200 | ||
response.content = BUCKET_LISTING_XML | ||
mock_get.return_value = response | ||
|
||
result = downloader.get_bucket_listing() | ||
|
||
self.assertEqual(len(result.contents), 1) | ||
self.assertEqual(result.contents[0].key, APP_KEY) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters