Compare commits

...

92 Commits

Author SHA1 Message Date
Beverly Nguyen
9b14a63f8b Merge pull request #1361 from GSA/finalize-cg-tools-update
Finalize updates for cg-cli-tools
2024-10-11 20:21:44 -07:00
Carlo Costino
3f6c362f15 Finalize updates for cg-cli-tools
This changeset finalizes our updates for the cg-cli-tools across all environments and restores the check for updates to the egress proxy before deploying.

Signed-off-by: Carlo Costino <carlo.costino@gsa.gov>
2024-10-10 18:34:52 -04:00
Carlo Costino
70022c0596 Merge pull request #1358 from GSA/update-egress-proxy
Update egress proxy deployment steps
2024-10-10 16:07:21 -04:00
Carlo Costino
f644f5250c Fix environment name references for CF
Signed-off-by: Carlo Costino <carlo.costino@gsa.gov>
2024-10-10 13:10:01 -04:00
Carlo Costino
22bb1d0b8c Swap config vars for env vars
Signed-off-by: Carlo Costino <carlo.costino@gsa.gov>
2024-10-09 14:23:25 -06:00
Carlo Costino
a1b6047292 Merge branch 'main' into update-egress-proxy 2024-10-09 13:52:30 -06:00
Carlo Costino
c2f2e36262 Added missing egress proxy deploy action inputs
Signed-off-by: Carlo Costino <carlo.costino@gsa.gov>
2024-10-09 13:52:01 -06:00
Carlo Costino
b230d86b53 Merge pull request #1357 from GSA/fix-deploy-tool-name
Switch out deprecated deploy script tools in demo/prod yml files.
2024-10-09 13:24:08 -06:00
Carlo Costino
37dc59324d Merge pull request #1348 from GSA/rotate_secret_doc
add documentation for how to rotate DANGEROUS_SALT
2024-10-05 15:20:01 -06:00
Carlo Costino
f81785c9b4 Update egress proxy deployment steps
This changeset updates the egress proxy deployment steps to match the admin repo, based on lessons learned there.

Signed-off-by: Carlo Costino <carlo.costino@gsa.gov>
2024-10-04 22:53:23 -04:00
Andrew Shumway
497f91af4d Fix deploy config references 2024-10-04 11:20:57 -06:00
Andrew Shumway
584f24390c Switch back repo reference and update deploy tool for demo/prod scripts 2024-10-04 11:17:34 -06:00
Andrew Shumway
0c100dd7cc Change references name in deploy tool 2024-10-04 10:26:23 -06:00
Carlo Costino
e6cf8a976e Merge pull request #1354 from GSA/notify-admin-1976
Bug: Invites expiring immediately
2024-10-03 14:59:44 -04:00
Carlo Costino
f87e71060b Merge pull request #1356 from GSA/debug_s3_partitioning
debug s3 partitioning
2024-10-03 11:06:11 -04:00
Kenneth Kehl
4abd54094d debug s3 partitioning 2024-10-03 07:00:07 -07:00
Kenneth Kehl
ae5c0d2d91 Merge pull request #1343 from GSA/notify-api-1299
optimize S3 partitioning
2024-10-02 09:55:03 -07:00
Kenneth Kehl
f720b48d0d Bug: Invites expiring immediately 2024-10-02 09:54:18 -07:00
Kenneth Kehl
c792a2492d cleanup 2024-10-02 07:58:24 -07:00
Kenneth Kehl
97ee4fe032 cleanup 2024-10-02 07:31:13 -07:00
Carlo Costino
aba9132f5f Merge pull request #1353 from GSA/fix-login-gov-url-var-name-with-quotes
Attempt quoting the Login.gov URL with var-name
2024-10-01 17:46:11 -04:00
Carlo Costino
182572a6b7 Forgot to add the push command
Signed-off-by: Carlo Costino <carlo.costino@gsa.gov>
2024-10-01 17:39:20 -04:00
Kenneth Kehl
5edf0c7a68 merge from main 2024-10-01 14:38:23 -07:00
Carlo Costino
05838f964f Revert back to our original formatting, but include the --strategy rolling
Signed-off-by: Carlo Costino <carlo.costino@gsa.gov>
2024-10-01 17:37:03 -04:00
Kenneth Kehl
bda8b75812 Merge pull request #1349 from GSA/fix_personalization_bug
fix personalization_bug for one-offs
2024-10-01 14:36:35 -07:00
Carlo Costino
f8410a27fd Attempt quoting the Login.gov URL with var-name
This changeset tries to wrap the Login.gov registration URL with quotes to get it to be properly handled in the shell environment using the same pattern as the secret variables.

Signed-off-by: Carlo Costino <carlo.costino@gsa.gov>
2024-10-01 17:29:05 -04:00
Carlo Costino
04e98368f6 Merge pull request #1352 from GSA/fix-login-gov-url-with-quotes
Attempt quoting the Login.gov URL
2024-10-01 17:16:22 -04:00
Carlo Costino
b70d47fa38 Attempt quoting the Login.gov URL
This changeset tries to wrap the Login.gov registration URL with quotes to get it to be properly handled in the shell environment.

Signed-off-by: Carlo Costino <carlo.costino@gsa.gov>
2024-10-01 17:09:10 -04:00
Carlo Costino
dbfc0257c8 Merge pull request #1350 from GSA/login-gov-url-fix-again
One more attempt at fixing the Login.gov registration URL
2024-10-01 16:37:29 -04:00
Kenneth Kehl
37e5de331a don't run coverage on tests 2024-10-01 13:31:04 -07:00
Kenneth Kehl
ab7e57597a don't run coverage on tests 2024-10-01 13:21:05 -07:00
Carlo Costino
05bac16376 One more attempt at fixing the Login.gov registration URL
This changeset attempts another fix by referencing the environment variable in a different fashion, according to the GitHub docs:

