Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Invoke plugins lambda from backend for npe2 manifest discovery #673

Merged
merged 44 commits into from
Oct 14, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
6bfefbd
First deploy inovking plugins from backend
DragaDoncila Sep 30, 2022
0a6d6d7
First deploy inovking plugins from backend
DragaDoncila Sep 30, 2022
f917d2c
Merge branch 'dev-npe2-triggers' of github.com:chanzuckerberg/napari-…
DragaDoncila Sep 30, 2022
ed936b3
move env var declaration and use json.dumps for payload
DragaDoncila Sep 30, 2022
15a2e00
Update manfiest writing path
DragaDoncila Sep 30, 2022
4c885d4
Fix tests
DragaDoncila Sep 30, 2022
f153679
Add copious print statements
DragaDoncila Sep 30, 2022
302ad7f
Convert generator to list
DragaDoncila Sep 30, 2022
6ba70ce
Add more printing
DragaDoncila Sep 30, 2022
648cbbd
Add listobjects permission
DragaDoncila Sep 30, 2022
eb112a6
Add early return until I figure out s3 permissions
DragaDoncila Sep 30, 2022
908188b
Try adding listobjects on bucket itself.
DragaDoncila Oct 3, 2022
9c22c7d
Try ListBucket
DragaDoncila Oct 3, 2022
1289880
Use bucket for put object
DragaDoncila Oct 3, 2022
f7cf423
Update print statements
DragaDoncila Oct 3, 2022
d46d21f
First deploy inovking plugins from backend
DragaDoncila Sep 30, 2022
d325a8b
move env var declaration and use json.dumps for payload
DragaDoncila Sep 30, 2022
8d91ca5
Update manfiest writing path
DragaDoncila Sep 30, 2022
9562c8f
Fix tests
DragaDoncila Sep 30, 2022
0945774
Add copious print statements
DragaDoncila Sep 30, 2022
fed5eb4
Convert generator to list
DragaDoncila Sep 30, 2022
758dc73
Add more printing
DragaDoncila Sep 30, 2022
73c3a79
Add listobjects permission
DragaDoncila Sep 30, 2022
1a29541
Add early return until I figure out s3 permissions
DragaDoncila Sep 30, 2022
59d013d
Try adding listobjects on bucket itself.
DragaDoncila Oct 3, 2022
7c0041c
Try ListBucket
DragaDoncila Oct 3, 2022
da29692
Use bucket for put object
DragaDoncila Oct 3, 2022
a904656
Update print statements
DragaDoncila Oct 3, 2022
c00ca7e
Limit lambda resources
DragaDoncila Oct 7, 2022
222b452
Merge branch 'dev-npe2-triggers' of github.com:chanzuckerberg/napari-…
DragaDoncila Oct 7, 2022
a0d63f1
Merge branch 'main' into dev-npe2-triggers
DragaDoncila Oct 7, 2022
b96aa31
update min ephemeral storage size
DragaDoncila Oct 7, 2022
bc2f77d
Merge branch 'dev-npe2-triggers' of github.com:chanzuckerberg/napari-…
DragaDoncila Oct 7, 2022
afd67c0
Remove single plugin guard
DragaDoncila Oct 7, 2022
a710875
Give more memory, update print statement
DragaDoncila Oct 7, 2022
1e1be43
Update timeout to 2 minutes
DragaDoncila Oct 7, 2022
0dad303
Write empty file at beginning of processing, update api messages
DragaDoncila Oct 12, 2022
26f68bd
Fix tests
DragaDoncila Oct 12, 2022
63e56fa
Add test dep
DragaDoncila Oct 12, 2022
a743680
dont actually need that dep
DragaDoncila Oct 12, 2022
7829a2a
Change timeout back to original 2.5 min
DragaDoncila Oct 12, 2022
dfb6816
Refactor discovery invocation
DragaDoncila Oct 13, 2022
ce40527
Fix bug with lambda invocation condition
DragaDoncila Oct 13, 2022
5a5815e
Turn on npe2 feature for staging
DragaDoncila Oct 14, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions .happy/terraform/modules/ecs-stack/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ module backend_lambda {
"DD_ENV" = var.env
"DD_SERVICE" = local.custom_stack_name
"API_URL" = var.env == "dev" ? module.api_gateway_proxy_stage.invoke_url : ""
"PLUGINS_LAMBDA_NAME" = local.plugins_function_name
}

