From 6de47173988d12c767b77635b15b4daf3cde4df3 Mon Sep 17 00:00:00 2001 From: Jason Still Date: Wed, 25 Jul 2018 09:51:00 -0400 Subject: [PATCH 1/5] Remove readonly_* vars being passed to DB cluster There are no settings by that name on a DB cluster --- infrastructure/terraform/modules/environment/environment.tf | 2 -- 1 file changed, 2 deletions(-) diff --git a/infrastructure/terraform/modules/environment/environment.tf b/infrastructure/terraform/modules/environment/environment.tf index 5ac7848..4e3035f 100644 --- a/infrastructure/terraform/modules/environment/environment.tf +++ b/infrastructure/terraform/modules/environment/environment.tf @@ -631,8 +631,6 @@ resource "aws_rds_cluster" "waze_database_cluster" { database_name = "waze_data" master_username = "${var.rds_master_username}" master_password = "${var.rds_master_password}" - readonly_username = "${var.rds_readonly_username}" - readonly_password = "${var.rds_readonly_password}" backup_retention_period = 3 # short because all the data could be regenerated easily preferred_backup_window = "02:00-04:00" preferred_maintenance_window = "wed:05:00-wed:06:00" From 63c08333dadce1a9884758a9af2d3faff3ccb6b2 Mon Sep 17 00:00:00 2001 From: Jason Still Date: Thu, 26 Jul 2018 11:44:50 -0400 Subject: [PATCH 2/5] Update .gitignore Modify ignore to generically handle built typescript --- .gitignore | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index d182542..11ab16d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,11 @@ - + \.vscode/ - + node_modules/ *.log - + **/\.terraform/ - -code/lambda-functions/waze-data-process/built/ - -code/lambda-functions/waze-data-process/dist/ - -code/lambda-functions/waze-data-download/built/ - -code/lambda-functions/waze-data-download/dist/ + +code/lambda-functions/*/built/ + +code/lambda-functions/*/dist/ From 294575f4cbd9edbe201408d1f3cd6252cb91c7c4 Mon Sep 17 00:00:00 2001 From: Jason Still Date: Thu, 26 Jul 2018 11:46:03 -0400 Subject: [PATCH 3/5] Rename initial-install.sql Rename file and alter to only create schema and users --- code/sql/initial-install.sql | 15 --------------- code/sql/initialize-schema-and-roles.sql | 21 +++++++++++++++++++++ 2 files changed, 21 insertions(+), 15 deletions(-) delete mode 100644 code/sql/initial-install.sql create mode 100644 code/sql/initialize-schema-and-roles.sql diff --git a/code/sql/initial-install.sql b/code/sql/initial-install.sql deleted file mode 100644 index 02d5659..0000000 --- a/code/sql/initial-install.sql +++ /dev/null @@ -1,15 +0,0 @@ --- Add Alerts type_id --- modify database -ALTER TABLE waze.alerts ADD COLUMN IF NOT EXISTS type_id INTEGER; - --- create index -CREATE INDEX CONCURRENTLY alerts_type_id_idx ON waze.alerts (type_id); - --- Create read-only user -CREATE USER waze_readonly WITH ENCRYPTED PASSWORD ''; - -GRANT CONNECT ON DATABASE waze_data TO waze_readonly; -GRANT USAGE ON SCHEMA waze TO waze_readonly; -GRANT SELECT ON ALL SEQUENCES IN SCHEMA waze TO waze_readonly; -GRANT SELECT ON ALL TABLES IN SCHEMA waze to waze_readonly; -ALTER DEFAULT PRIVILEGES IN SCHEMA waze GRANT SELECT ON TABLES TO waze_readonly; diff --git a/code/sql/initialize-schema-and-roles.sql b/code/sql/initialize-schema-and-roles.sql new file mode 100644 index 0000000..5170b7b --- /dev/null +++ b/code/sql/initialize-schema-and-roles.sql @@ -0,0 +1,21 @@ +-- create the schema +CREATE SCHEMA waze; + +-- create the lambda role +CREATE ROLE lambda_role LOGIN PASSWORD 'LAMBDA_ROLE_PASSWORD_PLACEHOLDER'; + +-- setup permissions for the lambda role +GRANT ALL ON SCHEMA waze TO lambda_role; +ALTER DEFAULT PRIVILEGES IN SCHEMA waze GRANT ALL ON TABLES TO lambda_role; +ALTER DEFAULT PRIVILEGES IN SCHEMA waze GRANT SELECT, USAGE ON SEQUENCES TO lambda_role; +ALTER DEFAULT PRIVILEGES IN SCHEMA waze GRANT EXECUTE ON FUNCTIONS TO lambda_role; +ALTER DEFAULT PRIVILEGES IN SCHEMA waze GRANT USAGE ON TYPES TO lambda_role; + + +-- Create read-only role +CREATE ROLE waze_readonly LOGIN PASSWORD 'READONLY_ROLE_PASSWORD_PLACEHOLDER'; + +GRANT CONNECT ON DATABASE waze_data TO waze_readonly; +GRANT USAGE ON SCHEMA waze TO waze_readonly; +ALTER DEFAULT PRIVILEGES IN SCHEMA waze GRANT SELECT ON SEQUENCES TO waze_readonly; +ALTER DEFAULT PRIVILEGES IN SCHEMA waze GRANT SELECT ON TABLES TO waze_readonly; From 07ed09bc7f609eb60aacf7c4b076e31434f33f23 Mon Sep 17 00:00:00 2001 From: Jason Still Date: Thu, 26 Jul 2018 11:49:32 -0400 Subject: [PATCH 4/5] Update schema Add version table and merge in other updates to unify initial table creation Add note to indexes file that they should be created in the main schema scripts Add version table and 2.1 version record to 2.0.1 to 2.1 update script --- code/sql/indexes.sql | 2 +- code/sql/initialize-schema-and-roles.sql | 23 +++++----- code/sql/schema.sql | 53 ++++++++++++++---------- code/sql/update-2.0.1-2.1.sql | 13 ++++++ 4 files changed, 58 insertions(+), 33 deletions(-) diff --git a/code/sql/indexes.sql b/code/sql/indexes.sql index 1d58012..aef806d 100644 --- a/code/sql/indexes.sql +++ b/code/sql/indexes.sql @@ -3,7 +3,7 @@ -- select all indexes SELECT * FROM pg_indexes WHERE tablename like '%' and schemaname = 'waze' order by indexname ; --- create indexes +-- create indexes (NOTE: THESE ARE CREATED IN THE BASE SCHEMA ALREADY AND MAINTAINED HERE FOR EASY DROP/RECREATE IF NECESSARY) CREATE INDEX CONCURRENTLY jams_pub_utc_date_idx ON waze.jams (pub_utc_date); CREATE INDEX CONCURRENTLY alerts_pub_utc_date_idx ON waze.alerts (pub_utc_date); CREATE INDEX CONCURRENTLY coordinates_jam_id_idx ON waze.coordinates (jam_id); diff --git a/code/sql/initialize-schema-and-roles.sql b/code/sql/initialize-schema-and-roles.sql index 5170b7b..79bdcf8 100644 --- a/code/sql/initialize-schema-and-roles.sql +++ b/code/sql/initialize-schema-and-roles.sql @@ -2,20 +2,21 @@ CREATE SCHEMA waze; -- create the lambda role -CREATE ROLE lambda_role LOGIN PASSWORD 'LAMBDA_ROLE_PASSWORD_PLACEHOLDER'; +-- NOTE: don't change the placeholder text before provisioning the environment, as we replace it during initial install +CREATE ROLE LAMBDA_ROLE_NAME_PLACEHOLDER LOGIN PASSWORD 'LAMBDA_ROLE_PASSWORD_PLACEHOLDER'; -- setup permissions for the lambda role -GRANT ALL ON SCHEMA waze TO lambda_role; -ALTER DEFAULT PRIVILEGES IN SCHEMA waze GRANT ALL ON TABLES TO lambda_role; -ALTER DEFAULT PRIVILEGES IN SCHEMA waze GRANT SELECT, USAGE ON SEQUENCES TO lambda_role; -ALTER DEFAULT PRIVILEGES IN SCHEMA waze GRANT EXECUTE ON FUNCTIONS TO lambda_role; -ALTER DEFAULT PRIVILEGES IN SCHEMA waze GRANT USAGE ON TYPES TO lambda_role; +GRANT ALL ON SCHEMA waze TO LAMBDA_ROLE_NAME_PLACEHOLDER; +ALTER DEFAULT PRIVILEGES IN SCHEMA waze GRANT ALL ON TABLES TO LAMBDA_ROLE_NAME_PLACEHOLDER; +ALTER DEFAULT PRIVILEGES IN SCHEMA waze GRANT SELECT, USAGE ON SEQUENCES TO LAMBDA_ROLE_NAME_PLACEHOLDER; +ALTER DEFAULT PRIVILEGES IN SCHEMA waze GRANT EXECUTE ON FUNCTIONS TO LAMBDA_ROLE_NAME_PLACEHOLDER; +ALTER DEFAULT PRIVILEGES IN SCHEMA waze GRANT USAGE ON TYPES TO LAMBDA_ROLE_NAME_PLACEHOLDER; -- Create read-only role -CREATE ROLE waze_readonly LOGIN PASSWORD 'READONLY_ROLE_PASSWORD_PLACEHOLDER'; +-- NOTE: don't change the placeholder text before provisioning the environment, as we replace it during initial install +CREATE ROLE READONLY_ROLE_NAME_PLACEHOLDER LOGIN PASSWORD 'READONLY_ROLE_PASSWORD_PLACEHOLDER'; -GRANT CONNECT ON DATABASE waze_data TO waze_readonly; -GRANT USAGE ON SCHEMA waze TO waze_readonly; -ALTER DEFAULT PRIVILEGES IN SCHEMA waze GRANT SELECT ON SEQUENCES TO waze_readonly; -ALTER DEFAULT PRIVILEGES IN SCHEMA waze GRANT SELECT ON TABLES TO waze_readonly; +GRANT USAGE ON SCHEMA waze TO READONLY_ROLE_NAME_PLACEHOLDER; +ALTER DEFAULT PRIVILEGES IN SCHEMA waze GRANT SELECT ON SEQUENCES TO READONLY_ROLE_NAME_PLACEHOLDER; +ALTER DEFAULT PRIVILEGES IN SCHEMA waze GRANT SELECT ON TABLES TO READONLY_ROLE_NAME_PLACEHOLDER; diff --git a/code/sql/schema.sql b/code/sql/schema.sql index c778e3c..9df240d 100644 --- a/code/sql/schema.sql +++ b/code/sql/schema.sql @@ -1,27 +1,26 @@ -CREATE SCHEMA waze; - --- create the lambda role -CREATE ROLE lambda_role LOGIN PASSWORD 'ENTER THE SAME PASSWORD YOU USED IN TERRAFORM HERE'; - --- setup permissions for the lambda role -GRANT ALL ON SCHEMA waze TO lambda_role; -ALTER DEFAULT PRIVILEGES IN SCHEMA waze GRANT ALL ON TABLES TO lambda_role; -ALTER DEFAULT PRIVILEGES IN SCHEMA waze GRANT SELECT, USAGE ON SEQUENCES TO lambda_role; -ALTER DEFAULT PRIVILEGES IN SCHEMA waze GRANT EXECUTE ON FUNCTIONS TO lambda_role; -ALTER DEFAULT PRIVILEGES IN SCHEMA waze GRANT USAGE ON TYPES TO lambda_role; +CREATE SCHEMA IF NOT EXISTS waze; -- schema should be created by install script, but keeping here for manual run as well +-- version table will be used for version tracking and upgrading +-- THIS TABLE MUST GET A RECORD ADDED WITH NEW INSTALLS TO INDICATE WHICH VERSION GOT INSTALLED +-- Auto-schema install should handle adding the initial record +-- Update scripts MUST ALSO add a new row to the table to indicate the new version +CREATE TABLE waze.application_version +( + "version_number" VARCHAR(30) PRIMARY KEY NOT NULL, + "install_date" TIMESTAMP NOT NULL +); CREATE TABLE waze.data_files ( -"id" SERIAL PRIMARY KEY NOT NULL, -"start_time_millis" BIGINT NOT NULL, -"end_time_millis" BIGINT NOT NULL, -"start_time" TIMESTAMP, -"end_time" TIMESTAMP, -"date_created" TIMESTAMP, -"date_updated" TIMESTAMP, -"file_name" TEXT NOT NULL, -"json_hash" VARCHAR(40) NOT NULL + "id" SERIAL PRIMARY KEY NOT NULL, + "start_time_millis" BIGINT NOT NULL, + "end_time_millis" BIGINT NOT NULL, + "start_time" TIMESTAMP, + "end_time" TIMESTAMP, + "date_created" TIMESTAMP, + "date_updated" TIMESTAMP, + "file_name" TEXT NOT NULL, + "json_hash" VARCHAR(40) NOT NULL ); CREATE UNIQUE INDEX "IDX_UNIQUE_json_hash" @@ -75,7 +74,8 @@ CREATE TABLE waze.alerts "report_by_municipality_user" BOOLEAN, "thumbs_up" INTEGER, "jam_uuid" TEXT, - "datafile_id" BIGINT NOT NULL REFERENCES waze.data_files (id) + "datafile_id" BIGINT NOT NULL REFERENCES waze.data_files (id), + "type_id" INTEGER ); CREATE TABLE waze.irregularities @@ -216,3 +216,14 @@ INSERT INTO waze.alert_types (type, subtype) VALUES ('CONSTRUCTION', 'NO_SUBTYPE INSERT INTO waze.alert_types (type, subtype) VALUES ('ROAD_CLOSED', 'ROAD_CLOSED_HAZARD'); INSERT INTO waze.alert_types (type, subtype) VALUES ('ROAD_CLOSED', 'ROAD_CLOSED_CONSTRUCTION'); INSERT INTO waze.alert_types (type, subtype) VALUES ('ROAD_CLOSED', 'ROAD_CLOSED_EVENT'); + + +-- create indexes +CREATE INDEX jams_pub_utc_date_idx ON waze.jams (pub_utc_date); +CREATE INDEX alerts_pub_utc_date_idx ON waze.alerts (pub_utc_date); +CREATE INDEX coordinates_jam_id_idx ON waze.coordinates (jam_id); +CREATE INDEX coordinates_latitude_idx ON waze.coordinates (latitude); +CREATE INDEX coordinates_longitude_idx ON waze.coordinates (longitude); +CREATE INDEX coordinates_coordinate_type_id_idx ON waze.coordinates (coordinate_type_id); +CREATE INDEX coordinates_alert_id_idx ON waze.coordinates (alert_id); +CREATE INDEX alerts_type_id_idx ON waze.alerts (type_id); \ No newline at end of file diff --git a/code/sql/update-2.0.1-2.1.sql b/code/sql/update-2.0.1-2.1.sql index 35b9ffb..7191d23 100644 --- a/code/sql/update-2.0.1-2.1.sql +++ b/code/sql/update-2.0.1-2.1.sql @@ -26,3 +26,16 @@ GRANT USAGE ON SCHEMA waze TO waze_readonly; GRANT SELECT ON ALL SEQUENCES IN SCHEMA waze TO waze_readonly; GRANT SELECT ON ALL TABLES IN SCHEMA waze to waze_readonly; ALTER DEFAULT PRIVILEGES IN SCHEMA waze GRANT SELECT ON TABLES TO waze_readonly; + + +-- version table will be used for version tracking and upgrading +-- THIS TABLE MUST GET A RECORD ADDED WITH NEW INSTALLS TO INDICATE WHICH VERSION GOT INSTALLED +-- Update scripts MUST ALSO add a new row to the table to indicate the new version +CREATE TABLE waze.application_version +( + "version_number" VARCHAR(30) PRIMARY KEY NOT NULL, + "install_date" TIMESTAMP NOT NULL +) + +-- insert the CURRENT VERSION NUMBER into the version table +INSERT INTO waze.application_version (version_number, install_date) VALUES ('2.1', current_timestamp); From d8acba4c4baf7df2386f325a73360b80a0a96545 Mon Sep 17 00:00:00 2001 From: Jason Still Date: Thu, 26 Jul 2018 16:17:04 -0400 Subject: [PATCH 5/5] Add DB initialization Add lambda to initialize the database and run it during TF run --- code/lambda-functions/waze-db-initialize.zip | Bin 0 -> 24111 bytes .../waze-db-initialize/package-lock.json | 4536 +++++++++++++++++ .../waze-db-initialize/package.json | 29 + .../src/waze-db-initialize.ts | 114 + .../waze-db-initialize/tsconfig.json | 16 + .../waze-db-initialize/webpack.config.js | 42 + .../terraform/environment/env-dev/outputs.tf | 5 + .../terraform/environment/env-prod/outputs.tf | 5 + .../modules/current-app-version/Readme.md | 3 + .../current-app-version.tf | 4 + .../modules/environment/environment.tf | 69 +- 11 files changed, 4820 insertions(+), 3 deletions(-) create mode 100644 code/lambda-functions/waze-db-initialize.zip create mode 100644 code/lambda-functions/waze-db-initialize/package-lock.json create mode 100644 code/lambda-functions/waze-db-initialize/package.json create mode 100644 code/lambda-functions/waze-db-initialize/src/waze-db-initialize.ts create mode 100644 code/lambda-functions/waze-db-initialize/tsconfig.json create mode 100644 code/lambda-functions/waze-db-initialize/webpack.config.js create mode 100644 infrastructure/terraform/modules/current-app-version/Readme.md create mode 100644 infrastructure/terraform/modules/current-app-version/current-app-version.tf diff --git a/code/lambda-functions/waze-db-initialize.zip b/code/lambda-functions/waze-db-initialize.zip new file mode 100644 index 0000000000000000000000000000000000000000..47953bd63beb7728fe167ac8e20723985f00b38b GIT binary patch literal 24111 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 z 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 { + try{ + + // get a DB client to run our queries with + var dbClient = new pg.Client(); + + //open the connection + await dbClient.connect(); + + //grab the env vars we'll need for the usernames and passwords to create + let lambda_username = process.env.LAMBDAPGUSER; + let lambda_password = process.env.LAMBDAPGPASSWORD; + let readonly_username = process.env.READONLYPGUSER; + let readonly_password = process.env.READONLYPASSWORD; + let current_version = process.env.CURRENTVERSION; + + //check if the schema already exists, and if so, check the version installed against what we're trying to install + //if versions are same, just log info message and exit, otherwise log warning and exit + let schemaResult = await dbClient.query("SELECT 1 FROM information_schema.schemata WHERE schema_name = 'waze';"); + if (schemaResult.rowCount > 0){ + //the schema exists, see if we have a version table (that gets its own special error) + console.log("SCHEMA exists, verifying versions"); + + let versionTableExistsResult = await dbClient.query("SELECT 1 FROM information_schema.tables WHERE table_schema = 'waze' AND table_name = 'application_version';") + if(versionTableExistsResult.rowCount === 0){ + //there IS NO version table, which is a problem + console.error('Version table not found'); + return { response: formatTerraformWarning('Version table not found, please verify SQL schema is up to date.') }; + } + + //version table found, so we need to make sure it is the same version as what we would be trying to install + let versionCheckResult = await dbClient.query("SELECT version_number from waze.application_version ORDER BY install_date DESC LIMIT 1"); + //if we didn't get a result, or get a result that isn't an exact match, warn about it + if(versionCheckResult.rowCount === 0){ + console.error('No version records found'); + return { response: formatTerraformWarning('No version records found, please verify SQL schema is up to date.') }; + } + else if(versionCheckResult.rows[0].version_number !== current_version){ + console.error('Version mismatch'); + return { response: formatTerraformWarning('Version mismatch, please verify SQL schema is up to date.') }; + } + else{ + //versions match up, so just return a notice that nothing needed to be done + console.log('Versions match, no DB changes needed'); + return { response: "Database is up-to-date" }; + } + } + + //the schema didn't exist, so we need to create everything + //first, load up the initialize script + let initFile = fs.readFileSync('./initialize-schema-and-roles.sql', 'utf-8'); + + //now we need to replace the placeholders + //we'll also do a quick check that they actually exist, and throw an error if not, just in case someone broke the script + const lambdaUserPlaceholder = 'LAMBDA_ROLE_NAME_PLACEHOLDER'; + const lambdaPassPlaceholder = 'LAMBDA_ROLE_PASSWORD_PLACEHOLDER'; + const readonlyUserPlaceholder = 'READONLY_ROLE_NAME_PLACEHOLDER'; + const readonlyPassPlaceholder = 'READONLY_ROLE_PASSWORD_PLACEHOLDER'; + + if(initFile.indexOf(lambdaUserPlaceholder) < 0 || initFile.indexOf(lambdaPassPlaceholder) < 0 || + initFile.indexOf(readonlyUserPlaceholder) < 0 || initFile.indexOf(readonlyPassPlaceholder) < 0){ + throw new Error('DB initialization script is missing placeholders and cannot be run'); + } + + //run all the replacements + initFile = initFile.replace(new RegExp(lambdaUserPlaceholder, 'g'), lambda_username) + .replace(new RegExp(lambdaPassPlaceholder, 'g'), lambda_password) + .replace(new RegExp(readonlyUserPlaceholder, 'g'), readonly_username) + .replace(new RegExp(readonlyPassPlaceholder, 'g'), readonly_password); + + //execute the sql! + await dbClient.query(initFile); + + //load and run the table creation + let schemaFile = fs.readFileSync('./schema.sql', 'utf-8'); + await dbClient.query(schemaFile); + + //update the version table + await dbClient.query('INSERT INTO waze.application_version VALUES ($1, current_timestamp)', [current_version]); + + //return success + console.log('Database intialization succeeded'); + return { response: "Database intialization succeeded" } + + } + catch (err) { + console.error(err); + callback(err); + return err; + } + finally{ + // CLOSE THAT CLIENT! + await dbClient.end(); + } +}; + +export {initializeDatabase} + +//build a terraform-output-friendly warning message +function formatTerraformWarning(warningMessage:string):string { + return ` + + WARNING! ********************* WARNING! ********************* WARNING! + ${warningMessage} + WARNING! ********************* WARNING! ********************* WARNING! + + `; +} \ No newline at end of file diff --git a/code/lambda-functions/waze-db-initialize/tsconfig.json b/code/lambda-functions/waze-db-initialize/tsconfig.json new file mode 100644 index 0000000..cf72a45 --- /dev/null +++ b/code/lambda-functions/waze-db-initialize/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "outDir": "./built", + "allowJs": true, + "noImplicitAny": true, + "removeComments": true, + "target": "es6", + "alwaysStrict": true, + "checkJs": true, + "noEmitOnError": true, + "module": "commonjs" + }, + "include": [ + "./src/**/*" + ] +} \ No newline at end of file diff --git a/code/lambda-functions/waze-db-initialize/webpack.config.js b/code/lambda-functions/waze-db-initialize/webpack.config.js new file mode 100644 index 0000000..872debf --- /dev/null +++ b/code/lambda-functions/waze-db-initialize/webpack.config.js @@ -0,0 +1,42 @@ +var path = require('path'); +var ZipPlugin = require('zip-webpack-plugin'); +var CopyWebpackPlugin = require('copy-webpack-plugin') + +module.exports = { + entry: './src/waze-db-initialize.ts', + target: 'node', + mode: 'production', + module: { + rules: [ + { + test: /\.tsx?$/, + use: 'ts-loader', + exclude: /node_modules/ + } + ] + }, + resolve: { + extensions: [ '.tsx', '.ts', '.js' ] + }, + externals: ['pg-native'], + output: { + libraryTarget: 'commonjs', + filename: 'waze-db-initialize.js', + path: path.resolve(__dirname, 'dist') + }, + plugins: [ + new CopyWebpackPlugin([ + '../../sql/initialize-schema-and-roles.sql', + '../../sql/schema.sql' + ]), + new ZipPlugin({ + // OPTIONAL: defaults to the Webpack output path (above) + // can be relative (to Webpack output path) or absolute + path: '../../', + + // OPTIONAL: defaults to the Webpack output filename (above) or, + // if not present, the basename of the path + filename: 'waze-db-initialize.zip', + }) + ] + }; \ No newline at end of file diff --git a/infrastructure/terraform/environment/env-dev/outputs.tf b/infrastructure/terraform/environment/env-dev/outputs.tf index 396bcc4..1869b3d 100644 --- a/infrastructure/terraform/environment/env-dev/outputs.tf +++ b/infrastructure/terraform/environment/env-dev/outputs.tf @@ -31,4 +31,9 @@ output "data_in_queue_alarm_sns_topic_arn" { output "data_processing_dlq_sns_topic_arn" { value = "${module.environment.data_processing_dlq_sns_topic_arn}" description = "ARN of the SNS topic that will receive notifications when records are found in the dead letter queue" +} + +output "db_init_response" { + value = "${module.environment.db_init_response}" + description = "Response returned by DB initialization invocation" } \ No newline at end of file diff --git a/infrastructure/terraform/environment/env-prod/outputs.tf b/infrastructure/terraform/environment/env-prod/outputs.tf index 396bcc4..1869b3d 100644 --- a/infrastructure/terraform/environment/env-prod/outputs.tf +++ b/infrastructure/terraform/environment/env-prod/outputs.tf @@ -31,4 +31,9 @@ output "data_in_queue_alarm_sns_topic_arn" { output "data_processing_dlq_sns_topic_arn" { value = "${module.environment.data_processing_dlq_sns_topic_arn}" description = "ARN of the SNS topic that will receive notifications when records are found in the dead letter queue" +} + +output "db_init_response" { + value = "${module.environment.db_init_response}" + description = "Response returned by DB initialization invocation" } \ No newline at end of file diff --git a/infrastructure/terraform/modules/current-app-version/Readme.md b/infrastructure/terraform/modules/current-app-version/Readme.md new file mode 100644 index 0000000..5ad7873 --- /dev/null +++ b/infrastructure/terraform/modules/current-app-version/Readme.md @@ -0,0 +1,3 @@ +## Current App Version Module + +Provides a single easy to find location for tracking the current application version, so that we can pass it to lambdas and such as needed. \ No newline at end of file diff --git a/infrastructure/terraform/modules/current-app-version/current-app-version.tf b/infrastructure/terraform/modules/current-app-version/current-app-version.tf new file mode 100644 index 0000000..74850e1 --- /dev/null +++ b/infrastructure/terraform/modules/current-app-version/current-app-version.tf @@ -0,0 +1,4 @@ +output "version_number" { + value = "2.1" + description = "The current version of the application" +} \ No newline at end of file diff --git a/infrastructure/terraform/modules/environment/environment.tf b/infrastructure/terraform/modules/environment/environment.tf index 4e3035f..0a3ad2d 100644 --- a/infrastructure/terraform/modules/environment/environment.tf +++ b/infrastructure/terraform/modules/environment/environment.tf @@ -3,6 +3,12 @@ provider "aws" { region = "${var.default_resource_region}" } +# load up the current app version module +module "current_app_version" { + source = "../current-app-version" +} + + ############################################### # Cloudwatch ############################################### @@ -380,7 +386,7 @@ resource "aws_iam_role_policy_attachment" "data_retrieval_lambda_basic_logging_r resource "aws_lambda_function" "waze_data_retrieval_function" { filename = "${var.lambda_artifacts_path}/waze-data-download.zip" function_name = "${var.object_name_prefix}-waze-data-retrieval" - runtime = "nodejs6.10" + runtime = "nodejs8.10" role = "${aws_iam_role.data_retrieval_execution_role.arn}" handler = "waze-data-download.downloadData" timeout = 300 @@ -467,7 +473,7 @@ resource "aws_lambda_function" "waze_data_alerts_processing_function" { resource "aws_lambda_function" "waze_data_jams_processing_function" { filename = "${var.lambda_artifacts_path}/waze-data-process.zip" function_name = "${var.object_name_prefix}-waze-data-jams-processing" - runtime = "nodejs6.10" + runtime = "nodejs8.10" role = "${aws_iam_role.data_retrieval_execution_role.arn}" handler = "waze-data-process.processDataJams" timeout = 300 @@ -494,7 +500,7 @@ resource "aws_lambda_function" "waze_data_jams_processing_function" { resource "aws_lambda_function" "waze_data_irregularities_processing_function" { filename = "${var.lambda_artifacts_path}/waze-data-process.zip" function_name = "${var.object_name_prefix}-waze-data-irregularities-processing" - runtime = "nodejs6.10" + runtime = "nodejs8.10" role = "${aws_iam_role.data_retrieval_execution_role.arn}" handler = "waze-data-process.processDataIrregularities" timeout = 300 @@ -517,6 +523,63 @@ resource "aws_lambda_function" "waze_data_irregularities_processing_function" { } } +# setup the db initialize function +resource "aws_lambda_function" "waze_db_initialize_function" { + + # this function will be used to intialize the DB, so a DB instance must be in service first + depends_on = ["aws_rds_cluster_instance.waze_database_instances"] + + filename = "${var.lambda_artifacts_path}/waze-db-initialize.zip" + source_code_hash = "${base64sha256(file("${var.lambda_artifacts_path}/waze-db-initialize.zip"))}" + function_name = "${var.object_name_prefix}-waze-db-initialize" + runtime = "nodejs8.10" + role = "${aws_iam_role.data_retrieval_execution_role.arn}" + handler = "waze-db-initialize.initializeDatabase" + timeout = 300 + memory_size = 512 + + environment { + variables = { + PGHOST = "${aws_rds_cluster.waze_database_cluster.endpoint}" + PGUSER = "${aws_rds_cluster.waze_database_cluster.master_username}" + PGPASSWORD = "${aws_rds_cluster.waze_database_cluster.master_password}" + PGDATABASE = "${aws_rds_cluster.waze_database_cluster.database_name}" + PGPORT = "${var.rds_port}" + POOLSIZE = "1" + + LAMBDAPGUSER = "${var.lambda_db_username}" + LAMBDAPGPASSWORD = "${var.lambda_db_password}" + + READONLYPGUSER = "${var.rds_readonly_username}" + READONLYPASSWORD = "${var.rds_readonly_password}" + + CURRENTVERSION = "${module.current_app_version.version_number}" + } + } + + tags { + Environment = "${var.environment}" + Scripted = "true" + } +} + +# invoke the db init lambda so that the database gets initialized +data "aws_lambda_invocation" "waze_db_init_invocation" { + function_name = "${aws_lambda_function.waze_db_initialize_function.function_name}" + # input is required by the invocation data source, but not required by the function, so pass empty JSON + input = <