diff --git a/.github/workflows/deploy_to_aws.yml b/.github/workflows/deploy_to_aws.yml index a4c791814..da97c709b 100644 --- a/.github/workflows/deploy_to_aws.yml +++ b/.github/workflows/deploy_to_aws.yml @@ -122,7 +122,7 @@ jobs: SETTINGS_BUCKET: oshub-settings-${{ steps.get_env_name.outputs.lowercase }} AWS_DEFAULT_REGION: "eu-west-1" - build_and_push_docker_image: + build_and_push_react_app: needs: apply runs-on: ubuntu-latest environment: ${{ inputs.deploy-env || (github.ref_name == 'main' && 'Development') || (startsWith(github.ref_name, 'releases/') && 'Pre-prod') || (startsWith(github.ref_name, 'production-') && 'Production') || (startsWith(github.ref_name, 'sandbox-') && 'Staging') || 'None' }} @@ -133,6 +133,7 @@ jobs: uses: Entepotenz/change-string-case-action-min-dependencies@v1 with: string: ${{ vars.ENV_NAME }} + - name: Checkout repo uses: actions/checkout@v4 @@ -156,8 +157,47 @@ jobs: working-directory: src/react run: yarn run build + - id: project + uses: Entepotenz/change-string-case-action-min-dependencies@v1 + with: + string: ${{ vars.PROJECT }} + - name: Move static - run: mv src/react/build src/django/static + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + FRONTEND_BUCKET: ${{ steps.project.outputs.lowercase }}-${{ steps.get_env_name.outputs.lowercase }}-frontend + AWS_DEFAULT_REGION: "eu-west-1" + CLOUDFRONT_DOMAIN: ${{ vars.CLOUDFRONT_DOMAIN }} + run: | + for id in $(aws cloudfront list-distributions --query "DistributionList.Items[*].Id" --output text); do + domains=$(aws cloudfront get-distribution-config --id $id --query "DistributionConfig.Aliases.Items" --output text) + if [[ "$domains" == "$CLOUDFRONT_DOMAIN" ]]; then + echo "Found Distribution ID: $id for Domain: $CLOUDFRONT_DOMAIN" + CLOUDFRONT_DISTRIBUTION_ID=$id + fi + done + if [ -z "$CLOUDFRONT_DISTRIBUTION_ID" ]; then + echo "Error: No CloudFront distribution found for domain: $CLOUDFRONT_DOMAIN" + exit 1 + fi + aws s3 sync src/react/build/ s3://$FRONTEND_BUCKET-$AWS_DEFAULT_REGION/ --delete + aws cloudfront create-invalidation --distribution-id $CLOUDFRONT_DISTRIBUTION_ID --paths "/*" + + build_and_push_docker_image: + needs: build_and_push_react_app + runs-on: ubuntu-latest + environment: ${{ inputs.deploy-env || (github.ref_name == 'main' && 'Development') || (startsWith(github.ref_name, 'releases/') && 'Pre-prod') || (startsWith(github.ref_name, 'production-') && 'Production') || (startsWith(github.ref_name, 'sandbox-') && 'Staging') || 'None' }} + if: ${{ inputs.deploy-plan-only == false }} + steps: + - name: Get Environment Name for ${{ vars.ENV_NAME }} + id: get_env_name + uses: Entepotenz/change-string-case-action-min-dependencies@v1 + with: + string: ${{ vars.ENV_NAME }} + + - name: Checkout repo + uses: actions/checkout@v4 - name: Configure AWS credentials for ${{ vars.ENV_NAME }} uses: aws-actions/configure-aws-credentials@v1 diff --git a/deployment/terraform/cdn.tf b/deployment/terraform/cdn.tf index a047e4736..5e5f395e8 100644 --- a/deployment/terraform/cdn.tf +++ b/deployment/terraform/cdn.tf @@ -1,7 +1,101 @@ +locals { + frontend_bucket_name = "${lower(replace(var.project, " ", ""))}-${lower(var.environment)}-frontend-${var.aws_region}" +} + +resource "aws_s3_bucket" "react" { + bucket = local.frontend_bucket_name + force_destroy = true + + tags = { + Name = local.frontend_bucket_name + } +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "react" { + bucket = aws_s3_bucket.react.id + + rule { + bucket_key_enabled = false + + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + } +} + +resource "aws_s3_bucket_versioning" "react" { + bucket = aws_s3_bucket.react.id + + versioning_configuration { + status = "Disabled" + } +} + +resource "aws_s3_bucket_ownership_controls" "react" { + bucket = aws_s3_bucket.react.id + + rule { + object_ownership = "BucketOwnerEnforced" + } +} + +data "aws_iam_policy_document" "react" { + statement { + sid = "denyInsecureTransport" + effect = "Deny" + + actions = [ + "s3:*", + ] + + resources = [ + aws_s3_bucket.react.arn, + "${aws_s3_bucket.react.arn}/*", + ] + + principals { + type = "*" + identifiers = ["*"] + } + + condition { + test = "Bool" + variable = "aws:SecureTransport" + values = [ + "false" + ] + } + } + + statement { + sid = "CloudFront" + principals { + identifiers = [ + aws_cloudfront_origin_access_identity.react.iam_arn + ] + type = "AWS" + } + actions = ["s3:GetObject"] + resources = ["${aws_s3_bucket.react.arn}/*"] + } +} + +resource "aws_s3_bucket_policy" "react" { + bucket = aws_s3_bucket.react.id + policy = data.aws_iam_policy_document.react.json +} + +resource "aws_cloudfront_origin_access_identity" "react" { + comment = local.frontend_bucket_name +} + resource "aws_cloudfront_distribution" "cdn" { depends_on = [ aws_s3_bucket.logs ] + + default_root_object = "index.html" + origin { domain_name = "origin.${local.domain_name}" origin_id = "originAlb" @@ -19,6 +113,20 @@ resource "aws_cloudfront_distribution" "cdn" { } } + origin { + domain_name = aws_s3_bucket.react.bucket_regional_domain_name + origin_id = "originS3" + + s3_origin_config { + origin_access_identity = aws_cloudfront_origin_access_identity.react.cloudfront_access_identity_path + } + + custom_header { + name = "X-CloudFront-Auth" + value = var.cloudfront_auth_token + } + } + enabled = true is_ipv6_enabled = true http_version = "http2" @@ -28,20 +136,63 @@ resource "aws_cloudfront_distribution" "cdn" { aliases = [local.domain_name] default_cache_behavior { + allowed_methods = ["GET", "HEAD"] + cached_methods = ["GET", "HEAD"] + target_origin_id = "originS3" + + forwarded_values { + query_string = true + headers = [] + + cookies { + forward = "all" + } + } + + compress = false + viewer_protocol_policy = "redirect-to-https" + min_ttl = 0 + default_ttl = 0 + max_ttl = 300 + } + + ordered_cache_behavior { + path_pattern = "tile/*" + allowed_methods = ["GET", "HEAD", "OPTIONS"] + cached_methods = ["GET", "HEAD", "OPTIONS"] + target_origin_id = "originAlb" + + forwarded_values { + query_string = true + headers = ["Referer"] # To discourage hotlinking to cached tiles + + cookies { + forward = "none" + } + } + compress = true + viewer_protocol_policy = "redirect-to-https" + min_ttl = 0 + default_ttl = 3600 + max_ttl = 31536000 # 1 year. Same as TILE_CACHE_MAX_AGE_IN_SECONDS in src/django/oar/settings.py + } + + ordered_cache_behavior { + path_pattern = "api/*" allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"] cached_methods = ["GET", "HEAD", "OPTIONS"] target_origin_id = "originAlb" forwarded_values { query_string = true - headers = ["*"] + headers = ["*"] # To discourage hotlinking to cached tiles cookies { forward = "all" } } - compress = false + compress = true viewer_protocol_policy = "redirect-to-https" min_ttl = 0 default_ttl = 0 @@ -49,46 +200,443 @@ resource "aws_cloudfront_distribution" "cdn" { } ordered_cache_behavior { - path_pattern = "static/*" - allowed_methods = ["GET", "HEAD", "OPTIONS"] + path_pattern = "/api-auth/*" + allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"] cached_methods = ["GET", "HEAD", "OPTIONS"] target_origin_id = "originAlb" forwarded_values { - query_string = false + query_string = true + headers = ["*"] # To discourage hotlinking to cached tiles cookies { - forward = "none" + forward = "all" } } compress = true viewer_protocol_policy = "redirect-to-https" min_ttl = 0 - default_ttl = 300 + default_ttl = 0 max_ttl = 300 } ordered_cache_behavior { - path_pattern = "tile/*" - allowed_methods = ["GET", "HEAD", "OPTIONS"] + path_pattern = "/api-token-auth/*" + allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"] cached_methods = ["GET", "HEAD", "OPTIONS"] target_origin_id = "originAlb" forwarded_values { query_string = true - headers = ["Referer"] # To discourage hotlinking to cached tiles + headers = ["*"] # To discourage hotlinking to cached tiles cookies { - forward = "none" + forward = "all" } } compress = true viewer_protocol_policy = "redirect-to-https" min_ttl = 0 - default_ttl = 3600 - max_ttl = 31536000 # 1 year. Same as TILE_CACHE_MAX_AGE_IN_SECONDS in src/django/oar/settings.py + default_ttl = 0 + max_ttl = 300 + } + + ordered_cache_behavior { + path_pattern = "/api-feature-flags/*" + allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"] + cached_methods = ["GET", "HEAD", "OPTIONS"] + target_origin_id = "originAlb" + + forwarded_values { + query_string = true + headers = ["*"] # To discourage hotlinking to cached tiles + + cookies { + forward = "all" + } + } + + compress = true + viewer_protocol_policy = "redirect-to-https" + min_ttl = 0 + default_ttl = 0 + max_ttl = 300 + } + + ordered_cache_behavior { + path_pattern = "/web/environment.js" + allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"] + cached_methods = ["GET", "HEAD", "OPTIONS"] + target_origin_id = "originAlb" + + forwarded_values { + query_string = true + headers = ["*"] # To discourage hotlinking to cached tiles + + cookies { + forward = "all" + } + } + + compress = true + viewer_protocol_policy = "redirect-to-https" + min_ttl = 0 + default_ttl = 0 + max_ttl = 300 + } + + ordered_cache_behavior { + path_pattern = "/admin/*" + allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"] + cached_methods = ["GET", "HEAD", "OPTIONS"] + target_origin_id = "originAlb" + + forwarded_values { + query_string = true + headers = ["*"] # To discourage hotlinking to cached tiles + + cookies { + forward = "all" + } + } + + compress = true + viewer_protocol_policy = "redirect-to-https" + min_ttl = 0 + default_ttl = 0 + max_ttl = 300 + } + + ordered_cache_behavior { + path_pattern = "/health-check/*" + allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"] + cached_methods = ["GET", "HEAD", "OPTIONS"] + target_origin_id = "originAlb" + + forwarded_values { + query_string = true + headers = ["*"] # To discourage hotlinking to cached tiles + + cookies { + forward = "all" + } + } + + compress = true + viewer_protocol_policy = "redirect-to-https" + min_ttl = 0 + default_ttl = 0 + max_ttl = 300 + } + + ordered_cache_behavior { + path_pattern = "/rest-auth/*" + allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"] + cached_methods = ["GET", "HEAD", "OPTIONS"] + target_origin_id = "originAlb" + + forwarded_values { + query_string = true + headers = ["*"] # To discourage hotlinking to cached tiles + + cookies { + forward = "all" + } + } + + compress = true + viewer_protocol_policy = "redirect-to-https" + min_ttl = 0 + default_ttl = 0 + max_ttl = 300 + } + + ordered_cache_behavior { + path_pattern = "/user-login/*" + allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"] + cached_methods = ["GET", "HEAD", "OPTIONS"] + target_origin_id = "originAlb" + + forwarded_values { + query_string = true + headers = ["*"] # To discourage hotlinking to cached tiles + + cookies { + forward = "all" + } + } + + compress = true + viewer_protocol_policy = "redirect-to-https" + min_ttl = 0 + default_ttl = 0 + max_ttl = 300 + } + + ordered_cache_behavior { + path_pattern = "/user-logout/*" + allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"] + cached_methods = ["GET", "HEAD", "OPTIONS"] + target_origin_id = "originAlb" + + forwarded_values { + query_string = true + headers = ["*"] # To discourage hotlinking to cached tiles + + cookies { + forward = "all" + } + } + + compress = true + viewer_protocol_policy = "redirect-to-https" + min_ttl = 0 + default_ttl = 0 + max_ttl = 300 + } + + ordered_cache_behavior { + path_pattern = "/user-signup/*" + allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"] + cached_methods = ["GET", "HEAD", "OPTIONS"] + target_origin_id = "originAlb" + + forwarded_values { + query_string = true + headers = ["*"] # To discourage hotlinking to cached tiles + + cookies { + forward = "all" + } + } + + compress = true + viewer_protocol_policy = "redirect-to-https" + min_ttl = 0 + default_ttl = 0 + max_ttl = 300 + } + + ordered_cache_behavior { + path_pattern = "/user-profile/*" + allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"] + cached_methods = ["GET", "HEAD", "OPTIONS"] + target_origin_id = "originAlb" + + forwarded_values { + query_string = true + headers = ["*"] # To discourage hotlinking to cached tiles + + cookies { + forward = "all" + } + } + + compress = true + viewer_protocol_policy = "redirect-to-https" + min_ttl = 0 + default_ttl = 0 + max_ttl = 300 + } + + ordered_cache_behavior { + path_pattern = "/user-api-info/*" + allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"] + cached_methods = ["GET", "HEAD", "OPTIONS"] + target_origin_id = "originAlb" + + forwarded_values { + query_string = true + headers = ["*"] # To discourage hotlinking to cached tiles + + cookies { + forward = "all" + } + } + + compress = true + viewer_protocol_policy = "redirect-to-https" + min_ttl = 0 + default_ttl = 0 + max_ttl = 300 + } + + ordered_cache_behavior { + path_pattern = "/admin" + allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"] + cached_methods = ["GET", "HEAD", "OPTIONS"] + target_origin_id = "originAlb" + + forwarded_values { + query_string = true + headers = ["*"] # To discourage hotlinking to cached tiles + + cookies { + forward = "all" + } + } + + compress = true + viewer_protocol_policy = "redirect-to-https" + min_ttl = 0 + default_ttl = 0 + max_ttl = 300 + } + + ordered_cache_behavior { + path_pattern = "/static/admin/*" + allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"] + cached_methods = ["GET", "HEAD", "OPTIONS"] + target_origin_id = "originAlb" + + forwarded_values { + query_string = true + headers = ["*"] # To discourage hotlinking to cached tiles + + cookies { + forward = "all" + } + } + + compress = true + viewer_protocol_policy = "redirect-to-https" + min_ttl = 0 + default_ttl = 0 + max_ttl = 300 + } + + ordered_cache_behavior { + path_pattern = "/static/django_extensions/*" + allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"] + cached_methods = ["GET", "HEAD", "OPTIONS"] + target_origin_id = "originAlb" + + forwarded_values { + query_string = true + headers = ["*"] # To discourage hotlinking to cached tiles + + cookies { + forward = "all" + } + } + + compress = true + viewer_protocol_policy = "redirect-to-https" + min_ttl = 0 + default_ttl = 0 + max_ttl = 300 + } + + ordered_cache_behavior { + path_pattern = "/static/drf-yasg/*" + allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"] + cached_methods = ["GET", "HEAD", "OPTIONS"] + target_origin_id = "originAlb" + + forwarded_values { + query_string = true + headers = ["*"] # To discourage hotlinking to cached tiles + + cookies { + forward = "all" + } + } + + compress = true + viewer_protocol_policy = "redirect-to-https" + min_ttl = 0 + default_ttl = 0 + max_ttl = 300 + } + + ordered_cache_behavior { + path_pattern = "/static/gis/*" + allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"] + cached_methods = ["GET", "HEAD", "OPTIONS"] + target_origin_id = "originAlb" + + forwarded_values { + query_string = true + headers = ["*"] # To discourage hotlinking to cached tiles + + cookies { + forward = "all" + } + } + + compress = true + viewer_protocol_policy = "redirect-to-https" + min_ttl = 0 + default_ttl = 0 + max_ttl = 300 + } + + ordered_cache_behavior { + path_pattern = "/static/rest_framework/*" + allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"] + cached_methods = ["GET", "HEAD", "OPTIONS"] + target_origin_id = "originAlb" + + forwarded_values { + query_string = true + headers = ["*"] # To discourage hotlinking to cached tiles + + cookies { + forward = "all" + } + } + + compress = true + viewer_protocol_policy = "redirect-to-https" + min_ttl = 0 + default_ttl = 0 + max_ttl = 300 + } + + ordered_cache_behavior { + path_pattern = "/static/static/*" + allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"] + cached_methods = ["GET", "HEAD", "OPTIONS"] + target_origin_id = "originAlb" + + forwarded_values { + query_string = true + headers = ["*"] # To discourage hotlinking to cached tiles + + cookies { + forward = "all" + } + } + + compress = true + viewer_protocol_policy = "redirect-to-https" + min_ttl = 0 + default_ttl = 0 + max_ttl = 300 + } + + ordered_cache_behavior { + path_pattern = "/static/staticfiles.json" + allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"] + cached_methods = ["GET", "HEAD", "OPTIONS"] + target_origin_id = "originAlb" + + forwarded_values { + query_string = true + headers = ["*"] # To discourage hotlinking to cached tiles + + cookies { + forward = "all" + } + } + + compress = true + viewer_protocol_policy = "redirect-to-https" + min_ttl = 0 + default_ttl = 0 + max_ttl = 300 } logging_config { @@ -109,9 +657,22 @@ resource "aws_cloudfront_distribution" "cdn" { ssl_support_method = "sni-only" } + custom_error_response { + error_code = 403 + error_caching_min_ttl = 10 + response_code = 200 + response_page_path = "/index.html" + } + + custom_error_response { + error_code = 404 + error_caching_min_ttl = 10 + response_code = 200 + response_page_path = "/index.html" + } + tags = { Project = var.project Environment = var.environment } } - diff --git a/doc/release/RELEASE-NOTES.md b/doc/release/RELEASE-NOTES.md index f648f3d00..3fbc2fa57 100644 --- a/doc/release/RELEASE-NOTES.md +++ b/doc/release/RELEASE-NOTES.md @@ -3,6 +3,63 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). The format is based on the `RELEASE-NOTES-TEMPLATE.md` file. +## Release 2.0.0 + +## Introduction +* Product name: Open Supply Hub +* Release date: February 22, 2025 + +### Database changes +* *Describe high-level database changes.* + +#### Migrations: +* *Describe migrations here.* + +#### Schema changes +* *Describe schema changes here.* + +### Code/API changes +* *Describe code/API changes here.* + +### Architecture/Environment changes +* [OSDEV-899](https://opensupplyhub.atlassian.net/browse/OSDEV-899) - With this task, we split the Django container into two components: FE (React) and BE (Django). Requests to the frontend (React) will be processed by the CDN (CloudFront), while requests to the API will be redirected to the Django container. This approach will allow for more efficient use of ECS cluster computing resources and improve frontend performance. + + The following endpoints will be redirected to the Django container: + * tile/* + * api/* + * /api-auth/* + * /api-token-auth/* + * /api-feature-flags/* + * /web/environment.js + * /admin/* + * /health-check/* + * /rest-auth/* + * /user-login/* + * /user-logout/* + * /user-signup/* + * /user-profile/* + * /user-api-info/* + * /admin + * /static/admin/* + * /static/django_extensions/* + * /static/drf-yasg/* + * /static/gis/* + * /static/rest_framework/* + * /static/static/* + * /static/staticfiles.json + + All other traffic will be redirected to the React application. + +### Bugfix +* *Describe bugfix here.* + +### What's new +* *Describe what's new here. The changes that can impact user experience should be listed in this section.* + +### Release instructions: +* *Provide release instructions here.* + + ## Release 1.29.0 ## Introduction