blob: fd8e3a1f837e46d5366bd7a523baea9c6cb59eaa [file]
# 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))