https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/accessing-contextual-information-about-workflow-runs#example-usage-of-the-env-context
Signed-off-by: Carlo Costino <carlo.costino@gsa.gov>
2024-10-01 16:08:48 -04:00
Kenneth Kehl
a70b4506bb revert tests 2024-10-01 13:08:33 -07:00
Kenneth Kehl
641d168370 fix properly 2024-10-01 12:58:31 -07:00
Kenneth Kehl
9c43329d4d fix test 2024-10-01 12:08:57 -07:00
Kenneth Kehl
b7a6f4a3ba try fixing tests 2024-10-01 11:45:52 -07:00
Kenneth Kehl
f55c437c7d try fixing tests 2024-10-01 11:36:09 -07:00
Kenneth Kehl
c2ed8f1686 fix personalization_bug for one-offs 2024-10-01 10:47:47 -07:00
Kenneth Kehl
216528d589 Merge pull request #1339 from GSA/faster_s3_downloads
improve report performance
2024-10-01 08:59:37 -07:00
Carlo Costino
816c8e9e58 Merge pull request #1347 from GSA/login-gov-url-fix
Refer to GitHub Action container environment variable
2024-10-01 10:52:22 -04:00
Kenneth Kehl
76eb5281cf add documentation for how to rotate DANGEROUS_SALT 2024-10-01 07:31:51 -07:00
Carlo Costino
1f4905efa0 Refer to GitHub Action container environment variable
This changeset attempts to fix another issue with the deploy command by setting and referring to an environment variable directly.

Signed-off-by: Carlo Costino <carlo.costino@gsa.gov>
2024-10-01 09:50:14 -04:00
Andrew Shumway
92877f829c Merge pull request #1346 from GSA/one-more-staging-depoy-fix
Adjust LOGIN_DOT_GOV_REGISTRATION_URL env var in staging deploy
2024-09-30 15:35:04 -06:00
Carlo Costino
1c231b1bbe Adjust LOGIN_DOT_GOV_REGISTRATION_URL env var in staging deploy
This changeset will hopefully fix the reference to the LOGIN_DOT_GOV_REGISTRATION_URL env var in the new cf_command.

Signed-off-by: Carlo Costino <carlo.costino@gsa.gov>
2024-09-30 17:27:05 -04:00
Kenneth Kehl
93fe1370b2 Merge pull request #1345 from GSA/fix-cgcommand-brackets
Go back to brackets with secrets. in command
2024-09-30 14:13:05 -07:00
Andrew Shumway
70dc52be81 Go back to brackets with secrets. in command 2024-09-30 14:38:01 -06:00
Kenneth Kehl
c0388d248b Merge pull request #1344 from GSA/fix-cg-deploytool
Fix syntax on cf command
2024-09-30 12:51:07 -07:00
Andrew Shumway
f4a07621ef Fix syntax on cf command 2024-09-30 13:36:05 -06:00
Carlo Costino
8082b09d1f Merge pull request #1340 from GSA/fix-cfcommand-deploytool
Modify input to accepted argument
2024-09-30 15:20:58 -04:00
Kenneth Kehl
544e7e61e4 code review feedback 2024-09-30 09:08:18 -07:00
Kenneth Kehl
6b861f31d7 fix debug 2024-09-30 08:45:15 -07:00
Kenneth Kehl
f844da8c68 update comment 2024-09-30 08:38:49 -07:00
Andrew Shumway
db7cc32e12 Merge branch 'main' of https://github.com/GSA/notifications-api into fix-cfcommand-deploytool 2024-09-30 09:25:05 -06:00
Andrew Shumway
e0dab07300 Fix env var syntax 2024-09-30 09:24:50 -06:00
Kenneth Kehl
0d82f89bb5 reduce max connections to 10 2024-09-27 14:18:42 -07:00
Kenneth Kehl
e99f4bc6b5 suppress warnings 2024-09-27 12:38:27 -07:00
Kenneth Kehl
16b357faa8 add comment 2024-09-27 09:55:08 -07:00
Kenneth Kehl
580885ab0c merge from main 2024-09-27 09:43:10 -07:00
Kenneth Kehl
d3768e3152 Merge pull request #1341 from GSA/shared_memory
use shared memory instead of expiring dict for jobs cache
2024-09-27 09:24:31 -07:00
Kenneth Kehl
b2651864e1 Merge pull request #1336 from GSA/notify-admin-1956
fix phone number lookup
2024-09-27 09:23:59 -07:00
Kenneth Kehl
d6f4acaa56 fix format 2024-09-27 09:10:51 -07:00
Kenneth Kehl
8918dbcffd increase test coverage 2024-09-27 09:07:02 -07:00
Kenneth Kehl
ca89779a52 get the lines that are not covered for tests 2024-09-27 08:52:11 -07:00
Kenneth Kehl
26ffb931c9 optimize S3 partitioning 2024-09-27 08:38:11 -07:00
Kenneth Kehl
8e6c079c09 fix flake8 2024-09-26 15:22:13 -07:00
Kenneth Kehl
096e3a367d clean up 2024-09-26 15:17:57 -07:00
Kenneth Kehl
1569de2afd revert coverage changes 2024-09-26 14:59:18 -07:00
Kenneth Kehl
b4db1b0b2d revert coverage changes 2024-09-26 14:49:53 -07:00
Kenneth Kehl
67467bbedc dont do coverage of test directory 2024-09-26 14:39:19 -07:00
Kenneth Kehl
b656ad84b7 dont do coverage of test directory 2024-09-26 14:23:19 -07:00
Kenneth Kehl
3259717061 dont do coverage of test directory 2024-09-26 14:15:51 -07:00
Kenneth Kehl
fceeed2401 dont do coverage of test directory 2024-09-26 14:05:58 -07:00
Kenneth Kehl
7c1ca17721 dont do coverage of test directory 2024-09-26 13:56:45 -07:00
Kenneth Kehl
b6d37b7071 dont do coverage of test directory 2024-09-26 13:47:51 -07:00
Kenneth Kehl
24f2b9fc9f fix flake8 2024-09-26 13:32:07 -07:00
Kenneth Kehl
3cba7f157e fix tests 2024-09-26 13:29:22 -07:00
Kenneth Kehl
c2f2cbc3bb fix tests 2024-09-26 13:18:14 -07:00
Kenneth Kehl
561d813d4e remove redis_store mocking from s3 tests 2024-09-26 12:24:06 -07:00
Kenneth Kehl
8aae0d59b4 use shared memory instead of expiring dict for jobs cache 2024-09-26 11:56:39 -07:00
Andrew Shumway
ec4e522dc9 Modify input to accepted argument 2024-09-26 10:33:46 -06:00
Kenneth Kehl
d27d877f68 Merge pull request #1330 from GSA/fix_test_coverage
actually start deleting old s3 objects
2024-09-26 08:13:20 -07:00
Kenneth Kehl
e5ac50b694 add test 2024-09-26 07:17:12 -07:00
Carlo Costino
0736c982f0 Merge pull request #1334 from GSA/update-deploy-tool
Change 18f deprecated deploy tool to CG deploy tool
2024-09-26 09:36:18 -04:00
Kenneth Kehl
291890b154 revert change to task schedule 2024-09-25 12:32:30 -07:00
Kenneth Kehl
ec6bfd8225 improve report performance 2024-09-25 12:26:01 -07:00
Kenneth Kehl
9cd1b8dc4d Merge pull request #1338 from GSA/argh_load_test
fix load test
2024-09-25 11:02:40 -07:00
Kenneth Kehl
71417a19dc fix load test 2024-09-25 10:22:27 -07:00
Kenneth Kehl
881a251216 fix phone number lookup 2024-09-24 10:26:01 -07:00
Kenneth Kehl
a3c1663d94 fix phone number lookup 2024-09-24 10:17:02 -07:00
Andrew Shumway
ada2c3ec45 Change 18f deploy tool to CG deploy tool 2024-09-18 10:37:43 -06:00
Kenneth Kehl
564ae06383 fix schedule 2024-09-11 08:50:29 -07:00
Kenneth Kehl
2f9fea909e actually start deleting old s3 objects 2024-09-11 07:31:50 -07:00
20 changed files with 568 additions and 1091 deletions

