diff --git a/.github/workflows/deploy-main-to-staging-from-tagged-prs.yml b/.github/workflows/deploy-main-to-staging-from-tagged-prs.yml new file mode 100644 index 0000000000..7c9d39c142 --- /dev/null +++ b/.github/workflows/deploy-main-to-staging-from-tagged-prs.yml @@ -0,0 +1,79 @@ +name: Deploy Main to Staging From Tagged PRs +on: + pull_request: + branches: + - main +jobs: + build: + if: contains(github.event.pull_request.labels.*.name, 'deploy-staging') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.CI_CD_AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.CI_CD_AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ secrets.AWS_DEFAULT_REGION }} + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v2 + - name: Get the SHA of the current branch/fork + shell: bash + run: | + echo "SHORT_SHA=$(git rev-parse --short HEAD)" >> "$GITHUB_ENV" + - name: Build and Push Sidekiq image + uses: docker/build-push-action@v6 + with: + push: true + target: sidekiq + tags: | + ${{ steps.login-ecr.outputs.registry }}/wca-on-rails:sidekiq-staging + cache-from: type=gha + cache-to: type=gha,mode=max + build-args: | + BUILD_TAG=${{ env.SHORT_SHA }} + WCA_LIVE_SITE=false + SHAKAPACKER_ASSET_HOST=https://assets-staging.worldcubeassociation.org + - name: Build and push Staging API Image + uses: docker/build-push-action@v6 + with: + push: true + target: monolith-api + tags: | + ${{ steps.login-ecr.outputs.registry }}/wca-on-rails:staging-api + cache-from: type=gha + cache-to: type=gha,mode=max + build-args: | + BUILD_TAG=${{ env.SHORT_SHA }} + WCA_LIVE_SITE=false + SHAKAPACKER_ASSET_HOST=https://assets-staging.worldcubeassociation.org + - name: Build and push Staging Image + id: build-staging + uses: docker/build-push-action@v6 + with: + push: true + load: true + target: monolith + tags: | + ${{ steps.login-ecr.outputs.registry }}/wca-on-rails:staging + cache-from: type=gha + cache-to: type=gha,mode=max + build-args: | + BUILD_TAG=${{ env.SHORT_SHA }} + WCA_LIVE_SITE=false + SHAKAPACKER_ASSET_HOST=https://assets-staging.worldcubeassociation.org + + # We build assets in docker and copy it out so we don't have to install all the dependencies in the GH Action + - name: Copy assets out of the container and push to S3 + run: | + id=$(docker create ${{steps.build-staging.outputs.imageid }}) + docker cp $id:/rails/public/ ./assets + aws s3 sync ./assets s3://assets-staging.worldcubeassociation.org/assets/${{ env.SHORT_SHA }} + # There is no pipeline for staging so we manually force to update the image + - name: Deploy staging + run: | + aws ecs update-service --cluster wca-on-rails --service wca-on-rails-staging --force-new-deployment + aws ecs update-service --cluster wca-on-rails --service wca-on-rails-staging-API --force-new-deployment diff --git a/.github/workflows/deploy-main-to-staging.yml b/.github/workflows/deploy-main-to-staging.yml index 89e5d31cbc..02fa123889 100644 --- a/.github/workflows/deploy-main-to-staging.yml +++ b/.github/workflows/deploy-main-to-staging.yml @@ -34,6 +34,19 @@ jobs: BUILD_TAG=${{ env.SHORT_SHA }} WCA_LIVE_SITE=false SHAKAPACKER_ASSET_HOST=https://assets-staging.worldcubeassociation.org + - name: Build and push Staging API Image + uses: docker/build-push-action@v6 + with: + push: true + target: monolith-api + tags: | + ${{ steps.login-ecr.outputs.registry }}/wca-on-rails:staging-api + cache-from: type=gha + cache-to: type=gha,mode=max + build-args: | + BUILD_TAG=${{ env.SHORT_SHA }} + WCA_LIVE_SITE=false + SHAKAPACKER_ASSET_HOST=https://assets-staging.worldcubeassociation.org - name: Build and push Staging Image id: build-staging uses: docker/build-push-action@v6 @@ -49,6 +62,7 @@ jobs: BUILD_TAG=${{ env.SHORT_SHA }} WCA_LIVE_SITE=false SHAKAPACKER_ASSET_HOST=https://assets-staging.worldcubeassociation.org + # We build assets in docker and copy it out so we don't have to install all the dependencies in the GH Action - name: Copy assets out of the container and push to S3 run: | @@ -59,3 +73,4 @@ jobs: - name: Deploy staging run: | aws ecs update-service --cluster wca-on-rails --service wca-on-rails-staging --force-new-deployment + aws ecs update-service --cluster wca-on-rails --service wca-on-rails-staging-API --force-new-deployment diff --git a/.github/workflows/staging-deployment-from-prs.yml b/.github/workflows/staging-deployment-from-prs.yml index a8f5ad09c3..9b6210e326 100644 --- a/.github/workflows/staging-deployment-from-prs.yml +++ b/.github/workflows/staging-deployment-from-prs.yml @@ -59,6 +59,20 @@ jobs: BUILD_TAG=${{ env.SHORT_SHA }} WCA_LIVE_SITE=false SHAKAPACKER_ASSET_HOST=https://assets-staging.worldcubeassociation.org + - name: Build and push Staging API Image if triggered + if: steps.trigger-deployment.outputs.triggered == 'true' + uses: docker/build-push-action@v6 + with: + push: true + target: monolith-api + tags: | + ${{ steps.login-ecr.outputs.registry }}/wca-on-rails:staging-api + cache-from: type=gha + cache-to: type=gha,mode=max + build-args: | + BUILD_TAG=${{ env.SHORT_SHA }} + WCA_LIVE_SITE=false + SHAKAPACKER_ASSET_HOST=https://assets-staging.worldcubeassociation.org - name: Build Rails if triggered if: steps.trigger-deployment.outputs.triggered == 'true' id: build-staging @@ -87,3 +101,4 @@ jobs: if: steps.trigger-deployment.outputs.triggered == 'true' run: | aws ecs update-service --cluster wca-on-rails --service wca-on-rails-staging --force-new-deployment + aws ecs update-service --cluster wca-on-rails --service wca-on-rails-staging-API --force-new-deployment diff --git a/Dockerfile b/Dockerfile index 28cf6620fe..6330e5f267 100644 --- a/Dockerfile +++ b/Dockerfile @@ -111,7 +111,15 @@ RUN fc-cache -f -v # Entrypoint prepares database and starts app on 0.0.0.0:3000 by default, # but can also take a rails command, like "console" or "runner" to start instead. -ENV PIDFILE "/rails/pids/puma.pid" +ENV PIDFILE="/rails/pids/puma.pid" ENTRYPOINT ["/rails/bin/docker-entrypoint"] CMD ["./bin/rails", "server"] + +FROM runtime AS monolith-api + +EXPOSE 3000 + +USER rails:rails +ENV API_ONLY="true" +CMD ["./bin/rails", "server"] diff --git a/app/controllers/api/v0/api_controller.rb b/app/controllers/api/v0/api_controller.rb index 9e426a3ad9..e75b2ba54b 100644 --- a/app/controllers/api/v0/api_controller.rb +++ b/app/controllers/api/v0/api_controller.rb @@ -19,6 +19,10 @@ def me render json: { me: current_api_user }, private_attributes: doorkeeper_token.scopes end + def healthcheck + render json: { status: "ok", api_instance: EnvConfig.API_ONLY? } + end + def auth_results if !current_user return render status: :unauthorized, json: { error: "Please log in" } @@ -132,9 +136,6 @@ def scramble_program } end - def help - end - def search(*models) query = params[:q]&.slice(0...SearchResultsController::SEARCH_QUERY_LIMIT) diff --git a/app/controllers/api_errors_controller.rb b/app/controllers/api_errors_controller.rb new file mode 100644 index 0000000000..53c6cf17e3 --- /dev/null +++ b/app/controllers/api_errors_controller.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class ApiErrorsController < ApplicationController + skip_before_action :set_locale + skip_before_action :store_user_location! + def show + exception = request.env["action_dispatch.exception"] + status_code = ActionDispatch::ExceptionWrapper.new(request.env, exception).status_code + request_id = request.env["action_dispatch.request_id"] + render json: { error_code: status_code, request_id: request_id, contact_url: Rails.application.routes.url_helpers.contact_url(contactRecipient: 'wst', requestId: request_id) }, status: status_code + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index cf3cbc1d3d..c17f629e7a 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -8,7 +8,7 @@ class ApplicationController < ActionController::Base protect_from_forgery with: :exception - prepend_before_action :set_locale + prepend_before_action :set_locale, unless: :is_api_request? before_action :store_user_location!, if: :storable_location? before_action :add_new_relic_headers protected def add_new_relic_headers diff --git a/app/controllers/static_pages_controller.rb b/app/controllers/static_pages_controller.rb index 601214513a..77c8dc9b40 100644 --- a/app/controllers/static_pages_controller.rb +++ b/app/controllers/static_pages_controller.rb @@ -19,6 +19,9 @@ def score_tools def logo end + def api_help + end + def robots respond_to :txt end diff --git a/app/views/api/v0/api/help.html.erb b/app/views/static_pages/api_help.html.erb similarity index 100% rename from app/views/api/v0/api/help.html.erb rename to app/views/static_pages/api_help.html.erb diff --git a/config/application.rb b/config/application.rb index fdd71a8986..a3ec26828a 100644 --- a/config/application.rb +++ b/config/application.rb @@ -64,7 +64,11 @@ class Application < Rails::Application end config.default_from_address = "notifications@worldcubeassociation.org" - config.site_name = "World Cube Association" + config.site_name = if EnvConfig.API_ONLY? + "World Cube Association API" + else + "World Cube Association" + end config.middleware.insert_before 0, Rack::Cors, debug: false, logger: (-> { Rails.logger }) do allow do diff --git a/config/environments/production.rb b/config/environments/production.rb index 7d984b138e..58b06cc768 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -138,8 +138,13 @@ # Error pages for production config.exceptions_app = ->(env) { - ErrorsController.action(:show).call(env) + if EnvConfig.API_ONLY? + ApiErrorsController.action(:show).call(env) + else + ErrorsController.action(:show).call(env) + end } + # Inserts middleware to perform automatic connection switching. # The `database_selector` hash is used to pass options to the DatabaseSelector # middleware. The `delay` is used to determine how long to wait after a write diff --git a/config/routes.rb b/config/routes.rb index b5a64f119b..eb53ca06c0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -202,6 +202,8 @@ get 'robots' => 'static_pages#robots' + get 'help/api' => 'static_pages#api_help' + get 'server-status' => 'server_status#index' get 'translations', to: redirect('translations/status', status: 302) @@ -320,7 +322,7 @@ end namespace :api do - get '/', to: redirect('/api/v0', status: 302) + get '/', to: redirect('/help/api', status: 302) namespace :internal do namespace :v1 do get '/users/:id/permissions' => 'permissions#index' @@ -330,8 +332,9 @@ end end namespace :v0 do - get '/' => 'api#help' + get '/', to: redirect('/help/api', status: 302) get '/me' => 'api#me' + get '/healthcheck' => 'api#healthcheck' get '/auth/results' => 'api#auth_results' get '/export/public' => 'api#export_public' get '/scramble-program' => 'api#scramble_program' diff --git a/env_config.rb b/env_config.rb index f01ee13fa1..d5c72d486e 100644 --- a/env_config.rb +++ b/env_config.rb @@ -91,6 +91,9 @@ # For Asset Compilation optional :ASSETS_COMPILATION, :bool, false + + # For API Only Server + optional :API_ONLY, :bool, false end # Require Asset Specific ENV variables diff --git a/infra/wca_on_rails/shared/elb.tf b/infra/wca_on_rails/shared/elb.tf index f3b3c2922d..9e443bc0f2 100644 --- a/infra/wca_on_rails/shared/elb.tf +++ b/infra/wca_on_rails/shared/elb.tf @@ -152,6 +152,30 @@ resource "aws_lb_target_group" "rails-staging" { } } +resource "aws_lb_target_group" "rails-staging-api" { + name = "wca-rails-staging-api" + port = 3000 + protocol = "HTTP" + vpc_id = aws_default_vpc.default.id + target_type = "ip" + + deregistration_delay = 10 + health_check { + interval = 5 + path = "/api/v0/healthcheck" + port = "traffic-port" + protocol = "HTTP" + timeout = 2 + healthy_threshold = 2 + unhealthy_threshold = 5 + matcher = 200 + } + tags = { + Name = var.name_prefix + Env = "staging" + } +} + resource "aws_lb_target_group" "mailcatcher-staging" { name = "wca-staging-mailcatcher" port = 1080 @@ -300,7 +324,7 @@ resource "aws_lb_listener_rule" "pma_forward_prod" { resource "aws_lb_listener_rule" "rails_forward_staging" { listener_arn = aws_lb_listener.https.arn - priority = 2 + priority = 4 action { type = "forward" @@ -314,6 +338,28 @@ resource "aws_lb_listener_rule" "rails_forward_staging" { } } +resource "aws_lb_listener_rule" "rails_forward_staging_api" { + listener_arn = aws_lb_listener.https.arn + priority = 2 + + action { + type = "forward" + target_group_arn = aws_lb_target_group.rails-staging-api.arn + } + + condition { + host_header { + values = ["staging.worldcubeassociation.org"] + } + } + + condition { + path_pattern { + values = ["/api*"] + } + } +} + resource "aws_lb_listener_rule" "pma_forward_staging" { listener_arn = aws_lb_listener.https.arn priority = 1 @@ -374,6 +420,10 @@ output "rails_staging"{ value = aws_lb_target_group.rails-staging } +output "rails_staging-api"{ + value = aws_lb_target_group.rails-staging-api +} + output "pma_production"{ value = aws_lb_target_group.auxiliary } diff --git a/infra/wca_on_rails/staging/main.tf b/infra/wca_on_rails/staging/main.tf index 8689842075..692bc3a319 100644 --- a/infra/wca_on_rails/staging/main.tf +++ b/infra/wca_on_rails/staging/main.tf @@ -187,6 +187,59 @@ resource "aws_iam_role_policy" "task_policy" { policy = data.aws_iam_policy_document.task_policy.json } +resource "aws_ecs_task_definition" "api" { + family = "${var.name_prefix}-api" + + network_mode = "awsvpc" + requires_compatibilities = ["EC2"] + + # We configure the roles to allow `aws ecs execute-command` into a task, + # as in https://aws.amazon.com/blogs/containers/new-using-amazon-ecs-exec-access-your-containers-fargate-ec2 + execution_role_arn = aws_iam_role.task_execution_role.arn + task_role_arn = aws_iam_role.task_role.arn + + # This is what our current staging instance is using + cpu = "512" + memory = "2048" + + container_definitions = jsonencode([ + { + name = "rails-staging-api" + image = "${var.shared.ecr_repository.repository_url}:staging-api" + cpu = 512 + memory = 2048 + portMappings = [ + { + # The hostPort is automatically set for awsvpc network mode, + # see https://docs.aws.amazon.com/AmazonECS/latest/APIReference/API_PortMapping.html#ECS-Type-PortMapping-hostPort + containerPort = 3000 + protocol = "tcp" + }, + ] + logConfiguration = { + logDriver = "awslogs" + options = { + awslogs-group = aws_cloudwatch_log_group.this.name + awslogs-region = var.region + awslogs-stream-prefix = var.name_prefix + } + } + environment = local.rails_environment + healthCheck = { + command = ["CMD-SHELL", "curl -f http://localhost:3000/api/v0/healthcheck || exit 1"] + interval = 10 + retries = 3 + startPeriod = 60 + timeout = 5 + } + } + ]) + + tags = { + Name = var.name_prefix + } +} + resource "aws_ecs_task_definition" "this" { family = var.name_prefix @@ -198,16 +251,16 @@ resource "aws_ecs_task_definition" "this" { execution_role_arn = aws_iam_role.task_execution_role.arn task_role_arn = aws_iam_role.task_role.arn - # This is what our current staging instance is using - cpu = "2048" - memory = "7861" + # This is shared with the API Server + cpu = "1536" + memory = "5813" container_definitions = jsonencode([ { name = "rails-staging" image = "${var.shared.ecr_repository.repository_url}:staging" - cpu = 1536 - memory = 5500 + cpu = 1024 + memory = 4901 portMappings = [ { # The hostPort is automatically set for awsvpc network mode, @@ -227,9 +280,9 @@ resource "aws_ecs_task_definition" "this" { environment = local.rails_environment healthCheck = { command = ["CMD-SHELL", "curl -f http://localhost:3000/ || exit 1"] - interval = 30 + interval = 10 retries = 3 - startPeriod = 300 + startPeriod = 60 timeout = 5 } }, @@ -237,7 +290,7 @@ resource "aws_ecs_task_definition" "this" { name = "sidekiq-staging" image = "${var.shared.ecr_repository.repository_url}:sidekiq-staging" cpu = 256 - memory = 1849 + memory = 512 portMappings = [{ # Mailcatcher containerPort = 1080 @@ -264,7 +317,7 @@ resource "aws_ecs_task_definition" "this" { name = "pma-staging" image = "${var.shared.ecr_repository.repository_url}:pma" cpu = 256 - memory = 512 + memory = 400 portMappings = [{ # The hostPort is automatically set for awsvpc network mode, # see https://docs.aws.amazon.com/AmazonECS/latest/APIReference/API_PortMapping.html#ECS-Type-PortMapping-hostPort @@ -301,6 +354,10 @@ data "aws_ecs_task_definition" "this" { task_definition = aws_ecs_task_definition.this.family } +data "aws_ecs_task_definition" "api" { + task_definition = aws_ecs_task_definition.api.family +} + resource "aws_ecs_task_definition" "db-reset" { family = "${var.name_prefix}-db-reset" @@ -414,3 +471,59 @@ resource "aws_ecs_service" "this" { } } + +resource "aws_ecs_service" "api" { + name = "${var.name_prefix}-API" + cluster = var.shared.ecs_cluster.id + # During deployment a new task revision is created with modified + # container image, so we want use data.aws_ecs_task_definition to + # always point to the active task definition + task_definition = data.aws_ecs_task_definition.api.arn + desired_count = 1 + scheduling_strategy = "REPLICA" + deployment_maximum_percent = 200 + deployment_minimum_healthy_percent = 50 + health_check_grace_period_seconds = var.rails_startup_time + + capacity_provider_strategy { + capacity_provider = var.shared.t3_capacity_provider.name + weight = 1 + } + + deployment_circuit_breaker { + enable = true + rollback = true + } + + enable_execute_command = true + + ordered_placement_strategy { + type = "spread" + field = "attribute:ecs.availability-zone" + } + + ordered_placement_strategy { + type = "spread" + field = "instanceId" + } + + load_balancer { + target_group_arn = var.shared.rails_staging-api.arn + container_name = "rails-staging-api" + container_port = 3000 + } + + network_configuration { + security_groups = [var.shared.cluster_security.id] + subnets = var.shared.private_subnets[*].id + } + + deployment_controller { + type = "ECS" + } + + tags = { + Name = var.name_prefix + } + +} diff --git a/infra/wca_on_rails/staging/variables.tf b/infra/wca_on_rails/staging/variables.tf index c90ef24c3b..f7470a86c0 100644 --- a/infra/wca_on_rails/staging/variables.tf +++ b/infra/wca_on_rails/staging/variables.tf @@ -85,6 +85,9 @@ variable "shared" { rails_staging: object({ arn: string }) + rails_staging-api: object({ + arn: string + }) pma_staging: object({ arn: string })