log_retention_in_days = 14
Expand Down Expand Up @@ -144,8 +145,8 @@ module plugins_lambda {

log_retention_in_days = 14
timeout = 150
memory_size = 10240
ephemeral_storage_size = 10240
memory_size = 256
ephemeral_storage_size = 512

}

Expand Down Expand Up @@ -213,19 +214,36 @@ data aws_iam_policy_document backend_policy {

resources = ["${local.data_bucket_arn}/*"]
}

statement {
actions = [
"lambda:InvokeFunction"
]

resources = [
module.plugins_lambda.function_arn,
]
}
}

data aws_iam_policy_document plugins_policy {
statement {
actions = [
"s3:PutObject",
"s3:GetObject",
"s3:DeleteObject",
]

resources = ["${local.data_bucket_arn}/*"]
}

statement {
actions = [
"s3:ListBucket",
]

resources = ["${local.data_bucket_arn}"]
}

}

resource aws_iam_role_policy policy {
Expand Down
13 changes: 6 additions & 7 deletions backend/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,24 +69,23 @@ def versioned_plugin(plugin: str, version: str = None) -> Response:
@app.route('/manifest/<plugin>', defaults={'version': None})
@app.route('/manifest/<plugin>/versions/<version>')
def plugin_manifest(plugin: str, version: str = None) -> Response:
max_failure_tries = 2
manifest = get_manifest(plugin, version)

if not manifest:
return app.make_response(("Plugin does not exist", 404))

if 'process_count' not in manifest:
if 'error' not in manifest:
return jsonify(manifest)

current_tries = manifest['process_count']
if current_tries >= max_failure_tries:
return app.make_response(
("Plugin Manifest Not Found. Installation failed or plugin does not implement npe2", 404))
else:
error = manifest['error']
if error == 'Manifest not yet processed.':
response = app.make_response(("Temporarily Unavailable. Attempting to build manifest. Please check back"
" in 5 minutes.", 503))
response.headers["Retry-After"] = 300
return response
else:
return app.make_response(
("Plugin Manifest Not Found. Manifest discovery failed.", 404))


@app.route('/shields/<plugin>')
Expand Down
60 changes: 52 additions & 8 deletions backend/api/model.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from concurrent import futures
from datetime import datetime
import json
import os
from typing import Tuple, Dict, List, Callable, Any
from zipfile import ZipFile
from io import BytesIO
Expand All @@ -12,6 +14,7 @@
from utils.utils import render_description, send_alert, get_attribute, get_category_mapping, parse_manifest
from utils.datadog import report_metrics
from api.zulip import notify_new_packages
import boto3

index_subset = {'name', 'summary', 'description_text', 'description_content_type',
'authors', 'license', 'python_version', 'operating_system',
Expand Down Expand Up @@ -74,13 +77,36 @@ def get_plugin(plugin: str, version: str = None) -> dict:


def get_frontend_manifest_metadata(plugin, version):
# load manifest from json (triggering build)
"""Get manifest from cache, if it exists and parse into frontend fields

When `error` is in the returned metadata, we return
default values to the frontend.

:param plugin: name of the plugin to get
:param version: version of the plugin manifest
:return: parsed metadata for the frontend
"""
raw_metadata = get_manifest(plugin, version)
if 'process_count' in raw_metadata:
if 'error' in raw_metadata:
raw_metadata = None
interpreted_metadata = parse_manifest(raw_metadata)
return interpreted_metadata

def discover_manifest(plugin: str, version: str = None):
"""
Invoke plugins lambda to generate manifest & write to cache.

:param plugin: name of the plugin to get
:param version: version of the plugin manifest
"""
client = boto3.client('lambda')
lambda_event = {'plugin': plugin, 'version': version}
# this lambda invocation will call `napari-hub/plugins/get_plugin_manifest/generate_manifest`
client.invoke(
FunctionName=os.environ.get('PLUGINS_LAMBDA_NAME'),
InvocationType='Event',
Payload=json.dumps(lambda_event),
)

def get_manifest(plugin: str, version: str = None) -> dict:
"""
Expand All @@ -95,11 +121,21 @@ def get_manifest(plugin: str, version: str = None) -> dict:
elif version is None:
version = plugins[plugin]
plugin_metadata = get_cache(f'cache/{plugin}/{version}-manifest.json')
if plugin_metadata:
return plugin_metadata
else:
cache({"process_count": 0}, f'cache/{plugin}/{version}-manifest.json')
return {"process_count": 0}

# plugin_metadata being None indicates manifest is not cached and needs processing
if plugin_metadata is None:
return {'error': 'Manifest not yet processed.'}

# empty dict indicates some lambda error in processing e.g. timed out
if plugin_metadata == {}:
return {'error': 'Processing manifest failed due to external error.'}

# error written to file indicates manifest discovery failed
if 'error' in plugin_metadata:
return {'error': plugin_metadata['error']}

# correct plugin manifest
return plugin_metadata


def get_index() -> dict:
Expand Down Expand Up @@ -137,7 +173,15 @@ def get_excluded_plugins() -> Dict[str, str]:


def build_manifest_metadata(plugin: str, version: str) -> Tuple[str, dict]:
metadata = get_frontend_manifest_metadata(plugin, version)
manifest = get_manifest(plugin, version)
if 'error' in manifest:
if 'Manifest not yet processed' in manifest['error']:
# this will invoke the plugins lambda & write manifes to cache
discover_manifest(plugin, version)
# return just default values for now
metadata = parse_manifest()
else:
metadata = parse_manifest(manifest)
return plugin, metadata


Expand Down
2 changes: 1 addition & 1 deletion frontend/src/utils/featureFlags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export const FEATURE_FLAGS = createFeatureFlags({
},

npe2: {
environments: ['dev'],
environments: ['dev', 'staging'],
},

collections: {
Expand Down
36 changes: 25 additions & 11 deletions plugins/_tests/test_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,18 @@
import os
import pytest
from unittest import mock
from unittest.mock import call
from plugins.get_plugin_manifest import generate_manifest
from botocore.exceptions import ClientError

TEST_PLUGIN = 'test-plugin'
TEST_VERSION = '0.0.1'
TEST_BUCKET = 'test-bucket'
TEST_CACHE_PATH = f'cache/{TEST_PLUGIN}/{TEST_PLUGIN}.{TEST_VERSION}-manifest.json'
TEST_CACHE_PATH = f'cache/{TEST_PLUGIN}/{TEST_VERSION}-manifest.json'

def _mock_put_object(Body, Bucket, Key):
if os.path.exists(Key):
raise FileExistsError
else:
with open(Key, 'w') as fp:
fp.write(Body)
def _mock_put_object(Body, Key):
with open(Key, 'w') as fp:
fp.write(Body)


@mock.patch('plugins.get_plugin_manifest.s3')
Expand All @@ -30,7 +28,7 @@ def test_discovery_manifest_exists(s3, tmp_path):
bucket_instance = s3.Bucket.return_value
bucket_instance.objects.filter.return_value = [TEST_PLUGIN]
generate_manifest({'plugin': TEST_PLUGIN, 'version': TEST_VERSION}, None)
s3.put_object.assert_not_called()
bucket_instance.put_object.assert_not_called()


@mock.patch('plugins.get_plugin_manifest.s3')
Expand Down Expand Up @@ -67,7 +65,7 @@ def test_discovery_failure(s3, tmp_path):
with mock.patch('plugins.get_plugin_manifest.bucket_path', tmp_path):
bucket_instance = s3.Bucket.return_value
bucket_instance.objects.filter.return_value = []
s3.put_object = _mock_put_object
bucket_instance.put_object = _mock_put_object
generate_manifest({'plugin': TEST_PLUGIN, 'version': TEST_VERSION}, None)
written = json.loads(manifest_pth.read_text())
assert written['error'] == 'HTTP Error 404: Not Found'
Expand All @@ -80,17 +78,33 @@ def test_discovery_success(s3, tmp_path):
plugin_name = 'napari-demo'
plugin_version = 'v0.1.0'

manifest_pth = tmp_path / f'cache/{plugin_name}/{plugin_name}.{plugin_version}-manifest.json'
manifest_pth = tmp_path / f'cache/{plugin_name}/{plugin_version}-manifest.json'
manifest_pth.parent.mkdir(parents=True)
with mock.patch('plugins.get_plugin_manifest.bucket_path', tmp_path):
bucket_instance = s3.Bucket.return_value
bucket_instance.objects.filter.return_value = []
s3.put_object = _mock_put_object
bucket_instance.put_object = _mock_put_object
generate_manifest({'plugin': plugin_name, 'version': plugin_version}, None)
written = json.loads(manifest_pth.read_text())
assert written['name'] == 'napari-demo'
assert len(written['contributions']['widgets']) == 1

@mock.patch('plugins.get_plugin_manifest.bucket_name', '')
def test_bucket_name_not_set():
with pytest.raises(RuntimeError, match='Bucket name not specified.'):
generate_manifest({}, None)

@mock.patch('plugins.get_plugin_manifest.s3')
@mock.patch('plugins.get_plugin_manifest.bucket_name', 'napari-hub')
def test_file_always_written(s3, tmp_path):
plugin_name = 'napari-demo'
plugin_version = 'v0.1.0'

manifest_pth = tmp_path / f'cache/{plugin_name}/{plugin_version}-manifest.json'
manifest_pth.parent.mkdir(parents=True)
with mock.patch('plugins.get_plugin_manifest.bucket_path', tmp_path):
bucket_instance = s3.Bucket.return_value
bucket_instance.objects.filter.return_value = []
generate_manifest({'plugin': plugin_name, 'version': plugin_version}, None)
assert bucket_instance.put_object.call_count == 2
bucket_instance.put_object.assert_has_calls([call(Body='{}', Key=str(manifest_pth))])
14 changes: 11 additions & 3 deletions plugins/get_plugin_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,24 @@ def generate_manifest(event, context):

plugin = event['plugin']
version = event['version']
key = os.path.join(bucket_path, f'cache/{plugin}/{plugin}.{version}-manifest.json')
key = os.path.join(bucket_path, f'cache/{plugin}/{version}-manifest.json')
print(f'Processing {key}')
# if the manifest for this plugin already exists there's nothing do to
bucket = s3.Bucket(bucket_name)
existing_manifest_summary = bucket.objects.filter(Prefix=key)
existing_manifest_summary = list(bucket.objects.filter(Prefix=key))
print(f'Matching manifests in bucket: {existing_manifest_summary}')
if existing_manifest_summary:
print("Manifest exists... returning.")
return

# write file to s3 to ensure we never retry this plugin version
bucket.put_object(Body=json.dumps({}), Key=key)
try:
print('Discovering manifest...')
manifest = fetch_manifest(plugin, version)
s3_body = manifest.json()
except Exception as e:
print("Failed discovery...")
s3_body = json.dumps({'error': str(e)})
s3.put_object(Body=s3_body, Bucket=bucket_name, Key=key)
print(f'Writing {s3_body} to {key} in {bucket_name}')
bucket.put_object(Body=s3_body, Key=key)