View File

@@ -209,7 +209,7 @@
"filename": "tests/app/aws/test_s3.py",
"hashed_secret": "67a74306b06d0c01624fe0d0249a570f4d093747",
"is_verified": false,
"line_number": 27,
"line_number": 29,
"is_secret": false
}
],
@@ -384,5 +384,5 @@
}
]
},
"generated_at": "2024-09-10T18:12:39Z"
"generated_at": "2024-09-27T16:42:53Z"
}

View File

@@ -1,6 +1,9 @@
name: Deploy egress proxy
description: Set egress space security groups and deploy proxy
inputs:
cf_org:
description: The org the target app exists in.
required: true
cf_space:
description: The space the target app exists in.
required: true
@@ -16,6 +19,19 @@ inputs:
runs:
using: composite
steps:
- name: Install cf-cli
shell: bash
run: |
curl -A "cg-deploy-action" -v -L -o cf-cli_amd64.deb 'https://packages.cloudfoundry.org/stable?release=debian64&version=v8&source=github'
sudo dpkg -i cf-cli_amd64.deb
- name: Login to cf-cli
shell: bash
run: |
cf api api.fr.cloud.gov
cf auth
- name: Target org and space
shell: bash
run: cf target -o ${{ inputs.cf_org }} -s ${{ inputs.cf_space }}
- name: Set restricted space egress
shell: bash
run: ./terraform/set_space_egress.sh -t -s ${{ inputs.cf_space }}

View File

