diff --git a/services/reports/src/constants.rs b/services/reports/src/constants.rs index fdf75364e..4ad64b9f2 100644 --- a/services/reports/src/constants.rs +++ b/services/reports/src/constants.rs @@ -1,2 +1,14 @@ pub const REPORT_LIST_DEFAULT_PAGE_SIZE: u32 = 20; pub const REQUEST_BODY_JSON_SIZE_LIMIT: usize = 10 * 1024 * 1024; // 10MB + +pub fn max_report_size() -> usize { + let size = std::env::var("MAX_REPORT_SIZE") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(REQUEST_BODY_JSON_SIZE_LIMIT); + + if size != REQUEST_BODY_JSON_SIZE_LIMIT { + tracing::info!("MAX_REPORT_SIZE is set to {} bytes", size); + } + size +} diff --git a/services/reports/src/http/mod.rs b/services/reports/src/http/mod.rs index 8caccae05..e5480f610 100644 --- a/services/reports/src/http/mod.rs +++ b/services/reports/src/http/mod.rs @@ -1,123 +1,122 @@ use actix_web::error::{ ErrorBadRequest, ErrorInternalServerError, ErrorNotFound, ErrorServiceUnavailable, ErrorUnsupportedMediaType, }; use actix_web::{web, App, HttpResponse, HttpServer, ResponseError}; use anyhow::Result; use comm_services_lib::auth::AuthService; use http::StatusCode; use tracing::{debug, error, info, trace, warn}; use crate::config::CONFIG; -use crate::constants::REQUEST_BODY_JSON_SIZE_LIMIT; +use crate::constants::max_report_size; use crate::service::{ReportsService, ReportsServiceError}; mod handlers; pub async fn run_http_server( reports_service: ReportsService, auth_service: AuthService, ) -> Result<()> { use actix_web::middleware::{Logger, NormalizePath}; use comm_services_lib::http::cors_config; use tracing_actix_web::TracingLogger; info!( "Starting HTTP server listening at port {}", CONFIG.http_port ); HttpServer::new(move || { - let json_cfg = - web::JsonConfig::default().limit(REQUEST_BODY_JSON_SIZE_LIMIT); + let json_cfg = web::JsonConfig::default().limit(max_report_size()); App::new() .app_data(json_cfg) .app_data(reports_service.to_owned()) .app_data(auth_service.to_owned()) .wrap(Logger::default()) .wrap(TracingLogger::default()) .wrap(NormalizePath::trim()) .wrap(cors_config(CONFIG.is_dev())) // Health endpoint for load balancers checks .route("/health", web::get().to(HttpResponse::Ok)) .service( web::scope("/reports") .service(handlers::post_reports) .service(handlers::query_reports) .service(handlers::get_single_report) .service(handlers::redux_devtools_import), ) }) .bind(("0.0.0.0", CONFIG.http_port))? .run() .await?; Ok(()) } fn handle_reports_service_error(err: &ReportsServiceError) -> actix_web::Error { use aws_sdk_dynamodb::Error as DynamoDBError; use comm_services_lib::database::Error as DBError; trace!("Handling reports service error: {:?}", err); match err { ReportsServiceError::UnsupportedReportType => { ErrorUnsupportedMediaType("unsupported report type") } ReportsServiceError::SerdeError(err) => { error!("Serde error: {0:?} - {0}", err); ErrorInternalServerError("internal error") } ReportsServiceError::ParseError(err) => { debug!("Parse error: {0:?} - {0}", err); ErrorBadRequest("invalid input format") } ReportsServiceError::BlobError(err) => { error!("Blob Service error: {0:?} - {0}", err); ErrorInternalServerError("internal error") } ReportsServiceError::DatabaseError(db_err) => match db_err { // retriable errors DBError::MaxRetriesExceeded | DBError::AwsSdk( DynamoDBError::InternalServerError(_) | DynamoDBError::ProvisionedThroughputExceededException(_) | DynamoDBError::RequestLimitExceeded(_), ) => { warn!("AWS transient error occurred"); ErrorServiceUnavailable("please retry") } err => { error!("Unexpected database error: {0:?} - {0}", err); ErrorInternalServerError("internal error") } }, err => { error!("Received an unexpected error: {0:?} - {0}", err); ErrorInternalServerError("server error") } } } /// This allow us to `await?` blob service calls in HTTP handlers impl ResponseError for ReportsServiceError { fn error_response(&self) -> HttpResponse { handle_reports_service_error(self).error_response() } fn status_code(&self) -> StatusCode { handle_reports_service_error(self) .as_response_error() .status_code() } } trait NotFoundHandler { /// Returns `Ok(T)` if `self` is `Some(T)`, /// otherwise returns a `404 Not Found` error. fn unwrap_or_404(self) -> actix_web::Result; } impl NotFoundHandler for Option { fn unwrap_or_404(self) -> actix_web::Result { self.ok_or_else(|| ErrorNotFound("not found")) } } diff --git a/services/terraform/remote/service_reports.tf b/services/terraform/remote/service_reports.tf index d46f4d24b..8c8fb5731 100644 --- a/services/terraform/remote/service_reports.tf +++ b/services/terraform/remote/service_reports.tf @@ -1,196 +1,209 @@ locals { - reports_service_image_tag = local.is_staging ? "latest" : "0.1.0" + reports_service_image_tag = local.is_staging ? "latest" : "0.1.1" reports_service_container_name = "reports-service-server" reports_service_server_image = "commapp/reports-server:${local.reports_service_image_tag}" reports_service_container_http_port = 50056 reports_service_domain_name = "reports.${local.root_domain}" } resource "aws_secretsmanager_secret" "email_config" { name_prefix = "email_config" description = "E-mail configuration for the reports service" } resource "aws_secretsmanager_secret_version" "email_config" { secret_id = aws_secretsmanager_secret.email_config.id secret_string = jsonencode(local.secrets["emailConfig"]) } resource "aws_ecs_task_definition" "reports_service" { family = "reports-service-task-def" container_definitions = jsonencode([ { name = local.reports_service_container_name image = local.reports_service_server_image essential = true portMappings = [ { containerPort = local.reports_service_container_http_port protocol = "tcp" appProtocol = "http" }, ] environment = [ { name = "RUST_LOG" value = "info" }, + { + name = "MAX_REPORT_SIZE" + value = "314572800" # 300MB + }, { name = "PUBLIC_URL", value = "https://${local.reports_service_domain_name}" }, { name = "BLOB_SERVICE_URL", value = local.blob_local_url # If this ever fails, we can fallback to blob public URL: # "https://${local.blob_service_domain_name}" }, ] # Don't enable e-mails on staging. secrets = local.is_staging ? [] : [ { # This is exposed as an environment variable in the container name = "EMAIL_CONFIG" valueFrom = aws_secretsmanager_secret.email_config.arn } ] logConfiguration = { "logDriver" = "awslogs" "options" = { "awslogs-create-group" = "true" "awslogs-group" = "/ecs/reports-service-task-def" "awslogs-region" = "us-east-2" "awslogs-stream-prefix" = "ecs" } } } ]) task_role_arn = aws_iam_role.reports_service.arn execution_role_arn = aws_iam_role.ecs_task_execution.arn - network_mode = "bridge" - cpu = "256" - memory = "256" - requires_compatibilities = ["EC2"] + network_mode = "awsvpc" + cpu = "1024" + memory = "8192" + requires_compatibilities = ["EC2", "FARGATE"] # Set this to true if you want to keep old revisions # when this definition is changed skip_destroy = false } resource "aws_ecs_service" "reports_service" { name = "reports-service" cluster = aws_ecs_cluster.comm_services.id - launch_type = "EC2" + launch_type = "FARGATE" task_definition = aws_ecs_task_definition.reports_service.arn force_new_deployment = true desired_count = 1 lifecycle { ignore_changes = [desired_count] } service_connect_configuration { # to be able to reach Blob service by DNS name enabled = true } # HTTP load_balancer { target_group_arn = aws_lb_target_group.reports_service_http.arn container_name = local.reports_service_container_name container_port = local.reports_service_container_http_port } + network_configuration { + assign_public_ip = true + security_groups = [ + aws_security_group.reports_service.id, + ] + subnets = [ + aws_subnet.public_a.id, + aws_subnet.public_b.id, + aws_subnet.public_c.id, + ] + } + deployment_circuit_breaker { enable = true rollback = true } enable_execute_command = true enable_ecs_managed_tags = true } # Security group to configure access to the service resource "aws_security_group" "reports_service" { name = "reports-service-ecs-sg" vpc_id = aws_vpc.default.id ingress { from_port = local.reports_service_container_http_port to_port = local.reports_service_container_http_port protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] description = "HTTP port" } # Allow all outbound traffic egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } lifecycle { create_before_destroy = true } } resource "aws_lb_target_group" "reports_service_http" { name = "reports-service-ecs-http-tg" port = local.reports_service_container_http_port protocol = "HTTP" vpc_id = aws_vpc.default.id # ECS Fargate requires target type set to IP - target_type = "instance" + target_type = "ip" health_check { enabled = true healthy_threshold = 2 unhealthy_threshold = 3 protocol = "HTTP" path = "/health" matcher = "200-204" } } # Load Balancer resource "aws_lb" "reports_service" { load_balancer_type = "application" name = "reports-service-lb" internal = false subnets = [ aws_subnet.public_a.id, aws_subnet.public_b.id, aws_subnet.public_c.id, ] } resource "aws_lb_listener" "reports_service_https" { load_balancer_arn = aws_lb.reports_service.arn port = "443" protocol = "HTTPS" ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06" certificate_arn = data.aws_acm_certificate.reports_service.arn default_action { type = "forward" target_group_arn = aws_lb_target_group.reports_service_http.arn } lifecycle { - # Target group cannot be destroyed if it is used - replace_triggered_by = [aws_lb_target_group.reports_service_http] - # Required to avoid no-op plan differences ignore_changes = [default_action[0].forward[0].stickiness[0].duration] } } # SSL Certificate data "aws_acm_certificate" "reports_service" { domain = local.reports_service_domain_name statuses = ["ISSUED"] }