| # Licensed to the Apache Software Foundation (ASF) under one or more | |
| # contributor license agreements. See the NOTICE file distributed with | |
| # this work for additional information regarding copyright ownership. | |
| # The ASF licenses this file to You under the Apache License, Version 2.0 | |
| # (the "License"); you may not use this file except in compliance with | |
| # the License. You may obtain a copy of the License at | |
| # | |
| # http://www.apache.org/licenses/LICENSE-2.0 | |
| # | |
| # Unless required by applicable law or agreed to in writing, software | |
| # distributed under the License is distributed on an "AS IS" BASIS, | |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| # See the License for the specific language governing permissions and | |
| # limitations under the License. | |
| import hashlib | |
| import json | |
| import logging | |
| import os | |
| import re | |
| import traceback | |
| from copy import deepcopy | |
| from functools import cache | |
| from urllib.parse import urlparse | |
| import boto3 | |
| import earthaccess | |
| import requests | |
| import shapely | |
| from shapely import from_wkt, intersects | |
| from shapely.geometry import box, Polygon, MultiPolygon | |
| logging.basicConfig(level=logging.DEBUG) | |
| s3 = boto3.client('s3') | |
| sns = boto3.client('sns') | |
| ddb = boto3.client('dynamodb') | |
| SNS_ARN = os.environ['SNS_ARN'] | |
| DDB_ARN = os.environ['DDB_ARN'] | |
| PROJECT = os.environ.get('PROJECT', 'CMR SUBSCRIBER') | |
| CHECKSUMS = { | |
| "MD5": hashlib.md5, | |
| "SHA-1": hashlib.sha1, | |
| "SHA-256": hashlib.sha256, | |
| "SHA-384": hashlib.sha384, | |
| "SHA-512": hashlib.sha512, | |
| } | |
| GLOBAL = box(-180, -90, 180, 90) | |
| try: | |
| import requests_mock | |
| except ImportError: | |
| print('Request mocking disabled') | |
| def _json_encode(obj: object) -> str: | |
| if isinstance(obj, shapely.geometry.base.BaseGeometry): | |
| return obj.wkt | |
| else: | |
| return repr(obj) | |
| def _set_edl_environ(): | |
| # Replace with your secret ID | |
| secret_name = os.environ['SECRET_ARN'] | |
| # Create a Secrets Manager client | |
| client = boto3.client('secretsmanager') | |
| # Get the secret value | |
| get_secret_value_response = client.get_secret_value(SecretId=secret_name) | |
| # Decode the secret value | |
| secret_value = get_secret_value_response['SecretString'] | |
| secret_json = json.loads(secret_value) | |
| os.environ['EARTHDATA_USERNAME'] = secret_json.pop('EARTHDATA_USER') | |
| os.environ['EARTHDATA_PASSWORD'] = secret_json.pop('EARTHDATA_PASSWORD') | |
| for secret_k in secret_json: | |
| os.environ[secret_k] = secret_json[secret_k] | |
| def _process_record(record, bearer_token): | |
| print(f'Processing record: {record}') | |
| message_type = record['Type'] | |
| if message_type == 'Notification': | |
| _handle_cmr_notification(json.loads(record['Message']), bearer_token) | |
| elif message_type == 'SubscriptionConfirmation': | |
| _forward_confirmation(record) | |
| else: | |
| print(f'Unknown message type: {message_type}') | |
| _fail_out_record(record, reason='Unknown message type') | |
| def _sns_send(subject, message): | |
| resp = sns.publish( | |
| TopicArn=SNS_ARN, | |
| Subject=subject, | |
| Message=json.dumps(message, indent=2)[:262144], | |
| ) | |
| print(resp) | |
| def _forward_confirmation(record): | |
| print('Forwarding subscription confirmation by SNS') | |
| _sns_send( | |
| f'[{PROJECT}] Confirm forward ingest CMR subscription', | |
| record | |
| ) | |
| def _fail_out_record(record, reason='exceeded retries'): | |
| print('Publishing record failure to SNS') | |
| _sns_send( | |
| f'[{PROJECT}] CMR forward ingest job failed ({reason})', | |
| record | |
| ) | |
| def _submit_maap_job(short_name, granule_ur, maap_config=None): | |
| try: | |
| from maap.maap import MAAP | |
| except ImportError: | |
| print('FATAL: MAAP py package not installed. Try rebuilding zip package with the package_maap.zip target') | |
| raise | |
| maap = MAAP() | |
| if maap_config is None: | |
| raise Exception('MAAP config not provided') | |
| ccid = maap.searchGranule( | |
| short_name=short_name, | |
| readable_granule_name=granule_ur, | |
| cmr_host="cmr.earthdata.nasa.gov", | |
| limit=1 | |
| )[0]['collection-concept-id'] | |
| algo = os.environ['MAAP_ALGO_ID'] | |
| algo_version = os.environ['MAAP_ALGO_VERSION'] | |
| queue = os.environ['MAAP_QUEUE'] | |
| kwargs = {v.removeprefix('_maap_kwarg_'): os.environ[v] for v in os.environ.keys() if v.startswith('_maap_kwarg_')} | |
| kwargs.update(maap_config) | |
| job_kwargs = dict( | |
| identifier=short_name, | |
| algo_id=algo, | |
| version=algo_version, | |
| queue=queue, | |
| granule_id=granule_ur, | |
| collection_id=ccid, | |
| concept_id=ccid, | |
| ) | |
| final_maap_args = deepcopy(job_kwargs) | |
| final_maap_args.update(kwargs) | |
| print(f'Submitting MAAP job with parameters: {final_maap_args}') | |
| job = maap.submitJob(**final_maap_args) | |
| if job.id is None or job.id == '': | |
| print(f'MAAP job submission failed: {job.error_details}') | |
| print(job) | |
| raise Exception('MAAP job submission failed') | |
| print(f'Submitted job {job.id}') | |
| return job.id | |
| def _handle_cmr_notification(message, bearer_token): | |
| print(f'Handling CMR notification: {message}') | |
| granule_metadata_url = f'{message["location"]}.umm_json' | |
| print(f'Getting UMM metadata for granule {message["granule-ur"]} from {granule_metadata_url}') | |
| umm_response = requests.get(granule_metadata_url) | |
| try: | |
| umm_response.raise_for_status() | |
| except: | |
| if umm_response.status_code == 404: | |
| print(f'Got a 404 for granule {message["granule-ur"]} at URL {granule_metadata_url}, it was likely ' | |
| f'superseded. Trying to pull the latest revision.') | |
| maybe_umm = _maybe_get_latest_revision(message['location']) | |
| if maybe_umm is None: | |
| return | |
| else: | |
| umm_response = maybe_umm | |
| else: | |
| raise | |
| umm = umm_response.json() | |
| processed_umm = _process_umm(umm) | |
| print(json.dumps(processed_umm, indent=2, default=_json_encode)) | |
| if processed_umm['collection_geo'] is not None: | |
| desired_geo = processed_umm['collection_geo'] | |
| granule_geo = processed_umm['spatial_extent'] | |
| if not intersects(granule_geo, desired_geo): | |
| print(f'Granule {message["granule-ur"]} does not intersect geo filter. Skipping.') | |
| return | |
| if 'MAAP_PGT' in os.environ: | |
| print('Submitting job through MAAP instead of staging in this function') | |
| return _submit_maap_job( | |
| processed_umm['collection'], | |
| message["granule-ur"], | |
| processed_umm['maap_config'] | |
| ) | |
| print(f'Downloading files for granule {processed_umm["granule"]}') | |
| s3_client = None | |
| if processed_umm['s3_credentials_url'] is not None: | |
| print('Attempting to get temporary S3 credentials from the DAAC...') | |
| creds = _try_get_s3_creds(processed_umm['s3_credentials_url'], bearer_token) | |
| if creds is not None: | |
| print(f'Got temporary S3 credentials: {creds["accessKeyId"]} (exp: {creds["expiration"]})') | |
| s3_client = boto3.client( | |
| 's3', | |
| aws_access_key_id=creds['accessKeyId'], | |
| aws_secret_access_key=creds['secretAccessKey'], | |
| aws_session_token=creds['sessionToken'], | |
| ) | |
| else: | |
| print('Could not get temporary S3 credentials, using HTTP') | |
| s3_tries = 3 if s3_client is not None else 0 | |
| for file in processed_umm['files']: | |
| file_info = processed_umm['files'][file] | |
| dl_path = None | |
| if s3_tries > 0 and 's3' in file_info: | |
| dl_path = _try_download_s3(file_info['s3'], s3_client) | |
| if dl_path is None: | |
| s3_tries -= 1 | |
| if dl_path is None: | |
| dl_path = _try_download_http(file_info['http'], bearer_token) | |
| if dl_path is None: | |
| raise RuntimeError(f'Could not download file {file}') | |
| if file_info['checksum'] is not None and file_info['checksum']['Algorithm'] in CHECKSUMS: | |
| print(f'Verifying checksum [{file_info["checksum"]["Algorithm"]}]...') | |
| if not _try_validate_checksum(dl_path, file_info['checksum']): | |
| raise RuntimeError(f'Checksum mismatch for file {file}') | |
| print('Checksum verified') | |
| push_s3_client = boto3.client('s3') | |
| dst_bucket = os.environ['DST_BUCKET'] | |
| dst_key = processed_umm['s3_prefix'] + file | |
| print(f'Uploading file {file} to s3://{dst_bucket}/{dst_key}') | |
| push_s3_client.upload_file( | |
| dl_path, | |
| dst_bucket, | |
| dst_key | |
| ) | |
| os.unlink(dl_path) | |
| def _maybe_get_latest_revision(location_url): | |
| match = re.search(r'/\d+$', location_url) | |
| if match is None: | |
| return None | |
| latest_rev_url = location_url[:match.start()] + '.umm_json' | |
| umm_response = requests.get(latest_rev_url) | |
| try: | |
| umm_response.raise_for_status() | |
| except: | |
| if umm_response.status_code == 404: | |
| print(f'No record exists for latest revision URL ({latest_rev_url}). It was may no longer exist ' | |
| f'and will thus be skipped') | |
| return None | |
| else: | |
| raise | |
| umm = umm_response.json() | |
| collection = umm['CollectionReference']['ShortName'] | |
| print('Got latest revision metadata, now checking if it is allowed to use this for this collection') | |
| collection_options = _search_collection_options(collection) | |
| if collection_options is None: | |
| print('Skipping this granule because there is no configuration to allow using latest revision metadata') | |
| return None | |
| if 'use_latest_rev' not in collection_options: | |
| print('Skipping this granule because there is no configuration to allow using latest revision metadata') | |
| return None | |
| if collection_options['use_latest_rev']['BOOL']: | |
| print('Collection supports using latest revision metadata, continuing...') | |
| return umm_response | |
| else: | |
| print('Skipping this granule because its collection configuration disallows using latest revision metadata') | |
| return None | |
| @cache | |
| def _search_collection_options(collection): | |
| collection_query = ddb.query( | |
| TableName=DDB_ARN, | |
| KeyConditionExpression='#collection_short_name = :c', | |
| ExpressionAttributeValues={ | |
| ':c': {'S': collection} | |
| }, | |
| ExpressionAttributeNames={ | |
| '#collection_short_name': 'collection' | |
| } | |
| ) | |
| print(f'Checked lookup table for {collection}') | |
| print(collection_query['Items']) | |
| if len(collection_query['Items']) == 0: | |
| return None | |
| else: | |
| collection_entry = collection_query['Items'][0] | |
| return collection_entry | |
| def _process_umm(umm): | |
| # I don't think this is guaranteed (this or EntryTitle). What does other field look like? | |
| collection = umm['CollectionReference']['ShortName'] | |
| granule = umm['GranuleUR'] | |
| links = umm['RelatedUrls'] | |
| archive_info = umm.get('DataGranule', {}).get('ArchiveAndDistributionInformation', []) | |
| archive_info = {i['Name']: i['Checksum'] for i in archive_info if 'Checksum' in i} | |
| http_urls = [u['URL'] for u in links if u['Type'] == 'GET DATA'] | |
| s3_urls = [u['URL'] for u in links if u['Type'] == 'GET DATA VIA DIRECT ACCESS'] | |
| file_map = {} | |
| for u in http_urls: | |
| filename = os.path.basename(u) | |
| file_map.setdefault(filename, {})['http'] = u | |
| for u in s3_urls: | |
| filename = os.path.basename(u) | |
| file_map.setdefault(filename, {})['s3'] = u | |
| if len(file_map) == 0: | |
| raise Exception('No data granules found') | |
| if any('http' not in file_map[f] for f in file_map): | |
| raise Exception('Granule without HTTP fallback method found') | |
| for f in file_map: | |
| if f in archive_info: | |
| file_map[f]['checksum'] = archive_info[f] | |
| else: | |
| file_map[f]['checksum'] = None | |
| creds_url = None | |
| for url in links: | |
| if url['URL'].endswith('/s3credentials'): | |
| creds_url = url['URL'] | |
| break | |
| try: | |
| spatial_extent = umm['SpatialExtent']['HorizontalSpatialDomain'] | |
| if 'Geometry' in spatial_extent: | |
| if 'BoundingRectangles' in spatial_extent['Geometry']: | |
| bounding_rectangles = spatial_extent['Geometry']['BoundingRectangles'] | |
| if len(bounding_rectangles) > 1: | |
| raise ValueError('Multiple bounding rectangles given when one expected') | |
| bbox_dict = bounding_rectangles[0] | |
| bbox = box( | |
| bbox_dict['WestBoundingCoordinate'], | |
| bbox_dict['SouthBoundingCoordinate'], | |
| bbox_dict['EastBoundingCoordinate'], | |
| bbox_dict['NorthBoundingCoordinate'], | |
| ) | |
| elif 'GPolygons' in spatial_extent['Geometry']: | |
| polygons = [] | |
| for polygon in spatial_extent['Geometry']['GPolygons']: | |
| polygons.append(Polygon([(p['Longitude'], p['Latitude']) for p in polygon['Boundary']['Points']])) | |
| if len(polygons) == 1: | |
| bbox = polygons[0] | |
| else: | |
| bbox = MultiPolygon(polygons) | |
| else: | |
| raise ValueError('Spatial extent geometry not provided') | |
| except Exception as e: | |
| print(f'WARN: Unable to get bbox from umm: {e!r}. Using global extent instead') | |
| bbox = GLOBAL | |
| collection_entry = _search_collection_options(collection) | |
| if collection_entry is None: | |
| s3_prefix = collection | |
| desired_geo = None | |
| maap_config = None | |
| else: | |
| s3_prefix = collection_entry['s3_prefix']['S'] if 's3_prefix' in collection_entry else collection | |
| desired_geo = from_wkt(collection_entry['polygon']['S']) if 'polygon' in collection_entry else None | |
| maap_config = collection_entry['maap_config']['M'] if 'maap_config' in collection_entry else {} | |
| maap_config = {k: list(v.values())[0] for k, v in maap_config.items()} | |
| if desired_geo is not None and desired_geo.geom_type != 'Polygon': | |
| print(f'WARN: Collection settings define incorrect geo filter geometry type. Must be POLYGON. Disabling filter') | |
| desired_geo = None | |
| if s3_prefix[-1] != '/': | |
| s3_prefix += '/' | |
| return dict( | |
| granule=granule, | |
| collection=collection, | |
| files=file_map, | |
| s3_prefix=s3_prefix, | |
| s3_credentials_url=creds_url, | |
| maap_config=maap_config, | |
| spatial_extent=bbox, | |
| collection_geo=desired_geo, | |
| ) | |
| @cache | |
| def _try_get_s3_creds(endpoint, bearer_token): | |
| try: | |
| headers = { | |
| "Authorization": f"Bearer {bearer_token}", | |
| } | |
| resp = requests.get(endpoint, headers=headers) | |
| resp.raise_for_status() | |
| return resp.json() | |
| except Exception as e: | |
| print(f'Failed to get S3 credentials [{resp.status_code}]: {e}') | |
| return None | |
| def _try_download_s3(url, client): | |
| try: | |
| parsed_url = urlparse(url) | |
| bucket = parsed_url.netloc | |
| key = parsed_url.path.lstrip('/') | |
| dst = os.path.join('/tmp', os.path.basename(key)) | |
| print(f'Attempting S3 download: {url}') | |
| client.download_file(bucket, key, dst) | |
| return dst | |
| except Exception as e: | |
| print(f'S3 download failed: {e}') | |
| return None | |
| def _try_download_http(url, bearer_token): | |
| headers = { | |
| "Authorization": f"Bearer {bearer_token}", | |
| } | |
| try: | |
| resp = requests.get(url, headers=headers, stream=True) | |
| resp.raise_for_status() | |
| dst = os.path.join('/tmp', os.path.basename(url)) | |
| with open(dst, 'wb') as f: | |
| for chunk in resp.iter_content(chunk_size=1024 ** 2): | |
| if chunk: | |
| f.write(chunk) | |
| return dst | |
| except Exception as e: | |
| print(f'Failed to download file [{url}]: {e}') | |
| return None | |
| def _try_validate_checksum(path, checksum): | |
| hash_fn = CHECKSUMS[checksum['Algorithm']]() | |
| with open(path, 'rb') as f: | |
| for chunk in iter(lambda: f.read(1024 * hash_fn.block_size), b''): | |
| hash_fn.update(chunk) | |
| dl_hash = hash_fn.hexdigest().lower() | |
| return dl_hash == checksum['Value'].lower() | |
| def lambda_handler(event, context): | |
| _set_edl_environ() | |
| auth = earthaccess.login(strategy='environment') | |
| bearer_token = auth.token['access_token'] | |
| batch_item_failures = [] | |
| for record in event['Records']: | |
| try: | |
| _process_record(json.loads(record['body']), bearer_token) | |
| except Exception as e: | |
| rec_count = int(record.get('attributes', {}).get("ApproximateReceiveCount", "5")) | |
| if rec_count <= 5: | |
| batch_item_failures.append({"itemIdentifier": record['messageId']}) | |
| print(f'Failed to process record {record["messageId"]} and will retry: {e}') | |
| else: | |
| print(f'Failed to process record {record["messageId"]} and will not retry: {e}') | |
| _fail_out_record(record) | |
| print(traceback.format_exc()) | |
| # TODO implement | |
| return { | |
| 'statusCode': 200, | |
| 'batchItemFailures': batch_item_failures | |
| } | |
| if __name__ == '__main__': | |
| import argparse | |
| parser = argparse.ArgumentParser() | |
| parser.add_argument( | |
| 'event_file' | |
| ) | |
| args = parser.parse_args() | |
| with open(args.event_file) as f: | |
| event = json.load(f) | |
| ret = lambda_handler(event, {}) | |
| print(json.dumps(ret)) |