@@ -54,7 +54,7 @@ jobs:
- name: Check for dead code
run: make dead-code
- name: Run tests with coverage
run: poetry run coverage run --omit=*/notifications_utils/*,*/migrations/* -m pytest --maxfail=10
run: poetry run coverage run --omit=*/migrations/*,*/tests/* -m pytest --maxfail=10
env:
SQLALCHEMY_DATABASE_TEST_URI: postgresql://user:password@localhost:5432/test_notification_api
NOTIFY_E2E_TEST_EMAIL: ${{ secrets.NOTIFY_E2E_TEST_EMAIL }}
@@ -63,7 +63,7 @@ jobs:
NOTIFY_E2E_TEST_PASSWORD: ${{ secrets.NOTIFY_E2E_TEST_PASSWORD }}
- name: Check coverage threshold
# TODO get this back up to 95
run: poetry run coverage report --fail-under=95
run: poetry run coverage report -m --fail-under=91
validate-new-relic-config:
runs-on: ubuntu-latest

View File

@@ -49,7 +49,7 @@ jobs:
run: poetry export --without-hashes --format=requirements.txt > requirements.txt
- name: Deploy to cloud.gov
uses: 18f/cg-deploy-action@main
uses: cloud-gov/cg-cli-tools@main
env:
DANGEROUS_SALT: ${{ secrets.DANGEROUS_SALT }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
@@ -64,7 +64,8 @@ jobs:
cf_password: ${{ secrets.CLOUDGOV_PASSWORD }}
cf_org: gsa-tts-benefits-studio
cf_space: notify-demo
push_arguments: >-
cf_command: >-
push -f manifest.yml
--vars-file deploy-config/demo.yml
--var DANGEROUS_SALT="$DANGEROUS_SALT"
--var SECRET_KEY="$SECRET_KEY"
@@ -73,6 +74,7 @@ jobs:
--var NOTIFY_E2E_TEST_EMAIL="$NOTIFY_E2E_TEST_EMAIL"
--var NOTIFY_E2E_TEST_PASSWORD="$NOTIFY_E2E_TEST_PASSWORD"
--var LOGIN_DOT_GOV_REGISTRATION_URL="$LOGIN_DOT_GOV_REGISTRATION_URL"
--strategy rolling
- name: Check for changes to templates.json
id: changed-templates
@@ -95,6 +97,10 @@ jobs:
- name: Deploy egress proxy
if: steps.changed-egress-config.outputs.any_changed == 'true'
uses: ./.github/actions/deploy-proxy
env:
CF_USERNAME: ${{ secrets.CLOUDGOV_USERNAME }}
CF_PASSWORD: ${{ secrets.CLOUDGOV_PASSWORD }}
with:
cf_space: notify-demo
app: notify-api-demo
cf_org: gsa-tts-benefits-studio
cf_space: notify-staging
app: notify-api-staging

View File

@@ -53,7 +53,7 @@ jobs:
run: poetry export --without-hashes --format=requirements.txt > requirements.txt
- name: Deploy to cloud.gov
uses: 18f/cg-deploy-action@main
uses: cloud-gov/cg-cli-tools@main
env:
DANGEROUS_SALT: ${{ secrets.DANGEROUS_SALT }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
@@ -68,7 +68,8 @@ jobs:
cf_password: ${{ secrets.CLOUDGOV_PASSWORD }}
cf_org: gsa-tts-benefits-studio
cf_space: notify-production
push_arguments: >-
cf_command: >-
push -f manifest.yml
--vars-file deploy-config/production.yml
--var DANGEROUS_SALT="$DANGEROUS_SALT"
--var SECRET_KEY="$SECRET_KEY"
@@ -77,6 +78,7 @@ jobs:
--var NOTIFY_E2E_TEST_EMAIL="$NOTIFY_E2E_TEST_EMAIL"
--var NOTIFY_E2E_TEST_PASSWORD="$NOTIFY_E2E_TEST_PASSWORD"
--var LOGIN_DOT_GOV_REGISTRATION_URL="$LOGIN_DOT_GOV_REGISTRATION_URL"
--strategy rolling
- name: Check for changes to templates.json
id: changed-templates
@@ -99,6 +101,10 @@ jobs:
- name: Deploy egress proxy
if: steps.changed-egress-config.outputs.any_changed == 'true'
uses: ./.github/actions/deploy-proxy
env:
CF_USERNAME: ${{ secrets.CLOUDGOV_USERNAME }}
CF_PASSWORD: ${{ secrets.CLOUDGOV_PASSWORD }}
with:
cf_space: notify-production
app: notify-api-production
cf_org: gsa-tts-benefits-studio
cf_space: notify-staging
app: notify-api-staging

View File

@@ -55,7 +55,7 @@ jobs:
run: poetry export --without-hashes --format=requirements.txt > requirements.txt
- name: Deploy to cloud.gov
uses: 18f/cg-deploy-action@main
uses: cloud-gov/cg-cli-tools@main
env:
DANGEROUS_SALT: ${{ secrets.DANGEROUS_SALT }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
@@ -70,7 +70,8 @@ jobs:
cf_password: ${{ secrets.CLOUDGOV_PASSWORD }}
cf_org: gsa-tts-benefits-studio
cf_space: notify-staging
push_arguments: >-
cf_command: >-
push -f manifest.yml
--vars-file deploy-config/staging.yml
--var DANGEROUS_SALT="$DANGEROUS_SALT"
--var SECRET_KEY="$SECRET_KEY"
@@ -79,6 +80,7 @@ jobs:
--var NOTIFY_E2E_TEST_EMAIL="$NOTIFY_E2E_TEST_EMAIL"
--var NOTIFY_E2E_TEST_PASSWORD="$NOTIFY_E2E_TEST_PASSWORD"
--var LOGIN_DOT_GOV_REGISTRATION_URL="$LOGIN_DOT_GOV_REGISTRATION_URL"
--strategy rolling
- name: Check for changes to templates.json
id: changed-templates
@@ -101,7 +103,11 @@ jobs:
- name: Deploy egress proxy
if: steps.changed-egress-config.outputs.any_changed == 'true'
uses: ./.github/actions/deploy-proxy
env:
CF_USERNAME: ${{ secrets.CLOUDGOV_USERNAME }}
CF_PASSWORD: ${{ secrets.CLOUDGOV_PASSWORD }}
with:
cf_org: gsa-tts-benefits-studio
cf_space: notify-staging
app: notify-api-staging

View File

@@ -81,9 +81,10 @@ test: ## Run tests and create coverage report
poetry run black .
poetry run flake8 .
poetry run isort --check-only ./app ./tests
poetry run coverage run --omit=*/notifications_utils/*,*/migrations/* -m pytest --maxfail=10
poetry run coverage run --omit=*/migrations/*,*/tests/* -m pytest --maxfail=10
poetry run coverage report -m --fail-under=95
## TODO set this back to 95 asap
poetry run coverage report -m --fail-under=91
poetry run coverage html -d .coverage_cache
.PHONY: py-lock

View File

@@ -1,31 +1,45 @@
import datetime
import re
import time
from concurrent.futures import ThreadPoolExecutor
from multiprocessing import Manager
import botocore
from boto3 import Session
from expiringdict import ExpiringDict
from flask import current_app
from app import redis_store
from app.clients import AWS_CLIENT_CONFIG
from notifications_utils import aware_utcnow
FILE_LOCATION_STRUCTURE = "service-{}-notify/{}.csv"
NEW_FILE_LOCATION_STRUCTURE = "{}-service-notify/{}.csv"
# Temporarily extend cache to 7 days
ttl = 60 * 60 * 24 * 7
JOBS = ExpiringDict(max_len=20000, max_age_seconds=ttl)
manager = Manager()
job_cache = manager.dict()
JOBS_CACHE_HITS = "JOBS_CACHE_HITS"
JOBS_CACHE_MISSES = "JOBS_CACHE_MISSES"
# Global variable
s3_client = None
s3_resource = None
def set_job_cache(job_cache, key, value):
job_cache[key] = (value, time.time() + 8 * 24 * 60 * 60)
def clean_cache():
current_time = time.time()
keys_to_delete = []
for key, (_, expiry_time) in job_cache.items():
if expiry_time < current_time:
keys_to_delete.append(key)
for key in keys_to_delete:
del job_cache[key]
def get_s3_client():
global s3_client
if s3_client is None:
@@ -87,7 +101,6 @@ def get_bucket_name():
def cleanup_old_s3_objects():
bucket_name = get_bucket_name()
s3_client = get_s3_client()
@@ -99,9 +112,15 @@ def cleanup_old_s3_objects():
while True:
for obj in response.get("Contents", []):
if obj["LastModified"] <= time_limit:
current_app.logger.info(
f"#delete-old-s3-objects Wanting to delete: {obj['LastModified']} {obj['Key']}"
)
try:
remove_csv_object(obj["Key"])
current_app.logger.info(
f"#delete-old-s3-objects Deleted: {obj['LastModified']} {obj['Key']}"
)
except botocore.exceptions.ClientError:
current_app.logger.exception(f"Couldn't delete {obj['Key']}")
if "NextContinuationToken" in response:
response = s3_client.list_objects_v2(
Bucket=bucket_name,
@@ -115,36 +134,70 @@ def cleanup_old_s3_objects():
)
def get_s3_files():
def get_job_id_from_s3_object_key(key):
object_arr = key.split("/")
job_id = object_arr[1] # get the job_id
job_id = job_id.replace(".csv", "") # we just want the job_id
return job_id
def read_s3_file(bucket_name, object_key, s3res):
"""
This method runs during the 'regenerate job cache' task.
Note that in addition to retrieving the jobs and putting them
into the cache, this method also does some pre-processing by
putting a list of all phone numbers into the cache as well.
This means that when the report needs to be regenerated, it
can easily find the phone numbers in the cache through job_cache[<job_id>_phones]
and the personalization through job_cache[<job_id>_personalisation], which
in theory should make report generation a lot faster.
We are moving processing from the front end where the user can see it
in wait time, to this back end process.
"""
try:
job_id = get_job_id_from_s3_object_key(object_key)
if job_cache.get(job_id) is None:
object = (
s3res.Object(bucket_name, object_key)
.get()["Body"]
.read()
.decode("utf-8")
)
set_job_cache(job_cache, job_id, object)
set_job_cache(job_cache, f"{job_id}_phones", extract_phones(object))
set_job_cache(
job_cache,
f"{job_id}_personalisation",
extract_personalisation(object),
)
except LookupError:
# perhaps our key is not formatted as we expected. If so skip it.
current_app.logger.exception("LookupError #notify-admin-1200")
def get_s3_files():
"""
We're using the ThreadPoolExecutor here to speed up the retrieval of S3
csv files for scaling needs.
"""
bucket_name = current_app.config["CSV_UPLOAD_BUCKET"]["bucket"]
objects = list_s3_objects()
object_keys = list_s3_objects()
s3res = get_s3_resource()
current_app.logger.info(
f"JOBS cache length before regen: {len(JOBS)} #notify-admin-1200"
f"job_cache length before regen: {len(job_cache)} #notify-admin-1200"
)
for object in objects:
# We put our csv files in the format "service-{service_id}-notify/{job_id}"
try:
object_arr = object.split("/")
job_id = object_arr[1] # get the job_id
job_id = job_id.replace(".csv", "") # we just want the job_id
if JOBS.get(job_id) is None:
object = (
s3res.Object(bucket_name, object)
.get()["Body"]
.read()
.decode("utf-8")
)
if "phone number" in object.lower():
JOBS[job_id] = object
except LookupError:
# perhaps our key is not formatted as we expected. If so skip it.
current_app.logger.exception("LookupError #notify-admin-1200")
try:
with ThreadPoolExecutor() as executor:
executor.map(lambda key: read_s3_file(bucket_name, key, s3res), object_keys)
except Exception:
current_app.logger.exception("Connection pool issue")
current_app.logger.info(
f"JOBS cache length after regen: {len(JOBS)} #notify-admin-1200"
f"job_cache length after regen: {len(job_cache)} #notify-admin-1200"
)
@@ -211,6 +264,27 @@ def file_exists(file_location):
def get_job_location(service_id, job_id):
current_app.logger.info(
f"#s3-partitioning NEW JOB_LOCATION: {NEW_FILE_LOCATION_STRUCTURE.format(service_id, job_id)}"
)
return (
current_app.config["CSV_UPLOAD_BUCKET"]["bucket"],
NEW_FILE_LOCATION_STRUCTURE.format(service_id, job_id),
current_app.config["CSV_UPLOAD_BUCKET"]["access_key_id"],
current_app.config["CSV_UPLOAD_BUCKET"]["secret_access_key"],
current_app.config["CSV_UPLOAD_BUCKET"]["region"],
)
def get_old_job_location(service_id, job_id):
"""
This is deprecated. We are transitioning to NEW_FILE_LOCATION_STRUCTURE,
but it will take a few days where we have to support both formats.
Remove this when everything works with the NEW_FILE_LOCATION_STRUCTURE.
"""
current_app.logger.info(
f"#s3-partitioning OLD JOB LOCATION: {FILE_LOCATION_STRUCTURE.format(service_id, job_id)}"
)
return (
current_app.config["CSV_UPLOAD_BUCKET"]["bucket"],
FILE_LOCATION_STRUCTURE.format(service_id, job_id),
@@ -239,17 +313,28 @@ def get_job_from_s3(service_id, job_id):
max_retries = 4
backoff_factor = 0.2
if not file_exists(FILE_LOCATION_STRUCTURE.format(service_id, job_id)):
if not file_exists(
FILE_LOCATION_STRUCTURE.format(service_id, job_id)
) and not file_exists(NEW_FILE_LOCATION_STRUCTURE.format(service_id, job_id)):
current_app.logger.error(
f"This file does not exist {FILE_LOCATION_STRUCTURE.format(service_id, job_id)}"
f"This file with service_id {service_id} and job_id {job_id} does not exist"
)
return None
while retries < max_retries:
try:
obj = get_s3_object(*get_job_location(service_id, job_id))
return obj.get()["Body"].read().decode("utf-8")
# TODO
# for transition on optimizing the s3 partition, we have
# to check for the file location using the new way and the
# old way. After this has been on production for a few weeks
# we should remove the check for the old way.
try:
obj = get_s3_object(*get_job_location(service_id, job_id))
return obj.get()["Body"].read().decode("utf-8")
except botocore.exceptions.ClientError:
obj = get_s3_object(*get_old_job_location(service_id, job_id))
return obj.get()["Body"].read().decode("utf-8")
except botocore.exceptions.ClientError as e:
if e.response["Error"]["Code"] in [
"Throttling",
@@ -257,7 +342,7 @@ def get_job_from_s3(service_id, job_id):
"SlowDown",
]:
current_app.logger.exception(
f"Retrying job fetch {FILE_LOCATION_STRUCTURE.format(service_id, job_id)} retry_count={retries}",
f"Retrying job fetch service_id {service_id} job_id {job_id} retry_count={retries}",
)
retries += 1
sleep_time = backoff_factor * (2**retries) # Exponential backoff
@@ -266,36 +351,22 @@ def get_job_from_s3(service_id, job_id):
else:
# Typically this is "NoSuchKey"
current_app.logger.exception(
f"Failed to get job {FILE_LOCATION_STRUCTURE.format(service_id, job_id)}",
f"Failed to get job with service_id {service_id} job_id {job_id}",
)
return None
except Exception:
current_app.logger.exception(
f"Failed to get job {FILE_LOCATION_STRUCTURE.format(service_id, job_id)} retry_count={retries}",
f"Failed to get job with service_id {service_id} job_id {job_id}retry_count={retries}",
)
return None
current_app.logger.error(
f"Never retrieved job {FILE_LOCATION_STRUCTURE.format(service_id, job_id)}",
f"Never retrieved job with service_id {service_id} job_id {job_id}",
)
return None
def incr_jobs_cache_misses():
if not redis_store.get(JOBS_CACHE_MISSES):
redis_store.set(JOBS_CACHE_MISSES, 1)
else:
redis_store.incr(JOBS_CACHE_MISSES)
def incr_jobs_cache_hits():
if not redis_store.get(JOBS_CACHE_HITS):
redis_store.set(JOBS_CACHE_HITS, 1)
else:
redis_store.incr(JOBS_CACHE_HITS)
def extract_phones(job):
job = job.split("\r\n")
first_row = job[0]
@@ -304,7 +375,7 @@ def extract_phones(job):
phone_index = 0
for item in first_row:
# Note: may contain a BOM and look like \ufeffphone number
if "phone number" in item.lower():
if item.lower() in ["phone number", "\\ufeffphone number"]:
break
phone_index = phone_index + 1
@@ -328,6 +399,8 @@ def extract_phones(job):
def extract_personalisation(job):
if isinstance(job, dict):
job = job[0]
job = job.split("\r\n")
first_row = job[0]
job.pop(0)
@@ -343,43 +416,32 @@ def extract_personalisation(job):
def get_phone_number_from_s3(service_id, job_id, job_row_number):
# We don't want to constantly pull down a job from s3 every time we need a phone number.
# At the same time we don't want to store it in redis or the db
# So this is a little recycling mechanism to reduce the number of downloads.
job = JOBS.get(job_id)
job = job_cache.get(job_id)
if job is None:
current_app.logger.info(f"job {job_id} was not in the cache")
job = get_job_from_s3(service_id, job_id)
# Even if it is None, put it here to avoid KeyErrors
JOBS[job_id] = job
incr_jobs_cache_misses()
set_job_cache(job_cache, job_id, job)
else:
incr_jobs_cache_hits()
# skip expiration date from cache, we don't need it here
job = job[0]
if job is None:
current_app.logger.error(
f"Couldnt find phone for job {FILE_LOCATION_STRUCTURE.format(service_id, job_id)} because job is missing"
f"Couldnt find phone for job with service_id {service_id} job_id {job_id} because job is missing"
)
return "Unavailable"
# If we look in the JOBS cache for the quick lookup dictionary of phones for a given job
# and that dictionary is not there, create it
if JOBS.get(f"{job_id}_phones") is None:
JOBS[f"{job_id}_phones"] = extract_phones(job)
phones = extract_phones(job)
set_job_cache(job_cache, f"{job_id}_phones", phones)
# If we can find the quick dictionary, use it
if JOBS.get(f"{job_id}_phones") is not None:
phone_to_return = JOBS.get(f"{job_id}_phones").get(job_row_number)
if phone_to_return:
return phone_to_return
else:
current_app.logger.warning(
f"Was unable to retrieve phone number from lookup dictionary for job {job_id}"
)
return "Unavailable"
phone_to_return = phones[job_row_number]
if phone_to_return:
return phone_to_return
else:
current_app.logger.error(
f"Was unable to construct lookup dictionary for job {job_id}"
current_app.logger.warning(
f"Was unable to retrieve phone number from lookup dictionary for job {job_id}"
)
return "Unavailable"
@@ -388,14 +450,15 @@ def get_personalisation_from_s3(service_id, job_id, job_row_number):
# We don't want to constantly pull down a job from s3 every time we need the personalisation.
# At the same time we don't want to store it in redis or the db
# So this is a little recycling mechanism to reduce the number of downloads.
job = JOBS.get(job_id)
job = job_cache.get(job_id)
if job is None:
current_app.logger.info(f"job {job_id} was not in the cache")
job = get_job_from_s3(service_id, job_id)
JOBS[job_id] = job
incr_jobs_cache_misses()
# Even if it is None, put it here to avoid KeyErrors
set_job_cache(job_cache, job_id, job)
else:
incr_jobs_cache_hits()
# skip expiration date from cache, we don't need it here
job = job[0]
# If the job is None after our attempt to retrieve it from s3, it
# probably means the job is old and has been deleted from s3, in
# which case there is nothing we can do. It's unlikely to run into
@@ -407,14 +470,11 @@ def get_personalisation_from_s3(service_id, job_id, job_row_number):
)
return {}
# If we look in the JOBS cache for the quick lookup dictionary of personalisations for a given job
# and that dictionary is not there, create it
if JOBS.get(f"{job_id}_personalisation") is None:
JOBS[f"{job_id}_personalisation"] = extract_personalisation(job)
set_job_cache(job_cache, f"{job_id}_personalisation", extract_personalisation(job))
# If we can find the quick dictionary, use it
if JOBS.get(f"{job_id}_personalisation") is not None:
personalisation_to_return = JOBS.get(f"{job_id}_personalisation").get(
if job_cache.get(f"{job_id}_personalisation") is not None:
personalisation_to_return = job_cache.get(f"{job_id}_personalisation")[0].get(
job_row_number
)
if personalisation_to_return:
@@ -432,6 +492,9 @@ def get_personalisation_from_s3(service_id, job_id, job_row_number):
def get_job_metadata_from_s3(service_id, job_id):
current_app.logger.info(
f"#s3-partitioning CALLING GET_JOB_METADATA with {service_id}, {job_id}"
)
obj = get_s3_object(*get_job_location(service_id, job_id))
return obj.get()["Metadata"]

View File

@@ -446,6 +446,11 @@ def regenerate_job_cache():
s3.get_s3_files()
@notify_celery.task(name="clean-job-cache")
def clean_job_cache():
s3.clean_cache()
@notify_celery.task(name="delete-old-s3-objects")
def delete_old_s3_objects():
s3.cleanup_old_s3_objects()

View File

@@ -13,6 +13,10 @@ AWS_CLIENT_CONFIG = Config(
"addressing_style": "virtual",
},
use_fips_endpoint=True,
# This is the default but just for doc sake
# there may come a time when increasing this helps
# with job cache management.
max_pool_connections=10,
)

View File

@@ -931,7 +931,7 @@ where possible to enable better maintainability.
# generate n number of test orgs into the dev DB
@notify_command(name="add-test-organizations-to-db")
@click.option("-g", "--generate", required=True, prompt=True, default=1)
def add_test_organizations_to_db(generate):
def add_test_organizations_to_db(generate): # pragma: no cover
if getenv("NOTIFY_ENVIRONMENT", "") not in ["development", "test"]:
current_app.logger.error("Can only be run in development")
return
@@ -993,7 +993,7 @@ def add_test_organizations_to_db(generate):
# generate n number of test services into the dev DB
@notify_command(name="add-test-services-to-db")
@click.option("-g", "--generate", required=True, prompt=True, default=1)
def add_test_services_to_db(generate):
def add_test_services_to_db(generate): # pragma: no cover
if getenv("NOTIFY_ENVIRONMENT", "") not in ["development", "test"]:
current_app.logger.error("Can only be run in development")
return
@@ -1007,7 +1007,7 @@ def add_test_services_to_db(generate):
# generate n number of test jobs into the dev DB
@notify_command(name="add-test-jobs-to-db")
@click.option("-g", "--generate", required=True, prompt=True, default=1)
def add_test_jobs_to_db(generate):
def add_test_jobs_to_db(generate): # pragma: no cover
if getenv("NOTIFY_ENVIRONMENT", "") not in ["development", "test"]:
current_app.logger.error("Can only be run in development")
return
@@ -1022,7 +1022,7 @@ def add_test_jobs_to_db(generate):
# generate n number of notifications into the dev DB
@notify_command(name="add-test-notifications-to-db")
@click.option("-g", "--generate", required=True, prompt=True, default=1)
def add_test_notifications_to_db(generate):
def add_test_notifications_to_db(generate): # pragma: no cover
if getenv("NOTIFY_ENVIRONMENT", "") not in ["development", "test"]:
current_app.logger.error("Can only be run in development")
return
@@ -1043,7 +1043,7 @@ def add_test_notifications_to_db(generate):
@click.option("-g", "--generate", required=True, prompt=True, default="1")
@click.option("-s", "--state", default="active")
@click.option("-d", "--admin", default=False, type=bool)
def add_test_users_to_db(generate, state, admin):
def add_test_users_to_db(generate, state, admin): # pragma: no cover
if getenv("NOTIFY_ENVIRONMENT", "") not in ["development", "test"]:
current_app.logger.error("Can only be run in development")
return

View File

@@ -251,7 +251,7 @@ class Config(object):
},
"delete_old_s3_objects": {
"task": "delete-old-s3-objects",
"schedule": crontab(minute="*/5"),
"schedule": crontab(hour=7, minute=10),
"options": {"queue": QueueNames.PERIODIC},
},
"regenerate-job-cache": {
@@ -269,6 +269,11 @@ class Config(object):
"expires": 60,
}, # Ensure it doesn't run if missed
},
"clean-job-cache": {
"task": "clean-job-cache",
"schedule": crontab(hour=2, minute=11),
"options": {"queue": QueueNames.PERIODIC},
},
"cleanup-unfinished-jobs": {
"task": "cleanup-unfinished-jobs",
"schedule": crontab(hour=4, minute=5),

View File

@@ -1,4 +1,5 @@
import json
import os
from contextlib import suppress
from urllib import parse
@@ -35,17 +36,14 @@ def send_sms_to_provider(notification):
Get data for recipient, template,
notification and send it to sns.
"""
# we no longer store the personalisation in the db,
# need to retrieve from s3 before generating content
# However, we are still sending the initial verify code through personalisation
# so if there is some value there, don't overwrite it
if not notification.personalisation:
personalisation = get_personalisation_from_s3(
notification.service_id,
notification.job_id,
notification.job_row_number,
)
notification.personalisation = personalisation
# Take this path for report generation, where we know
# everything is in the cache.
personalisation = get_personalisation_from_s3(
notification.service_id,
notification.job_id,
notification.job_row_number,
)
notification.personalisation = personalisation
service = SerialisedService.from_id(notification.service_id)
message_id = None
@@ -104,7 +102,9 @@ def send_sms_to_provider(notification):
recipient_lookup = f"+{recipient}"
else:
recipient_lookup = recipient
if recipient_lookup in current_app.config["SIMULATED_SMS_NUMBERS"]:
if recipient_lookup in current_app.config[
"SIMULATED_SMS_NUMBERS"
] and os.getenv("NOTIFY_ENVIRONMENT") in ["development", "test"]:
current_app.logger.info(hilite("#validate-phone-number fired"))
aws_pinpoint_client.validate_phone_number("01", recipient)
else:

View File

@@ -175,6 +175,7 @@ def create_job(service_id):
original_file_name = data.get("original_file_name")
data.update({"service": service_id})
try:
current_app.logger.info(f"#s3-partitioning DATA IN CREATE_JOB: {data}")
data.update(**get_job_metadata_from_s3(service_id, data["id"]))
except KeyError:
raise InvalidRequest(

View File

@@ -86,7 +86,7 @@ def _create_service_invite(invited_user, invite_link_host):
redis_store.set(
f"email-personalisation-{saved_notification.id}",
json.dumps(personalisation),
ex=1800,
ex=2*24*60*60,
)
send_notification_to_queue(saved_notification, queue=QueueNames.NOTIFY)

View File

@@ -599,7 +599,6 @@ def fetch_user_by_email():
fetched_user = get_user_by_email(email["email"])
debug_not_production(hilite(f"fetched user is {fetched_user}"))
result = fetched_user.serialize()
debug_not_production(hilite(f"result is serialized to {result}"))
return jsonify(data=result)
except Exception as e:
debug_not_production(hilite(f"Failed with {e}!!"))

View File

@@ -1242,6 +1242,17 @@ Notify.gov DNS records are maintained within [the 18f/dns repository](https://gi
- Rename to `api_static_scan_DATE.zip` and add it to 🔒 https://drive.google.com/drive/folders/1dSe9H7Ag_hLfi5hmQDB2ktWaDwWSf4_R
- Repeat for https://github.com/GSA/notifications-admin/actions/workflows/daily_checks.yml
## Rotating the DANGEROUS_SALT
1. Start API locally `make run-procfile`
2. In a separate terminal tab, navigate to the API project and run `poetry run flask command generate-salt`
3. A random secret will appear in the tab
4. Go to github->settings->secrets and variables->actions in the admin project and find the DANGEROUS_SALT secret for the admin project for staging. Open it and paste the result of #3 into the secret and save. Repeat for the API project, for staging.
5. Repeat #3 and #4 but do it for demo
6. Repeat #3 and #4 but do it for production
The important thing is to use the same secret for Admin and API on each tier--i.e. you only generate three secrets.
## <a name="gotcha"></a> Known Gotchas

View File

@@ -39,6 +39,12 @@ def init_app(app):
for logger_instance, handler in product(warning_loggers, handlers):
logger_instance.addHandler(handler)
logger_instance.setLevel(logging.WARNING)
# Suppress specific loggers to prevent leaking sensitive info
logging.getLogger("boto3").setLevel(logging.ERROR)
logging.getLogger("botocore").setLevel(logging.ERROR)
logging.getLogger("urllib3").setLevel(logging.ERROR)
app.logger.info("Logging configured")

View File

@@ -1,4 +1,5 @@
import os
from datetime import timedelta
from os import getenv
import pytest
@@ -8,6 +9,7 @@ from app.aws.s3 import (
cleanup_old_s3_objects,
file_exists,
get_job_from_s3,
get_job_id_from_s3_object_key,
get_personalisation_from_s3,
get_phone_number_from_s3,
get_s3_file,
@@ -31,15 +33,30 @@ def single_s3_object_stub(key="foo", last_modified=None):
def test_cleanup_old_s3_objects(mocker):
"""
Currently we are going to delete s3 objects if they are more than 14 days old,
because we want to delete all jobs older than 7 days, and jobs can be scheduled
three days in advance, and on top of that we want to leave a little cushion for
the time being. This test shows that a 3 day old job ("B") is not deleted,
whereas a 30 day old job ("A") is.
"""
mocker.patch("app.aws.s3.get_bucket_name", return_value="Bucket")
mock_s3_client = mocker.Mock()
mocker.patch("app.aws.s3.get_s3_client", return_value=mock_s3_client)
mock_remove_csv_object = mocker.patch("app.aws.s3.remove_csv_object")
lastmod30 = aware_utcnow() - timedelta(days=30)
lastmod3 = aware_utcnow() - timedelta(days=3)
mock_s3_client.list_objects_v2.return_value = {
"Contents": [{"Key": "A", "LastModified": aware_utcnow()}]
"Contents": [
{"Key": "A", "LastModified": lastmod30},
{"Key": "B", "LastModified": lastmod3},
]
}
cleanup_old_s3_objects()
mock_s3_client.list_objects_v2.assert_called_with(Bucket="Bucket")
mock_remove_csv_object.assert_called_once_with("A")
def test_get_s3_file_makes_correct_call(notify_api, mocker):
@@ -95,13 +112,27 @@ def test_get_s3_file_makes_correct_call(notify_api, mocker):
def test_get_phone_number_from_s3(
mocker, job, job_id, job_row_number, expected_phone_number
):
mocker.patch("app.aws.s3.redis_store")
get_job_mock = mocker.patch("app.aws.s3.get_job_from_s3")
get_job_mock.return_value = job
phone_number = get_phone_number_from_s3("service_id", job_id, job_row_number)
assert phone_number == expected_phone_number
@pytest.mark.parametrize(
"key, expected_job_id",
[
("service-blahblahblah-notify/abcde.csv", "abcde"),
(
"service-x-notify/4c99f361-4ed7-49b1-bd6f-02fe0c807c53.csv",
"4c99f361-4ed7-49b1-bd6f-02fe0c807c53",
),
],
)
def test_get_job_id_from_s3_object_key(key, expected_job_id):
actual_job_id = get_job_id_from_s3_object_key(key)
assert actual_job_id == expected_job_id
def mock_s3_get_object_slowdown(*args, **kwargs):
error_response = {
"Error": {
@@ -120,7 +151,7 @@ def test_get_job_from_s3_exponential_backoff_on_throttling(mocker):
mocker.patch("app.aws.s3.file_exists", return_value=True)
job = get_job_from_s3("service_id", "job_id")
assert job is None
assert mock_get_object.call_count == 4
assert mock_get_object.call_count == 8
def test_get_job_from_s3_exponential_backoff_file_not_found(mocker):
@@ -160,7 +191,6 @@ def test_get_job_from_s3_exponential_backoff_file_not_found(mocker):
def test_get_personalisation_from_s3(
mocker, job, job_id, job_row_number, expected_personalisation
):
mocker.patch("app.aws.s3.redis_store")
get_job_mock = mocker.patch("app.aws.s3.get_job_from_s3")
get_job_mock.return_value = job
personalisation = get_personalisation_from_s3("service_id", job_id, job_row_number)

File diff suppressed because it is too large Load Diff