diff --git a/core b/core new file mode 100644 index 00000000..e69de29b diff --git a/infrastructure/modules/container-apps/jobs.tf b/infrastructure/modules/container-apps/jobs.tf index 8770825d..c7df424c 100644 --- a/infrastructure/modules/container-apps/jobs.tf +++ b/infrastructure/modules/container-apps/jobs.tf @@ -1,3 +1,16 @@ +locals { + scheduled_jobs = { + collect_metrics = { + cron_expression = "0 */6 * * *" + environment_variables = { + ENVIRONMENT = var.environment + } + job_short_name = "rs" + job_container_args = "request_summary" + } + } +} + module "db_setup" { source = "../dtos-devops-templates/infrastructure/modules/container-app-job" @@ -25,3 +38,54 @@ module "db_setup" { ] } + +module "scheduled_jobs" { + source = "../dtos-devops-templates/infrastructure/modules/container-app-job" + + for_each = local.scheduled_jobs + + name = "${var.app_short_name}-${each.value.job_short_name}-${var.environment}" + container_app_environment_id = var.container_app_environment_id + resource_group_name = azurerm_resource_group.main.name + + fetch_secrets_from_app_key_vault = var.fetch_secrets_from_app_key_vault + app_key_vault_id = var.app_key_vault_id + + container_command = ["/bin/sh", "-c"] + container_args = [ + "python manage.py ${each.value.job_container_args}" + ] + + docker_image = var.docker_image + replica_retry_limit = 0 + user_assigned_identity_ids = flatten([ + [module.azure_blob_storage_identity.id], + var.deploy_database_as_container ? [] : [module.db_connect_identity[0].id] + ]) + + environment_variables = merge( + local.common_env, + { + "STORAGE_ACCOUNT_NAME" = module.storage.storage_account_name, + "BLOB_MI_CLIENT_ID" = module.azure_blob_storage_identity.client_id, + }, + each.value.environment_variables, + var.deploy_database_as_container ? local.container_db_env : local.azure_db_env + ) + secret_variables = merge( + { SLACK_WEBHOOK_URL = var.slack_webhook_url }, + var.deploy_database_as_container ? { DATABASE_PASSWORD = resource.random_password.admin_password[0].result } : {} + ) + + # alerts + action_group_id = var.action_group_id + enable_alerting = var.enable_alerting + log_analytics_workspace_id = var.log_analytics_workspace_audit_id + + # Ensure RBAC role assignments are created before the job definition finalizes + depends_on = [ + module.blob_storage_role_assignment, + ] + + cron_expression = each.value.cron_expression +} diff --git a/infrastructure/modules/container-apps/variables.tf b/infrastructure/modules/container-apps/variables.tf index 5c752459..bf3137c9 100644 --- a/infrastructure/modules/container-apps/variables.tf +++ b/infrastructure/modules/container-apps/variables.tf @@ -196,6 +196,11 @@ variable "infra_key_vault_rg" { type = string } +variable "slack_webhook_url" { + description = "slack_webhook_url is the URL used to send alerts to Slack. It should be stored as a secret in the infra key vault with the name 'slack-webhook-url'." + type = string +} + locals { resource_group_name = "rg-${var.app_short_name}-${var.environment}-container-app-uks" diff --git a/infrastructure/modules/infra/logic_app.tf b/infrastructure/modules/infra/logic_app.tf new file mode 100644 index 00000000..960c0774 --- /dev/null +++ b/infrastructure/modules/infra/logic_app.tf @@ -0,0 +1,24 @@ +module "logic_app_slack_alert" { + count = var.enable_alerting ? 1 : 0 + + source = "../dtos-devops-templates/infrastructure/modules/logic-app-slack-alert" + + name = "logic-${var.app_short_name}-${var.environment}-slack-alerts" + resource_group_name = azurerm_resource_group.main.name + location = var.region + slack_webhook_url = var.slack_webhook_url +} + +resource "azurerm_monitor_action_group" "slack" { + count = var.enable_alerting ? 1 : 0 + + name = "ag-slack-${var.app_short_name}-${var.environment}-uks" + resource_group_name = azurerm_resource_group.main.name + short_name = "slack" + + webhook_receiver { + name = "logic-app-slack" + service_uri = module.logic_app_slack_alert[0].trigger_callback_url + use_common_alert_schema = true + } +} diff --git a/infrastructure/modules/infra/variables.tf b/infrastructure/modules/infra/variables.tf index 36726480..f845c75f 100644 --- a/infrastructure/modules/infra/variables.tf +++ b/infrastructure/modules/infra/variables.tf @@ -73,6 +73,11 @@ variable "enable_alerting" { type = bool } +variable "slack_webhook_url" { + description = "slack_webhook_url is the URL used to send alerts to Slack. It should be stored as a secret in the infra key vault with the name 'slack-webhook-url'." + type = string +} + locals { hub_vnet_rg_name = "rg-hub-${var.hub}-uks-bootstrap" hub_vnet_name = "vnet-hub-${var.hub}-uks" diff --git a/infrastructure/terraform/spoke/data.tf b/infrastructure/terraform/spoke/data.tf index 855734fc..c0cfc9e5 100644 --- a/infrastructure/terraform/spoke/data.tf +++ b/infrastructure/terraform/spoke/data.tf @@ -49,3 +49,17 @@ data "azurerm_application_insights" "app_insights" { name = "appi-${var.env_config}-uks-${var.app_short_name}" resource_group_name = local.resource_group_name } + +data "azurerm_key_vault" "infra" { + provider = azurerm.hub + + name = local.infra_key_vault_name + resource_group_name = local.infra_key_vault_rg +} + +data "azurerm_key_vault_secret" "slack_webhook_url" { + name = "slack-webhook-url" + key_vault_id = data.azurerm_key_vault.infra.id +} + +# git-sha-a85180497c23d742b5f92262b3b43069e44a4110 diff --git a/infrastructure/terraform/spoke/main.tf b/infrastructure/terraform/spoke/main.tf index d3626dd8..ee141137 100644 --- a/infrastructure/terraform/spoke/main.tf +++ b/infrastructure/terraform/spoke/main.tf @@ -22,6 +22,7 @@ module "infra" { vnet_address_space = var.vnet_address_space cae_zone_redundancy_enabled = var.cae_zone_redundancy_enabled enable_alerting = var.enable_alerting + slack_webhook_url = data.azurerm_key_vault_secret.slack_webhook_url.value } module "container-apps" { @@ -69,4 +70,5 @@ module "container-apps" { use_apex_domain = var.use_apex_domain container_memory = var.container_memory min_replicas = var.min_replicas + slack_webhook_url = data.azurerm_key_vault_secret.slack_webhook_url.value } diff --git a/lung_cancer_screening/questions/management/__init__.py b/lung_cancer_screening/questions/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/lung_cancer_screening/questions/management/commands/__init__.py b/lung_cancer_screening/questions/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/lung_cancer_screening/questions/management/commands/request_summary.py b/lung_cancer_screening/questions/management/commands/request_summary.py new file mode 100644 index 00000000..1ae1f511 --- /dev/null +++ b/lung_cancer_screening/questions/management/commands/request_summary.py @@ -0,0 +1,64 @@ +import logging +import os + +import requests +from django.core.management.base import BaseCommand, CommandError +from lung_cancer_screening.questions.services.request_summary import RequestSummary + +logger = logging.getLogger(__name__) + +class Command(BaseCommand): + help = "Counts the number of submitted requests." + + def handle(self, *args, **options): + + logger.info("Command: Request Summary.") + try: + rs = RequestSummary() + summary = rs.get_summary() + + self.stdout.write(str(summary)) + + slack_webhook_url = os.environ.get("SLACK_WEBHOOK_URL") + + if not slack_webhook_url: + logger.warning("SLACK_WEBHOOK_URL is not set; skipping Slack notification.") + return + + payload = { + "blocks": [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": "Request Summary", + } + }, + { + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": f"*Requests:*\n{rs.get_count()}" + }, + { + "type": "mrkdwn", + "text": f"*Submitted:*\n{rs.get_submitted_count()}" + } + ] + } + ] + } + + response = requests.post( + slack_webhook_url, + json=payload, + timeout=10, + ) + response.raise_for_status() + + logger.info("Request summary sent to Slack.") + + except Exception as e: + logger.error(e, exc_info=True) + raise CommandError(e) diff --git a/lung_cancer_screening/questions/services/__init__.py b/lung_cancer_screening/questions/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/lung_cancer_screening/questions/services/request_summary.py b/lung_cancer_screening/questions/services/request_summary.py new file mode 100644 index 00000000..7161cd7a --- /dev/null +++ b/lung_cancer_screening/questions/services/request_summary.py @@ -0,0 +1,23 @@ +import logging +from ..models.response_set import ResponseSet + +logger = logging.getLogger(__name__) + +class RequestSummary: + + def __init__(self): + logger.info("RequestSummary: init") + + def get_submitted_count(self): + + return ResponseSet.objects.submitted().count() + + def get_count(self): + + return ResponseSet.objects.count() + + def get_summary(self): + return { + "total": self.get_count(), + "submitted": self.get_submitted_count(), + } diff --git a/scripts/terraform/terraform.mk b/scripts/terraform/terraform.mk index f3b45da1..38f7862f 100644 --- a/scripts/terraform/terraform.mk +++ b/scripts/terraform/terraform.mk @@ -30,6 +30,7 @@ prod: # Target the prod environment - make prod review: # Target the review infrastructure, or a review app if PR_NUMBER is used - make review [PR_NUMBER=] $(eval include infrastructure/environments/review/variables.sh) $(if ${PR_NUMBER}, $(eval export TF_VAR_deploy_infra=false), $(eval export TF_VAR_deploy_container_apps=true)) + $(if ${PR_NUMBER},,$(eval export TF_VAR_deploy_container_apps=false)) $(if ${PR_NUMBER}, $(eval export ENVIRONMENT=pr-${PR_NUMBER}), $(eval export ENVIRONMENT=review)) db-setup: