Skip to content

Commit b5374f9

Browse files
authored
Merge pull request #116 from NHSDigital/feature/terb-ELI-275-manual-uploads
Feature/terb eli 275 manual uploads
2 parents bec6439 + 7b6c972 commit b5374f9

10 files changed

Lines changed: 969 additions & 393 deletions

poetry.lock

Lines changed: 469 additions & 393 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ localstack = "^4.1.1"
5656
pytest-docker = "^3.2.0"
5757
stamina = "^25.1.0"
5858
pytest-freezer = "^0.4.9"
59+
moto = "^5.1.5"
5960

6061
[tool.poetry-plugin-lambda-build]
6162
docker-image = "public.ecr.aws/sam/build-python3.13:1.139-x86_64" # See https://gallery.ecr.aws/search?searchTerm=%22python%22&architecture=x86-64&popularRegistries=amazon&verified=verified&operatingSystems=Linux
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
#!/bin/bash
2+
3+
# === Config ===
4+
5+
BUCKET_NAME="eligibility-signposting-api-dev-eli-rules"
6+
S3_PREFIX="manual-uploads"
7+
8+
# === Usage Info ===
9+
# make it executable using - chmod +x manual_config_upload.sh
10+
11+
if [ "$#" -lt 1 ]; then
12+
echo "Usage: $0 <file.json> [bucket_name]"
13+
echo
14+
echo "Before running this script, you should either:"
15+
echo " • Run 'aws configure'"
16+
echo " • OR export these variables:"
17+
echo " export AWS_ACCESS_KEY_ID=..."
18+
echo " export AWS_SECRET_ACCESS_KEY=..."
19+
echo " export AWS_SESSION_TOKEN=... # if using temporary credentials"
20+
echo
21+
exit 1
22+
fi
23+
FILE="$1"
24+
BUCKET="${2:-$BUCKET_NAME}"
25+
26+
# === Check dependencies ===
27+
28+
if ! command -v jq >/dev/null 2>&1; then
29+
echo " 'jq' is not installed. Please install it (e.g., sudo apt install jq)"
30+
exit 1
31+
fi
32+
33+
# === Validate JSON ===
34+
35+
if ! jq empty "$FILE" >/dev/null 2>&1; then
36+
echo " Invalid JSON in file: $FILE"
37+
exit 1
38+
fi
39+
40+
# === Prompt for AWS credentials if not set ===
41+
42+
if [ -z "$AWS_ACCESS_KEY_ID" ] || [ -z "$AWS_SECRET_ACCESS_KEY" ]; then
43+
echo "AWS credentials not found in environment. Let's set them now:"
44+
read -p "Enter AWS_ACCESS_KEY_ID: " AWS_ACCESS_KEY_ID
45+
read -s -p "Enter AWS_SECRET_ACCESS_KEY: " AWS_SECRET_ACCESS_KEY
46+
echo
47+
read -s -p "Enter AWS_SESSION_TOKEN (leave blank if not needed): " AWS_SESSION_TOKEN
48+
echo export AWS_ACCESS_KEY_ID
49+
export AWS_SECRET_ACCESS_KEY
50+
export AWS_SESSION_TOKEN
51+
fi
52+
53+
# === Confirm credentials work ===
54+
55+
aws sts get-caller-identity >/dev/null 2>&1
56+
if [ $? -ne 0 ]; then
57+
echo "Failed to authenticate with AWS. Please check your credentials."
58+
exit 1
59+
60+
fi
61+
62+
# === Create unique key ===
63+
64+
BASENAME=$(basename "$FILE" .json)
65+
S3_KEY="${S3_PREFIX}/${BASENAME}.json"
66+
67+
# === Upload ===
68+
69+
echo " JSON is valid. Uploading to s3://$BUCKET/$S3_KEY ..."
70+
aws s3 cp "$FILE" "s3://$BUCKET/$S3_KEY" --content-type "application/json"
71+
72+
if [ $? -eq 0 ]; then
73+
echo " Upload complete."
74+
else
75+
echo "Upload failed."
76+
fi
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
#!/bin/bash
2+
set -e
3+
4+
# Start LocalStack (assumes localstack is installed and in path)
5+
echo "Starting LocalStack for S3..."
6+
localstack start -d
7+
sleep 5
8+
9+
export AWS_ACCESS_KEY_ID=test
10+
export AWS_SECRET_ACCESS_KEY=test
11+
export AWS_DEFAULT_REGION=us-east-1
12+
export AWS_ENDPOINT_URL=http://localhost:4566
13+
14+
# Create mock S3 bucket
15+
aws --endpoint-url=$AWS_ENDPOINT_URL s3 mb s3://test-bucket
16+
17+
# Create test file
18+
echo '{ "test": "value" }' > testfile.json
19+
20+
# Run the script
21+
echo "Testing manual_config_upload.sh..."
22+
./manual_config_upload.sh testfile.json test-bucket
23+
24+
# List contents
25+
echo "Contents of bucket:"
26+
aws --endpoint-url=$AWS_ENDPOINT_URL s3 ls s3://test-bucket/manual-uploads/
27+
28+
# Cleanup
29+
rm testfile.json
30+
localstack stop
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
#!/bin/bash
2+
3+
# === Config ===
4+
DEFAULT_TABLE="eligibility_data_store"
5+
REGION="eu-west-2"
6+
AWS_ENDPOINT_URL="${AWS_ENDPOINT_URL:-https://dynamodb.${REGION}.amazonaws.com}"
7+
8+
# === Usage Check ===
9+
10+
if [ "$#" -lt 1 ]; then
11+
echo "Usage: $0 <item.json> [table_name]"
12+
echo
13+
echo "Before running this, make sure AWS credentials are set either via:"
14+
echo " • 'aws configure'"
15+
echo " • OR by exporting:"
16+
echo " export AWS_ACCESS_KEY_ID=..."
17+
echo " export AWS_SECRET_ACCESS_KEY=..."
18+
echo " export AWS_SESSION_TOKEN=..."
19+
exit 1
20+
fi
21+
22+
FILE="$1"
23+
TABLE="${2:-$DEFAULT_TABLE}"
24+
25+
# === Validate JSON ===
26+
27+
if ! jq empty "$FILE" >/dev/null 2>&1; then
28+
echo "Invalid JSON in file: $FILE"
29+
exit 1
30+
fi
31+
32+
# === Prompt for AWS credentials if not set ===
33+
34+
if [ -z "$AWS_ACCESS_KEY_ID" ] || [ -z "$AWS_SECRET_ACCESS_KEY" ]; then
35+
echo "AWS credentials not found. Let's set them now:"
36+
read -p "Enter AWS_ACCESS_KEY_ID: " AWS_ACCESS_KEY_ID
37+
read -s -p "Enter AWS_SECRET_ACCESS_KEY: " AWS_SECRET_ACCESS_KEY
38+
echo
39+
read -s -p "Enter AWS_SESSION_TOKEN (leave blank if not needed): " AWS_SESSION_TOKEN
40+
echo
41+
42+
export AWS_ACCESS_KEY_ID
43+
export AWS_SECRET_ACCESS_KEY
44+
export AWS_SESSION_TOKEN
45+
fi
46+
47+
# === Check AWS auth ===
48+
49+
aws sts get-caller-identity >/dev/null 2>&1
50+
if [ $? -ne 0 ]; then
51+
echo "Failed to authenticate with AWS. Check credentials."
52+
exit 1
53+
fi
54+
55+
# === Upload to DynamoDB ===
56+
57+
echo "Uploading item from $FILE to table $TABLE ..."
58+
aws dynamodb put-item \
59+
--table-name "$TABLE" \
60+
--item "file://$FILE" \
61+
--region "$REGION" \
62+
--endpoint-url "$AWS_ENDPOINT_URL"
63+
64+
if [ $? -eq 0 ]; then
65+
echo "Upload complete."
66+
else
67+
echo "Upload failed."
68+
fi
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
#!/bin/bash
2+
set -e
3+
4+
# Start LocalStack (assumes localstack is installed and in path)
5+
echo "Starting LocalStack for DynamoDB..."
6+
localstack start -d
7+
sleep 5
8+
9+
export AWS_ACCESS_KEY_ID=test
10+
export AWS_SECRET_ACCESS_KEY=test
11+
export AWS_DEFAULT_REGION=eu-west-2
12+
export AWS_ENDPOINT_URL=http://localhost:4566
13+
14+
# Create DynamoDB table
15+
aws --endpoint-url=$AWS_ENDPOINT_URL dynamodb create-table \
16+
--table-name test-table \
17+
--attribute-definitions AttributeName=UserId,AttributeType=S \
18+
--key-schema AttributeName=UserId,KeyType=HASH \
19+
--billing-mode PAY_PER_REQUEST
20+
21+
# Create test item in DynamoDB JSON format
22+
cat <<EOF > testitem.json
23+
{
24+
"UserId": { "S": "123" },
25+
"Email": { "S": "localstack@test.com" },
26+
"Score": { "N": "100" }
27+
}
28+
EOF
29+
30+
# Run the script
31+
echo "Testing manual_dynamo_upload.sh..."
32+
./manual_dynamo_upload.sh testitem.json test-table
33+
34+
# Query item
35+
echo "Querying DynamoDB table:"
36+
aws --endpoint-url=$AWS_ENDPOINT_URL dynamodb get-item \
37+
--table-name test-table \
38+
--key '{ "UserId": { "S": "123" } }'
39+
40+
# Cleanup
41+
rm testitem.json
42+
localstack stop
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import boto3
2+
import json
3+
import os
4+
import argparse
5+
import logging
6+
from pathlib import Path
7+
from typing import Any, Dict, List, Optional, Union, Generator
8+
from decimal import Decimal
9+
10+
11+
def map_dynamo_type(value: Any) -> Dict[str, Any]:
12+
if isinstance(value, str):
13+
return {"S": value}
14+
elif isinstance(value, bool):
15+
return {"BOOL": value}
16+
elif isinstance(value, (int, float, Decimal)):
17+
return {"N": str(value)}
18+
elif value is None:
19+
return {"NULL": True}
20+
elif isinstance(value, list):
21+
return {"L": [map_dynamo_type(item) for item in value]}
22+
elif isinstance(value, dict):
23+
return {"M": {k: map_dynamo_type(v) for k, v in value.items()}}
24+
else:
25+
logging.warning(f"Unsupported value type: {type(value)}", "Converting it to string")
26+
return {"S": str(value)}
27+
28+
29+
def load_json_lines(filepath: Union[str, Path]) -> Generator[Dict[str, Any], None, None]:
30+
with Path.open(filepath) as f:
31+
for line in f:
32+
if line.strip():
33+
yield json.loads(line)
34+
35+
36+
def upload_to_s3(
37+
s3_client: Any,
38+
bucket: str,
39+
filepath: Union[str, Path],
40+
dry_run: bool = False
41+
) -> None:
42+
43+
filename = os.path.basename(filepath)
44+
s3_key = f"manual-uploads/{filename}"
45+
46+
if dry_run:
47+
print(f"[DRY RUN] Would upload {filepath} to s3://{bucket}/{s3_key}")
48+
return
49+
50+
try:
51+
s3_client.upload_file(filepath, bucket, s3_key)
52+
print(f"Uploaded {filepath} to s3://{bucket}/{s3_key}")
53+
except Exception as e:
54+
print(f"Failed to upload {filepath}: {e}")
55+
56+
57+
def upload_to_dynamo(
58+
dynamo_client: Any,
59+
table_name: str,
60+
filepath: Union[str, Path],
61+
) -> None:
62+
uploaded_items = 0
63+
for item in load_json_lines(filepath):
64+
try:
65+
dynamo_client.put_item(
66+
TableName=table_name, Item={key: map_dynamo_type(value) for key, value in item.items()}
67+
)
68+
uploaded_items += 1
69+
except Exception as e:
70+
partition_key = item.get("NHS_NUMBER", "Unknown")
71+
sort_key = item.get("ATTRIBUTE_TYPE", "Unknown")
72+
print(f"Failed to upload item (NHS_NUMBER: {partition_key}, ATTRIBUTE_TYPE: {sort_key}) from {filepath}: {e}")
73+
74+
if uploaded_items > 0:
75+
print(f"Uploaded {uploaded_items} items from {filepath} to DynamoDB table {table_name}")
76+
77+
def run_upload(args: Optional[List[str]] = None) -> None:
78+
parser = argparse.ArgumentParser()
79+
parser.add_argument("--env")
80+
parser.add_argument("--upload-s3", type=Path)
81+
parser.add_argument("--upload-dynamo", type=Path)
82+
parser.add_argument("--region", default="eu-west-2")
83+
parser.add_argument("--s3-bucket")
84+
parser.add_argument("--dynamo-table")
85+
parser.add_argument("--dry-run", action="store_true")
86+
87+
if args is None:
88+
parsed_args = parser.parse_args()
89+
else:
90+
parsed_args = parser.parse_args(args)
91+
92+
if not parsed_args.upload_s3 and not parsed_args.upload_dynamo:
93+
logging.warning("Neither '--upload-s3' nor '--upload-dynamo' flags specified. No upload actions will be performed.")
94+
if not parsed_args.s3_bucket:
95+
parsed_args.s3_bucket = f"eligibility-signposting-api-{parsed_args.env}-eli-rules"
96+
if not parsed_args.dynamo_table:
97+
parsed_args.dynamo_table = f"eligibility-signposting-api-{parsed_args.env}-eligibility_datastore"
98+
99+
session = boto3.Session()
100+
s3 = session.client("s3", region_name=parsed_args.region)
101+
dynamo = session.client("dynamodb", region_name=parsed_args.region)
102+
103+
if parsed_args.upload_s3:
104+
if parsed_args.upload_s3.is_dir():
105+
files = parsed_args.upload_s3.glob("*.json")
106+
else:
107+
files = [parsed_args.upload_s3]
108+
109+
for filepath in files:
110+
print(f"Uploading to S3 from {filepath}")
111+
upload_to_s3(s3, parsed_args.s3_bucket, str(filepath), parsed_args.dry_run)
112+
113+
if parsed_args.upload_dynamo:
114+
if parsed_args.upload_dynamo.is_dir():
115+
paths = parsed_args.upload_dynamo.glob("*.json")
116+
else:
117+
paths = [parsed_args.upload_dynamo]
118+
119+
for filepath in paths:
120+
print(f"Uploading to DynamoDB from {filepath}")
121+
upload_to_dynamo(dynamo, parsed_args.dynamo_table, str(filepath))
122+
123+
124+
if __name__ == "__main__":
125+
run_upload()

0 commit comments

Comments
 (0)