diff --git a/.gitignore b/.gitignore index 2d0403a..57414ac 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .DS_Store +.terraform node_modules /web/dist diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000..5380367 --- /dev/null +++ b/api/Dockerfile @@ -0,0 +1,20 @@ +FROM 063932952339.dkr.ecr.us-west-2.amazonaws.com/alpine-nim:nim-1.4.8 AS build +MAINTAINER jonathan@jdbernard.com + +COPY hff_entry_forms_api.nimble /hff_entry_forms_api/ +COPY src /hff_entry_forms_api/src +WORKDIR /hff_entry_forms_api +RUN nimble build -y + +FROM alpine +EXPOSE 80 +RUN apk -v --update add --no-cache \ + ca-certificates \ + libcrypto1.1 \ + libssl1.1 \ + pcre \ + postgresql-client + +COPY --from=build /hff_entry_forms_api/hff_entry_forms_api / +COPY hff_entry_forms_api.config.docker.json /hff_entry_forms_api.config.json +CMD ["/hff_entry_forms_api", "serve"] diff --git a/api/Makefile b/api/Makefile new file mode 100644 index 0000000..dd67ce2 --- /dev/null +++ b/api/Makefile @@ -0,0 +1,81 @@ +SOURCES=$(wildcard src/*.nim) $(wildcard src/hff_entry_forms_apipkg/*.nim) + +# AWS Account URL for the ECR repository +ECR_ACCOUNT_URL ?= 063932952339.dkr.ecr.us-west-2.amazonaws.com + +# The port on the host machine (not the container) +PORT ?= 8300 + +# The Notion integration token. +AUTH_SECRET ?= 123abc + +VERSION ?=`git describe` + +default: serve-docker + +# Running the API locally on bare metal +# ------------------------------------- + +hff_entry_forms_api: $(SOURCES) + nimble build + +# Run the API on this machine. Note that configuration is taken by default +# from the `hff_entry_forms_api.config.json` file, but environment variables +# specified when running make can be used to override these (to change the +# DB_CONN_STRING, for example). +serve: hff_entry_forms_api + ./hff_entry_forms_api serve --debug + +# Run tests +# --------- +#unittest: +# nim c -r src/unittest/nim/runner +# +#functest: DB_NAME = live_budget_test +#functest: DB_CONFIG = test +#functest: hff_entry_forms_api start-postgres +# echo "\n--------" >> functest-api-server.log +# ./hff_entry_forms_api serve --config hff_entry_forms_api.config.test.json >> functest-api-server.log & +# -nim c -r src/functest/nim/runner "$(TEST_NAME)" +# curl -s -X POST localhost:$(PORT)/api/v1/control/shutdown | jq . +# db_migrate down -c database-$(DB_CONFIG).json +# PGPASSWORD=password psql -p 5500 -U postgres -h localhost -c "DROP DATABASE $(DB_NAME);" + +# Building and deploying the API container image +# ---------------------------------------------- + +# Build the container image. +build-image: $(SOURCES) + docker image build -t $(ECR_ACCOUNT_URL)/hff_entry_forms_api:$(VERSION) . + +# Push the container image to the private AWS ECR +push-image: build-image + docker push $(ECR_ACCOUNT_URL)/hff_entry_forms_api:$(VERSION) + +# Running locally in a container +# -------------------------------------- + +# Run the API in a docker container. Note that the configuration loaded into +# the Docker container defines very little of the actual configuration as +# environment variables are used in the deployed environments. Accordingly, +# we must specify them explicitly here. +serve-docker: build-image + docker run \ + -e INTEGRATION_TOKEN=$(INTEGRATION_TOKEN) \ + -e PORT=80 \ + -p 127.0.0.1:$(PORT):80/tcp \ + $(ECR_ACCOUNT_URL)/hff_entry_forms_api:$(VERSION) + +# Utility +# ------- + +# Authenticate docker to the AWS private elastic container repository. +ecr-auth: + aws ecr get-login-password --region us-west-2 | docker login --username AWS --password-stdin $(ECR_ACCOUNT_URL) + +echo-vars: + @echo \ + " ECR_ACCOUNT_URL=$(ECR_ACCOUNT_URL)\n" \ + "VERSION=$(VERSION)\n" \ + "PORT=$(PORT)\n" \ + "INTEGRATION_TOKEN=$(INTEGRATION_TOKEN)\n" diff --git a/api/hff_entry_forms_api.config.docker.json b/api/hff_entry_forms_api.config.docker.json new file mode 100644 index 0000000..940db47 --- /dev/null +++ b/api/hff_entry_forms_api.config.docker.json @@ -0,0 +1,7 @@ +{ + "eventParentId": "ffff5ca5cb0547b6a99fb78cdb9b0170", + "knownOrigins": [ "https://forms.hopefamilyfellowship.com" ], + "notionApiBaseUrl": "https://api.notion.com/v1", + "notionVersion": "2021-08-16", + "port": 80 +} diff --git a/api/src/hff_entry_forms_api.nim b/api/src/hff_entry_forms_api.nim index f81d5ef..a02de3b 100644 --- a/api/src/hff_entry_forms_api.nim +++ b/api/src/hff_entry_forms_api.nim @@ -26,7 +26,7 @@ proc loadConfig(args: Table[string, docopt.Value]): HffEntryFormsApiConfig = let cfg = CombinedConfig(docopt: args, json: json) result = HffEntryFormsApiConfig( - debug: parseBool(cfg.getVal("debug")), + debug: args["--debug"], eventParentId: cfg.getVal("event-parent-id"), integrationToken: cfg.getVal("integration-token"), knownOrigins: cfg.getVal("known-origins")[1..^2].split(',').mapIt(it[1..^2]), diff --git a/operations/README.md b/operations/README.md new file mode 100644 index 0000000..699f208 --- /dev/null +++ b/operations/README.md @@ -0,0 +1,11 @@ +# HFF Entry Forms - DevOps + +## System Components + +* DNS - hosted on GoDaddy +* API Server - hosted on the `ortis` ECS cluster at JDB Software +* API Loadbalancer - using the main load balancer for JDB Software +* Web App - Served by CloudFront from an S3 bucket managed by JDB Software +* Certificates - Manually created and validated for \*.HFF.com +* Notion Integration - defined in Notion, token provided via AWS secrets + manager to the API instance running on the ortis cluster. diff --git a/operations/terraform/common.tf b/operations/terraform/common.tf new file mode 100644 index 0000000..a85adb2 --- /dev/null +++ b/operations/terraform/common.tf @@ -0,0 +1,21 @@ +### Variables + +variable "aws_region" { + description = "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-regions-availability-zones.html" + default = "us-west-2" # Oregon +} + +variable "app_root_url" { + description = "Name of the S3 bucket to store deployed artifacts, logs, etc." + default = "forms.hopefamilyfellowship.com" +} + +variable "cloudfront_certificate_arn" { + description = "Name of the certificate to use for CloudFront distributions (must be in us-east-1)." + default = "arn:aws:acm:us-east-1:063932952339:certificate/8e4b4a05-d61e-49af-b7e9-8e59999f197a" +} + +variable "api_certificate_arn" { + description = "Name of the certificate to use for the API load balancer (must be in the same region as the loadbalancer)." + default = "arn:aws:acm:us-west-2:063932952339:certificate/04c33fd7-a6b0-4f58-8e8a-fddbe361aa85" +} diff --git a/operations/terraform/deployed_env/cloudfront.tf b/operations/terraform/deployed_env/cloudfront.tf new file mode 100644 index 0000000..e608b75 --- /dev/null +++ b/operations/terraform/deployed_env/cloudfront.tf @@ -0,0 +1,99 @@ +data "aws_iam_policy_document" "bucket_access_policy" { + statement { + actions = [ "s3:GetObject" ] + effect = "Allow" + resources = [ "${var.artifact_bucket.arn}/${var.environment}/webroot/*" ] + + principals { + type = "AWS" + identifiers = [ aws_cloudfront_origin_access_identity.origin_access_identity.iam_arn ] + } + } + + statement { + actions = [ "s3:ListBucket" ] + effect = "Allow" + resources = [ var.artifact_bucket.arn ] + + principals { + type = "AWS" + identifiers = [ aws_cloudfront_origin_access_identity.origin_access_identity.iam_arn ] + } + } +} + +output "oai_access_policy" { + value = data.aws_iam_policy_document.bucket_access_policy +} + +resource "aws_cloudfront_origin_access_identity" "origin_access_identity" { + comment = "OAI for HFF Entry Forms {$var.environment} environment." +} + +resource "aws_cloudfront_distribution" "s3_distribution" { + origin { + domain_name = var.artifact_bucket.bucket_regional_domain_name + origin_id = "S3-HffEntryForms-${var.environment}" + origin_path = "/${var.environment}/webroot" + + s3_origin_config { + origin_access_identity = aws_cloudfront_origin_access_identity.origin_access_identity.cloudfront_access_identity_path + } + } + + enabled = true + is_ipv6_enabled = true + comment = "HFF Entry Forms ${var.environment} distribution." + default_root_object = "/index.html" + + logging_config { + include_cookies = false + bucket = var.artifact_bucket.bucket_domain_name + prefix = "${var.environment}/logs/cloudfront" + } + + aliases = [local.app_domain_name] + + default_cache_behavior { + allowed_methods = ["GET", "HEAD", "OPTIONS"] + cached_methods = ["GET", "HEAD", "OPTIONS"] + target_origin_id = "S3-HffEntryForms-${var.environment}" + + forwarded_values { + query_string = false + + cookies { + forward = "none" + } + } + + min_ttl = 0 + default_ttl = 60 * 60 * 24 * 365 # cache for a year + max_ttl = 60 * 60 * 24 * 365 # cache for a year + compress = true + viewer_protocol_policy = "redirect-to-https" + } + + custom_error_response { + error_code = 404 + response_code = 200 + response_page_path = "/index.html" + } + + price_class = "PriceClass_100" # US and Canada only + + restrictions { + geo_restriction { + restriction_type = "none" + } + } + tags = { + Environment = local.environment_name + } + + viewer_certificate { + # TODO + acm_certificate_arn = var.cloudfront_certificate_arn + ssl_support_method = "sni-only" + } +} diff --git a/operations/terraform/deployed_env/ecs.tf b/operations/terraform/deployed_env/ecs.tf new file mode 100644 index 0000000..d31e6fc --- /dev/null +++ b/operations/terraform/deployed_env/ecs.tf @@ -0,0 +1,70 @@ +resource "aws_secretsmanager_secret" "hff_entry_forms_api" { + name = "${local.environment_name}-Config" + tags = { Environment = local.environment_name } +} + +resource "aws_ecs_task_definition" "hff_entry_forms_api" { + family = local.environment_name + network_mode = "bridge" + requires_compatibilities = ["EC2"] + execution_role_arn = aws_iam_role.ecs_task.arn + + # See https://docs.aws.amazon.com/AmazonECS/latest/APIReference/API_ContainerDefinition.html + container_definitions = jsonencode([ + { + name = local.environment_name + image = "${var.ecr_repo.repository_url}:${data.external.git_describe.result.version}" + cpu = 128 + memory = 128 + memoryReservation = 32 + environment = [ + { + name = "PORT" + value = "80" + } + ] + portMappings = [ + { + protocol = "tcp" + containerPort = 80 + } + ] + secrets = [ + { + name = "INTEGRATION_TOKEN" + description = "Connection string with user credentials." + valueFrom = "${aws_secretsmanager_secret.hff_entry_forms_api.arn}:integrationToken::" + }, + { + name = "KNOWN_ORIGINS" + description = "Connection string with user credentials." + valueFrom = "${aws_secretsmanager_secret.hff_entry_forms_api.arn}:knownOrigins::" + } + ] + } + ]) + + tags = { + Name = local.api_domain_name + Environment = local.environment_name + } +} + +resource "aws_ecs_service" "hff_entry_forms_api" { + name = local.environment_name + cluster = data.terraform_remote_state.jdbsoft.outputs.aws_ecs_cluster_ortis.id + task_definition = aws_ecs_task_definition.hff_entry_forms_api.arn + desired_count = 1 + launch_type = "EC2" + + load_balancer { + target_group_arn = aws_lb_target_group.hff_entry_forms_api.arn + container_name = local.environment_name + container_port = 80 + } + + tags = { + Name = local.api_domain_name + Environment = local.environment_name + } +} diff --git a/operations/terraform/deployed_env/iam.tf b/operations/terraform/deployed_env/iam.tf new file mode 100644 index 0000000..7dd245c --- /dev/null +++ b/operations/terraform/deployed_env/iam.tf @@ -0,0 +1,69 @@ +resource "aws_iam_role" "ecs_task" { + name = "${local.environment_name}-EcsTaskRole" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = "sts:AssumeRole" + Effect = "Allow" + Sid = "" + Principal = { + Service = "ecs-tasks.amazonaws.com" + } + } + ] + }) + + inline_policy { + name = "AllowSecretsAccessForHffEntryFormsApiTasks" + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "secretsmanager:GetSecretValue", + "kms:Decrypt" + ] + Resource = [ + aws_secretsmanager_secret.hff_entry_forms_api.arn + ] + } + ] + }) + } + + inline_policy { + name = "AllowAccessToEcrForHffEntryFormsApiTasks" + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "ecr:GetAuthorizationToken" + ] + Resource = [ "*" ] + }, + { + Effect = "Allow" + Action = [ + "ecr:BatchGetImage", + "ecr:BatchCheckLayerAvailability", + "ecr:DescribeImages", + "ecr:GetDownloadUrlForLayer" + ] + Resource = [ + var.ecr_repo.arn + ] + } + ] + }) + } + + tags = { + Name = "HffEntryForms-EcsTaskRole" + Environment = local.environment_name + } +} diff --git a/operations/terraform/deployed_env/load-balancer.tf b/operations/terraform/deployed_env/load-balancer.tf new file mode 100644 index 0000000..6aa4484 --- /dev/null +++ b/operations/terraform/deployed_env/load-balancer.tf @@ -0,0 +1,43 @@ +resource "aws_lb_target_group" "hff_entry_forms_api" { + name = "${local.environment_name}-${substr(uuid(), 0, 2)}" + port = 80 + protocol = "HTTP" + target_type = "instance" + vpc_id = data.terraform_remote_state.jdbsoft.outputs.aws_vpc_jdbsoft.id + + health_check { + enabled = true + matcher = "200" + path = "/v1/version" + } + + lifecycle { + create_before_destroy = true + ignore_changes = [name] + } + + tags = { + Name = local.api_domain_name + Environment = local.environment_name + } +} + +resource "aws_lb_listener_rule" "hff_entry_forms_api" { + listener_arn = data.terraform_remote_state.jdbsoft.outputs.aws_lb_listener_https.arn + + action { + type = "forward" + target_group_arn = aws_lb_target_group.hff_entry_forms_api.arn + } + + condition { + host_header { + values = [ local.api_domain_name ] + } + } + + tags = { + Name = "${local.api_domain_name} HTTPS" + Environment = local.environment_name + } +} diff --git a/operations/terraform/deployed_env/variables.tf b/operations/terraform/deployed_env/variables.tf new file mode 100644 index 0000000..75967a2 --- /dev/null +++ b/operations/terraform/deployed_env/variables.tf @@ -0,0 +1,42 @@ +### Variables + +variable "environment" { + description = "The short name of this deployed environment. For example: 'dev' or 'prod'. This short name will be used to name resources (CloudFront distributions, etc.)" +} + +variable "artifact_bucket" { + description = "The aws_s3_bucket object representing the artifact bucket where deployed artifacts, logs, etc. live." +} + +variable "ecr_repo" { + description = "ECR repository information." +} + +variable "api_certificate_arn" { + description = "ARN of the certificate to use for the API loadbalancer." +} + +variable "cloudfront_certificate_arn" { + description = "ARN of the certificate to use for CloudFront." +} + +locals { + environment_name = "HffEntryForms-${var.environment}" + app_domain_name = "forms${var.environment == "prod" ? "" : "-${var.environment}"}.hopefamilyfellowship.com" + api_domain_name = "forms-api${var.environment == "prod" ? "" : "-${var.environment}"}.hopefamilyfellowship.com" +} + +data "external" "git_describe" { + program = ["sh", "-c", "git describe | xargs printf '{\"version\": \"%s\"}'"] +} + +data "terraform_remote_state" "jdbsoft" { + backend = "s3" + + config = { + bucket = "operations.jdb-software.com" + region = "us-west-2" + key = "terraform/operations.tfstate" + dynamodb_table = "terraform-state-lock.jdb-software.com" + } +} diff --git a/operations/terraform/ecr.tf b/operations/terraform/ecr.tf new file mode 100644 index 0000000..95a95ec --- /dev/null +++ b/operations/terraform/ecr.tf @@ -0,0 +1,8 @@ +resource "aws_ecr_repository" "hff_entry_forms_api" { + name = "hff_entry_forms_api" + image_tag_mutability = "IMMUTABLE" + + image_scanning_configuration { + scan_on_push = true + } +} diff --git a/operations/terraform/main.tf b/operations/terraform/main.tf new file mode 100644 index 0000000..bb4f89e --- /dev/null +++ b/operations/terraform/main.tf @@ -0,0 +1,38 @@ +provider "aws" { + region = var.aws_region +} + +resource "aws_s3_bucket" "hff_entry_forms" { + bucket = var.app_root_url + acl = "log-delivery-write" +} + +module "dev_env" { + source = "./deployed_env" + + environment = "dev" + api_certificate_arn = var.api_certificate_arn + artifact_bucket = aws_s3_bucket.hff_entry_forms + cloudfront_certificate_arn = var.cloudfront_certificate_arn + ecr_repo = aws_ecr_repository.hff_entry_forms_api +} + +module "prod_env" { + source = "./deployed_env" + + environment = "prod" + api_certificate_arn = var.api_certificate_arn + artifact_bucket = aws_s3_bucket.hff_entry_forms + cloudfront_certificate_arn = var.cloudfront_certificate_arn + ecr_repo = aws_ecr_repository.hff_entry_forms_api +} + +data "aws_iam_policy_document" "cloudfront_access_policy" { + source_json = "${module.dev_env.oai_access_policy.json}" + override_json = "${module.prod_env.oai_access_policy.json}" +} + +resource "aws_s3_bucket_policy" "hff_entry_forms" { + bucket = aws_s3_bucket.hff_entry_forms.id + policy = data.aws_iam_policy_document.cloudfront_access_policy.json +} diff --git a/operations/terraform/terraform.tf b/operations/terraform/terraform.tf new file mode 100644 index 0000000..c968f4f --- /dev/null +++ b/operations/terraform/terraform.tf @@ -0,0 +1,8 @@ +terraform { + backend "s3" { + bucket = "forms.hopefamilyfellowship.com" + region = "us-west-2" + key = "terraform.tfstate" + dynamodb_table = "terraform-state-lock.jdb-software.com" + } +} diff --git a/operations/terraform/terraform.tfstate b/operations/terraform/terraform.tfstate new file mode 100644 index 0000000..e69de29 diff --git a/operations/terraform/terraform.tfstate.backup b/operations/terraform/terraform.tfstate.backup new file mode 100644 index 0000000..37a52c6 --- /dev/null +++ b/operations/terraform/terraform.tfstate.backup @@ -0,0 +1,88 @@ +{ + "version": 4, + "terraform_version": "0.13.1", + "serial": 3, + "lineage": "a0c8b19d-5dd4-8895-bb16-f9d47e764e93", + "outputs": {}, + "resources": [ + { + "mode": "managed", + "type": "aws_ecr_repository", + "name": "hff_entry_forms", + "provider": "provider[\"registry.terraform.io/hashicorp/aws\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "arn": "arn:aws:ecr:us-west-2:063932952339:repository/hff_entry_forms", + "encryption_configuration": [ + { + "encryption_type": "AES256", + "kms_key": "" + } + ], + "id": "hff_entry_forms", + "image_scanning_configuration": [ + { + "scan_on_push": true + } + ], + "image_tag_mutability": "IMMUTABLE", + "name": "hff_entry_forms", + "registry_id": "063932952339", + "repository_url": "063932952339.dkr.ecr.us-west-2.amazonaws.com/hff_entry_forms", + "tags": null, + "tags_all": {}, + "timeouts": null + }, + "private": "eyJlMmJmYjczMC1lY2FhLTExZTYtOGY4OC0zNDM2M2JjN2M0YzAiOnsiZGVsZXRlIjoxMjAwMDAwMDAwMDAwfX0=" + } + ] + }, + { + "mode": "managed", + "type": "aws_s3_bucket", + "name": "hff_entry_forms", + "provider": "provider[\"registry.terraform.io/hashicorp/aws\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "acceleration_status": "", + "acl": "log-delivery-write", + "arn": "arn:aws:s3:::forms.hopefamilyfellowship.com", + "bucket": "forms.hopefamilyfellowship.com", + "bucket_domain_name": "forms.hopefamilyfellowship.com.s3.amazonaws.com", + "bucket_prefix": null, + "bucket_regional_domain_name": "forms.hopefamilyfellowship.com.s3.us-west-2.amazonaws.com", + "cors_rule": [], + "force_destroy": false, + "grant": [], + "hosted_zone_id": "Z3BJ6K6RIION7M", + "id": "forms.hopefamilyfellowship.com", + "lifecycle_rule": [], + "logging": [], + "object_lock_configuration": [], + "policy": null, + "region": "us-west-2", + "replication_configuration": [], + "request_payer": "BucketOwner", + "server_side_encryption_configuration": [], + "tags": null, + "tags_all": {}, + "versioning": [ + { + "enabled": false, + "mfa_delete": false + } + ], + "website": [], + "website_domain": null, + "website_endpoint": null + }, + "private": "bnVsbA==" + } + ] + } + ] +}