Skip to content

Commit df3adc1

Browse files
authored
KMS-660: Publish keyword events to SNS after a successful publish (#99)
1 parent 3ef55a2 commit df3adc1

33 files changed

Lines changed: 2264 additions & 849 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,4 @@ loadtest/locust/**/summary.csv~
4444
loadtest/locust/
4545
scripts/load/hammer_endpoints_sequential.js
4646
data/scheme-size*
47+
notes/

README.md

Lines changed: 12 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -27,17 +27,22 @@ Prerequisites:
2727
- Docker
2828
- aws-sam-cli (`brew install aws-sam-cli`)
2929

30-
To start local server (including rdf4j database server, cdk synth and sam)
31-
First, make sure to start LocalStack:
30+
To start local server, first make sure to start LocalStack:
3231
```
3332
npm run localstack:start
3433
```
3534

36-
Then, you can start the local server:
37-
```
35+
By default, `start-local` enables Redis with the local container settings from `bin/env/local_env.sh`, so the normal local startup path is:
36+
```bash
37+
npm run redis:start
3838
npm run start-local
3939
```
4040

41+
If you do not need Redis for your local test, start local with Redis disabled:
42+
```bash
43+
REDIS_ENABLED=false npm run start-local
44+
```
45+
4146
To run local server with SAM watch mode enabled
4247
```
4348
npm run start-local:watch
@@ -51,35 +56,11 @@ Local development intentionally splits responsibilities between SAM and LocalSta
5156
- LocalStack emulates AWS-managed services that SAM does not model end-to-end for this repo, especially SNS and SQS.
5257
- RDF4J and Redis remain separate local services because they are not AWS services.
5358

54-
We do not run the entire application stack inside LocalStack because the existing SAM flow is simpler for day-to-day Lambda/API development, while LocalStack is most useful here for the managed messaging pieces. For keyword event processing, `npm run start-local` also starts [`scripts/local/run_localstack_cmr_keyword_events_bridge.js`], which polls the LocalStack SQS queue and forwards messages into the local CMR consumer handler. This bridge exists because `sam local start-api` does not emulate SQS event source mappings the way AWS does in deployed environments.
59+
We do not run the entire application stack inside LocalStack because the existing SAM flow is simpler for day-to-day Lambda/API development, while LocalStack is most useful here for the managed messaging pieces. For keyword event processing, `npm run start-local` also starts `scripts/localstack/run_bridge.sh`, which runs `scripts/localstack/bridge.js`.
5560

56-
### Testing keyword event publishing in SIT
61+
This bridge exists because `sam local start-api` does not emulate EventBridge targets or SQS event source mappings the way AWS does in deployed environments.
5762

58-
After deploying to SIT, you can exercise the keyword event publisher with:
59-
60-
```bash
61-
curl -H "Authorization: Bearer $TOKEN" -X POST https://cmr.sit.earthdata.nasa.gov/kms/keyword-events/test \
62-
-H 'Content-Type: application/json' \
63-
-d '{
64-
"EventType": "UPDATED",
65-
"Scheme": "sciencekeywords",
66-
"UUID": "4f81c61c-f100-4bc4-9664-d9b70d2f162f",
67-
"OldKeywordPath": "Instruments > Solar/Space Observing Instruments > Passive Remote Sensing",
68-
"NewKeywordPath": "Instruments > Earth Remote Sensing Instruments > Passive Remote Sensing",
69-
"Timestamp": "2026-03-19T10:41:57.720Z",
70-
"MetadataSpecification": {
71-
"Name": "Kms-Keyword-Event",
72-
"URL": "https://cdn.earthdata.nasa.gov/kms-keyword-event/v1.0",
73-
"Version": "1.0"
74-
}
75-
}'
76-
```
77-
78-
Expected result:
79-
- the API returns `200`
80-
- the response includes the SNS topic ARN and message id
81-
- the CMR event processor is invoked from the subscribed queue in AWS
82-
- `EventType` must be one of `INSERTED`, `UPDATED`, or `DELETED`
63+
For bridge implementation details and extension guidance, see `scripts/localstack/README.md`
8364

8465
### Optional: Enable Redis cache in local SAM/LocalStack
8566

bin/deploy-bamboo.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ dockerRun() {
6363
--env "RDF4J_PASSWORD=$bamboo_RDF4J_PASSWORD" \
6464
--env "EDL_PASSWORD=$bamboo_EDL_PASSWORD" \
6565
--env "CMR_BASE_URL=$bamboo_CMR_BASE_URL" \
66+
--env "BLOCK_PUBLISH_ON_KEYWORD_DIFF_FAILURE=${bamboo_BLOCK_PUBLISH_ON_KEYWORD_DIFF_FAILURE:-false}" \
6667
--env "CORS_ORIGIN=$bamboo_CORS_ORIGIN" \
6768
--env "RDF4J_INSTANCE_TYPE=$bamboo_RDF4J_INSTANCE_TYPE" \
6869
--env "RDF4J_CONTAINER_MEMORY_LIMIT=$bamboo_RDF4J_CONTAINER_MEMORY_LIMIT" \

bin/env/local_env.sh

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,16 @@
22

33
# Shared defaults for local SAM/CDK workflows.
44
export RDF4J_SERVICE_URL="${RDF4J_SERVICE_URL:-http://rdf4j-server:8080}"
5+
export RDF4J_HOST_SERVICE_URL="${RDF4J_HOST_SERVICE_URL:-http://localhost:8081}"
6+
export RDF4J_USER_NAME="${RDF4J_USER_NAME:-rdf4j}"
7+
export RDF4J_PASSWORD="${RDF4J_PASSWORD:-rdf4j}"
8+
export RDF4J_CONTAINER_MEMORY_LIMIT="${RDF4J_CONTAINER_MEMORY_LIMIT:-2048}"
59
export REDIS_ENABLED="${REDIS_ENABLED:-true}"
610
export REDIS_HOST="${REDIS_HOST:-kms-redis-local}"
711
export REDIS_PORT="${REDIS_PORT:-6379}"
12+
export REDIS_FAIL_FAST="${REDIS_FAIL_FAST:-true}"
13+
export REDIS_HOST_SERVICE_HOST="${REDIS_HOST_SERVICE_HOST:-localhost}"
14+
export REDIS_HOST_PORT="${REDIS_HOST_PORT:-6380}"
815
export KMS_DOCKER_NETWORK="${KMS_DOCKER_NETWORK:-kms-network}"
916
export LOCALSTACK_CONTAINER_NAME="${LOCALSTACK_CONTAINER_NAME:-kms-localstack}"
1017
export LOCALSTACK_IMAGE="${LOCALSTACK_IMAGE:-localstack/localstack:3.8.1}"

bin/localstack/start.sh

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,31 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
66
# shellcheck source=bin/env/local_env.sh
77
source "${SCRIPT_DIR}/../env/local_env.sh"
88

9+
REQUIRED_SERVICES="sns,sqs,events"
10+
911
if ! docker network inspect "${KMS_DOCKER_NETWORK}" >/dev/null 2>&1; then
1012
docker network create "${KMS_DOCKER_NETWORK}" >/dev/null
1113
echo "Created docker network '${KMS_DOCKER_NETWORK}'"
1214
fi
1315

1416
existing_id="$(docker ps -aq --filter "name=^${LOCALSTACK_CONTAINER_NAME}$")"
17+
if [[ -n "${existing_id}" ]]; then
18+
configured_services="$(
19+
docker inspect \
20+
--format '{{range .Config.Env}}{{println .}}{{end}}' \
21+
"${LOCALSTACK_CONTAINER_NAME}" \
22+
| grep '^SERVICES=' \
23+
| cut -d= -f2- \
24+
|| true
25+
)"
26+
27+
if [[ ",${configured_services}," != *",events,"* ]]; then
28+
docker rm -f "${LOCALSTACK_CONTAINER_NAME}" >/dev/null
29+
echo "Recreating LocalStack container '${LOCALSTACK_CONTAINER_NAME}' to enable services: ${REQUIRED_SERVICES}"
30+
existing_id=""
31+
fi
32+
fi
33+
1534
if [[ -n "${existing_id}" ]]; then
1635
running_id="$(docker ps -q --filter "name=^${LOCALSTACK_CONTAINER_NAME}$")"
1736
if [[ -n "${running_id}" ]]; then
@@ -29,11 +48,11 @@ docker run -d \
2948
--network "${KMS_DOCKER_NETWORK}" \
3049
--network-alias "localstack" \
3150
-p "${LOCALSTACK_PORT}:4566" \
32-
-e SERVICES="sns,sqs" \
51+
-e SERVICES="${REQUIRED_SERVICES}" \
3352
-e AWS_DEFAULT_REGION="us-east-1" \
3453
-e EDGE_PORT="4566" \
3554
"${LOCALSTACK_IMAGE}" >/dev/null
3655

3756
echo "Started LocalStack container '${LOCALSTACK_CONTAINER_NAME}' on ${LOCALSTACK_PORT}->4566"
38-
echo "SNS/SQS endpoint for SAM/Lambda containers: ${AWS_ENDPOINT_URL}"
39-
echo "SNS/SQS endpoint from host: http://localhost:${LOCALSTACK_PORT}"
57+
echo "SNS/SQS/EventBridge endpoint for SAM/Lambda containers: ${AWS_ENDPOINT_URL}"
58+
echo "SNS/SQS/EventBridge endpoint from host: http://localhost:${LOCALSTACK_PORT}"

bin/start-local.sh

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,28 @@ PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
55
# shellcheck source=bin/env/local_env.sh
66
source "${SCRIPT_DIR}/env/local_env.sh"
77

8-
LOCAL_CONSUMER_PID=""
8+
LOCAL_BRIDGE_PID=""
99

1010
# Function to clean up processes and containers
1111
cleanup() {
1212
echo "Cleaning up..."
1313

14-
if [ -n "${LOCAL_CONSUMER_PID}" ] && kill -0 "${LOCAL_CONSUMER_PID}" >/dev/null 2>&1; then
15-
kill "${LOCAL_CONSUMER_PID}" >/dev/null 2>&1 || true
16-
wait "${LOCAL_CONSUMER_PID}" 2>/dev/null || true
14+
if [ -n "${LOCAL_BRIDGE_PID}" ] && kill -0 "${LOCAL_BRIDGE_PID}" >/dev/null 2>&1; then
15+
kill "${LOCAL_BRIDGE_PID}" >/dev/null 2>&1 || true
16+
wait "${LOCAL_BRIDGE_PID}" 2>/dev/null || true
1717
fi
1818

1919
exit 0
2020
}
2121

22+
clearStaleSAMContainers() {
23+
echo "Clearing stale SAM containers..."
24+
25+
docker ps --format '{{.ID}} {{.Image}}' \
26+
| awk '$2 ~ /public\.ecr\.aws\/lambda\/nodejs:22-rapid-/ { print $1 }' \
27+
| xargs -r docker rm -f >/dev/null 2>&1 || true
28+
}
29+
2230
# Set up trap to call cleanup function on Ctrl+C
2331
trap cleanup SIGINT
2432

@@ -27,8 +35,10 @@ if [ "$SAM_LOCAL_WATCH" = "true" ]; then
2735
SAM_WATCH_ARGS+=(--beta-features --watch)
2836
fi
2937

30-
vite-node --config "${PROJECT_ROOT}/vite.config.js" "${PROJECT_ROOT}/scripts/local/run_localstack_cmr_keyword_events_bridge.js" &
31-
LOCAL_CONSUMER_PID=$!
38+
clearStaleSAMContainers
39+
40+
"${PROJECT_ROOT}/scripts/localstack/run_bridge.sh" &
41+
LOCAL_BRIDGE_PID=$!
3242

3343
# Synthesize the CDK stack
3444
cd cdk

cdk/app/lib/KmsStack.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export interface KmsStackProps extends cdk.StackProps {
2424
environment: {
2525
CMR_BASE_URL: string
2626
EDL_PASSWORD: string
27+
BLOCK_PUBLISH_ON_KEYWORD_DIFF_FAILURE?: string
2728
LOG_LEVEL: string
2829
REDIS_ENABLED?: string
2930
REDIS_HOST?: string

cdk/app/lib/helper/KmsLambdaFunctions.ts

Lines changed: 1 addition & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ interface LambdaFunctionsProps {
2727
environment: {
2828
CMR_BASE_URL: string;
2929
EDL_PASSWORD: string;
30+
BLOCK_PUBLISH_ON_KEYWORD_DIFF_FAILURE?: string;
3031
REDIS_ENABLED?: string;
3132
REDIS_HOST?: string;
3233
REDIS_PORT?: string;
@@ -133,7 +134,6 @@ export class LambdaFunctions {
133134
this.createTreeOperationApiLambdas(scope)
134135
this.createNightlyCachePrimeCron(scope)
135136
this.createCrudOperationApiLambdas(scope)
136-
this.createKeywordEventTestPublishApiLambda(scope)
137137
this.createPublishEventBridgeWiring(scope)
138138
this.createExportRdfCrons(scope)
139139
}
@@ -400,24 +400,6 @@ export class LambdaFunctions {
400400
)
401401
}
402402

403-
/**
404-
* Creates the keyword events test publish endpoint.
405-
* Queue-based event consumption lives in CmrEventProcessingStack.
406-
* @param {Construct} scope - The scope in which to define these constructs
407-
* @private
408-
*/
409-
private createKeywordEventTestPublishApiLambda(scope: Construct) {
410-
this.createApiLambda(
411-
scope,
412-
'keywordEventsTestPublish/handler.js',
413-
'keyword-events-test-publish',
414-
'keywordEventsTestPublish',
415-
'/keyword-events/test',
416-
'POST',
417-
true
418-
)
419-
}
420-
421403
/**
422404
* Creates EventBridge wiring for publish events and cache-prime target execution.
423405
*

cdk/bin/main.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ async function main() {
160160
RDF_BUCKET_NAME: process.env.RDF_BUCKET_NAME || 'kms-rdf-backup',
161161
CMR_BASE_URL: process.env.CMR_BASE_URL || 'https://cmr.earthdata.nasa.gov',
162162
EDL_PASSWORD: process.env.EDL_PASSWORD || '',
163+
BLOCK_PUBLISH_ON_KEYWORD_DIFF_FAILURE: process.env.BLOCK_PUBLISH_ON_KEYWORD_DIFF_FAILURE,
163164
LOG_LEVEL: process.env.LOG_LEVEL || 'INFO',
164165
REDIS_ENABLED: redisEnabledValue,
165166
REDIS_HOST: useLocalstack ? localRedisHost : redisStack?.endpointAddress,
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
#!/usr/bin/env bash
2+
3+
set -euo pipefail
4+
5+
API_BASE_URL="${API_BASE_URL:-http://127.0.0.1:3013}"
6+
VERSION="${VERSION:-draft}"
7+
SCHEME_ID="${SCHEME_ID:-sciencekeywords}"
8+
BROADER_ID="${BROADER_ID:-1eb0ea0a-312c-4d74-8d42-6f1ad758f999}"
9+
LABEL_PREFIX="${LABEL_PREFIX:-LOCAL TEST KEYWORD}"
10+
TIMESTAMP_SUFFIX="$(date +%s)"
11+
CONCEPT_ID="$(uuidgen | tr '[:upper:]' '[:lower:]')"
12+
PREF_LABEL="${LABEL_PREFIX} ${TIMESTAMP_SUFFIX}"
13+
REFERENCE_URL="${REFERENCE_URL:-https://example.com/local-test-keyword}"
14+
DEFINITION_TEXT="${DEFINITION_TEXT:-Local test keyword created for publish verification ${TIMESTAMP_SUFFIX}}"
15+
16+
read -r -d '' RDF_XML <<EOF || true
17+
<rdf:RDF
18+
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
19+
xmlns:skos="http://www.w3.org/2004/02/skos/core#"
20+
xmlns:gcmd="https://gcmd.earthdata.nasa.gov/kms#"
21+
xmlns:dcterms="http://purl.org/dc/terms/"
22+
xml:base="https://gcmd.earthdata.nasa.gov/kms/concept/">
23+
<skos:Concept rdf:about="${CONCEPT_ID}">
24+
<skos:prefLabel xml:lang="en">${PREF_LABEL}</skos:prefLabel>
25+
<skos:definition xml:lang="en">${DEFINITION_TEXT}</skos:definition>
26+
<gcmd:reference gcmd:text="${REFERENCE_URL}" xml:lang="en"/>
27+
<skos:inScheme rdf:resource="https://gcmd.earthdata.nasa.gov/kms/concepts/concept_scheme/${SCHEME_ID}"/>
28+
<skos:broader rdf:resource="${BROADER_ID}"/>
29+
</skos:Concept>
30+
</rdf:RDF>
31+
EOF
32+
33+
echo "Creating keyword:"
34+
echo " conceptId: ${CONCEPT_ID}"
35+
echo " prefLabel: ${PREF_LABEL}"
36+
echo " scheme: ${SCHEME_ID}"
37+
echo " broader: ${BROADER_ID}"
38+
39+
curl \
40+
--fail \
41+
--silent \
42+
--show-error \
43+
-X POST "${API_BASE_URL}/concept?version=${VERSION}" \
44+
-H 'Content-Type: application/rdf+xml' \
45+
--data-binary "${RDF_XML}"
46+
47+
echo

0 commit comments

Comments
 (0)