diff --git a/.github/actions/stack-infra-run/action.yml b/.github/actions/stack-infra-run/action.yml index 2f11d197..93d0b902 100644 --- a/.github/actions/stack-infra-run/action.yml +++ b/.github/actions/stack-infra-run/action.yml @@ -108,6 +108,22 @@ inputs: description: "Optional JSON object containing protected storage process job runtime configuration" required: false default: "" + graphql_process_job_image_tag: + description: "Optional GraphQL process job image tag" + required: false + default: "" + deployment_mode: + description: "Optional deployment mode: automatic or manual" + required: false + default: "" + cron_expression: + description: "Optional cron expression for scheduled runs" + required: false + default: "" + graphql_process_job_configuration: + description: "Optional JSON object containing protected GraphQL process job runtime configuration" + required: false + default: "" runs: @@ -132,6 +148,7 @@ runs: env: ADDITIONAL_TAGS: ${{ inputs.additional_tags }} STORAGE_PROCESS_JOB_CONFIGURATION: ${{ inputs.storage_process_job_configuration }} + GRAPHQL_PROCESS_JOB_CONFIGURATION: ${{ inputs.graphql_process_job_configuration }} run: | set -euo pipefail @@ -192,6 +209,23 @@ runs: PARAMETERS+=("storageProcessJobImageTag=${{ inputs.storage_process_job_image_tag }}") fi + if [ -n "${{ inputs.graphql_process_job_image_tag }}" ]; then + PARAMETERS+=("graphqlProcessJobImageTag=${{ inputs.graphql_process_job_image_tag }}") + fi + + if [ -n "${{ inputs.deployment_mode }}" ]; then + PARAMETERS+=("deploymentMode=${{ inputs.deployment_mode }}") + fi + + if [ -n "${{ inputs.cron_expression }}" ]; then + PARAMETERS+=("cronExpression=${{ inputs.cron_expression }}") + fi + + if [ -n "${GRAPHQL_PROCESS_JOB_CONFIGURATION}" ]; then + GRAPHQL_PROCESS_JOB_CONFIGURATION_JSON="$(jq -nce --argjson configuration "${GRAPHQL_PROCESS_JOB_CONFIGURATION}" 'if ($configuration | type) == "object" then $configuration else error("graphql_process_job_configuration must be a JSON object") end')" + PARAMETERS+=("graphqlProcessJobConfiguration=${GRAPHQL_PROCESS_JOB_CONFIGURATION_JSON}") + fi + if [ -n "${{ inputs.matching_api_image_tag }}" ]; then PARAMETERS+=("matchingApiImageTag=${{ inputs.matching_api_image_tag }}") fi diff --git a/.github/workflows/gh-api-batch-processor-infra-deploy.yml b/.github/workflows/gh-api-batch-processor-infra-deploy.yml new file mode 100644 index 00000000..a1911815 --- /dev/null +++ b/.github/workflows/gh-api-batch-processor-infra-deploy.yml @@ -0,0 +1,292 @@ +name: Deploy Api-Batch-Processor Infrastructure +run-name: >- + Api Batch Processor / ${{ inputs.environment }} / ${{ inputs.environment_prefix }} / + ${{ inputs.what_if && 'what-if' || 'deploy' }} / ${{ inputs.version }} + +on: + workflow_dispatch: + inputs: + environment: + description: The environment target for deployment + required: true + default: 'Integration' + what_if: + description: Run bicep what-if to check for changes + default: true + type: boolean + version: + description: Deployment version, tag, branch, or SHA to deploy + required: true + type: string + environment_prefix: + description: The prefix for the environment + default: 's215d01' + type: string + graphql_process_image_tag: + description: GraphQL process job image tag + default: 'latest' + required: false + type: string + deployment_mode: + description: The deployment mode for the job + default: 'manual' + type: choice + options: + - automatic + - manual + cron_expression: + description: The cron expression for the scheduled trigger when deployment_mode is automatic + default: '0 9,12,15 * * 1-5' + required: false + type: string + turn_on_alerts: + description: Enable monitoring alerts + default: false + type: boolean + include_role_assignments: + description: Include role assignments? + default: true + type: boolean + resource_group_mode: + description: Resource group mode + default: create + type: choice + options: + - create + - existing + target_resource_group_name: + description: Existing resource group name when resource_group_mode is existing + required: false + type: string + +permissions: + id-token: write + contents: read + +env: + AZURE_ENV_NAME: ${{ inputs.environment }} + AZURE_ENV_PREFIX: ${{ inputs.environment_prefix }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + AZURE_LOCATION: ${{ vars.AZURE_LOCATION }} + STACK_VERSION: ${{ inputs.version }} + AZURE_MONITORING_ACTION_GROUP_EMAIL: ${{ secrets.AZURE_MONITORING_ACTION_GROUP_EMAIL }} + AZURE_CONTAINER_APP_MANAGED_ENVIRONMENT_NUMBER: ${{ vars.AZURE_CONTAINER_APP_MANAGED_ENVIRONMENT_NUMBER }} + AZURE_CONTAINER_APP_VNET: ${{ secrets.AZURE_CONTAINER_APP_VNET }} + AZURE_CONTAINER_APP_ENV_SUBNET: ${{ secrets.AZURE_CONTAINER_APP_ENV_SUBNET }} + AZURE_CONTAINER_APP_PE_SUBNET: ${{ secrets.AZURE_CONTAINER_APP_PE_SUBNET }} + AZURE_TAG_ENVIRONMENT_NAME: ${{ vars.AZURE_TAG_ENVIRONMENT_NAME }} + AZURE_ADDITIONAL_TAGS: ${{ vars.AZURE_ADDITIONAL_TAGS }} + AZURE_INCLUDE_ROLE_ASSIGNMENTS: ${{ inputs.include_role_assignments }} + AZURE_TURN_ON_ALERTS: ${{ inputs.turn_on_alerts || 'false' }} + GRAPHQL_PROCESS_JOB_IMAGE_TAG: ${{ inputs.graphql_process_image_tag || 'latest' }} + DEPLOYMENT_MODE: ${{ inputs.deployment_mode || 'manual' }} + CRON_EXPRESSION: ${{ inputs.cron_expression || '0 9,12,15 * * 1-5' }} + RESOURCE_GROUP_MODE: ${{ inputs.resource_group_mode || 'create' }} + TARGET_RESOURCE_GROUP_NAME: ${{ inputs.target_resource_group_name }} + GRAPHQL_PROCESS_JOB_CONFIGURATION: ${{ secrets.GRAPHQL_PROCESS_JOB_CONFIGURATION }} + +concurrency: + group: api-batch-processor-${{ inputs.environment_prefix }}-${{ inputs.environment }} + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + prepare: + name: Prepare api-batch-processor context + runs-on: ubuntu-latest + env: + STACK_NAME: api-batch-processor + DEPLOY_RUN_MODE: ${{ inputs.what_if && 'what-if' || 'deploy' }} + outputs: + stack_resource_group: ${{ steps.context.outputs.stack_resource_group }} + deployment_name: ${{ steps.context.outputs.deployment_name }} + deployment_mode: ${{ steps.context.outputs.deployment_mode }} + steps: + - name: Validate private endpoint subnet configuration + run: | + set -euo pipefail + + if [ -z "${AZURE_CONTAINER_APP_PE_SUBNET}" ]; then + echo "::error::AZURE_CONTAINER_APP_PE_SUBNET secret must be configured for api-batch-processor infrastructure deployments." + exit 1 + fi + + - name: Validate graphql process job configuration + run: | + set -euo pipefail + + if [ -z "${GRAPHQL_PROCESS_JOB_CONFIGURATION}" ]; then + echo "::error::GRAPHQL_PROCESS_JOB_CONFIGURATION secret must be configured for api-batch-processor infrastructure deployments." + exit 1 + fi + + - name: Derive target names + id: context + run: | + set -euo pipefail + STACK_RESOURCE_GROUP="${AZURE_ENV_PREFIX}-${AZURE_ENV_NAME,,}-${STACK_NAME}" + if [ "${RESOURCE_GROUP_MODE}" = "existing" ]; then + if [ -z "${TARGET_RESOURCE_GROUP_NAME}" ]; then + echo "::error::target_resource_group_name must be set when resource_group_mode is existing." + exit 1 + fi + STACK_RESOURCE_GROUP="${TARGET_RESOURCE_GROUP_NAME}" + fi + + DEPLOYMENT_NAME="${STACK_RESOURCE_GROUP}-${AZURE_LOCATION,,}-${DEPLOY_RUN_MODE}" + + { + echo "stack_resource_group=${STACK_RESOURCE_GROUP}" + echo "deployment_name=${DEPLOYMENT_NAME}" + echo "deployment_mode=${DEPLOY_RUN_MODE}" + } >> "$GITHUB_OUTPUT" + + { + echo "## Deployment context" + echo + echo "**Stack:** \`${STACK_NAME}\`" + echo "**Mode:** \`${DEPLOY_RUN_MODE}\`" + echo + echo "### Target" + echo "- Environment: \`${AZURE_ENV_NAME}\`" + echo "- Environment prefix: \`${AZURE_ENV_PREFIX}\`" + echo "- Azure location: \`${AZURE_LOCATION}\`" + echo "- Version/ref: \`${STACK_VERSION}\`" + echo "- Resource group: \`${STACK_RESOURCE_GROUP}\`" + echo "- Resource group mode: \`${RESOURCE_GROUP_MODE}\`" + echo "- Deployment name: \`${DEPLOYMENT_NAME}\`" + } >> "$GITHUB_STEP_SUMMARY" + + what_if: + name: What-if api-batch-processor infrastructure + if: ${{ inputs.what_if == true }} + needs: prepare + runs-on: ubuntu-latest + env: + STACK_RESOURCE_GROUP: ${{ needs.prepare.outputs.stack_resource_group }} + DEPLOYMENT_NAME: ${{ needs.prepare.outputs.deployment_name }} + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + ref: ${{ env.STACK_VERSION }} + + - name: Run api-batch-processor what-if + uses: ./.github/actions/stack-infra-run + with: + mode: what-if + stack_label: api-batch-processor + template_file: infra/stacks/api-batch-processor/subscription.bicep + azure_env_name: ${{ env.AZURE_ENV_NAME }} + azure_env_prefix: ${{ env.AZURE_ENV_PREFIX }} + azure_client_id: ${{ env.AZURE_CLIENT_ID }} + azure_tenant_id: ${{ env.AZURE_TENANT_ID }} + azure_subscription_id: ${{ env.AZURE_SUBSCRIPTION_ID }} + azure_location: ${{ env.AZURE_LOCATION }} + azure_monitoring_action_group_email: ${{ env.AZURE_MONITORING_ACTION_GROUP_EMAIL }} + azure_container_app_managed_environment_number: ${{ env.AZURE_CONTAINER_APP_MANAGED_ENVIRONMENT_NUMBER }} + azure_container_app_vnet: ${{ env.AZURE_CONTAINER_APP_VNET }} + azure_container_app_env_subnet: ${{ env.AZURE_CONTAINER_APP_ENV_SUBNET }} + azure_container_app_pe_subnet: ${{ env.AZURE_CONTAINER_APP_PE_SUBNET }} + tag_environment_name: ${{ env.AZURE_TAG_ENVIRONMENT_NAME }} + additional_tags: ${{ env.AZURE_ADDITIONAL_TAGS }} + azure_include_role_assignments: ${{ env.AZURE_INCLUDE_ROLE_ASSIGNMENTS }} + stack_resource_group: ${{ env.STACK_RESOURCE_GROUP }} + deployment_name: ${{ env.DEPLOYMENT_NAME }} + turn_on_alerts: ${{ env.AZURE_TURN_ON_ALERTS }} + graphql_process_job_image_tag: ${{ env.GRAPHQL_PROCESS_JOB_IMAGE_TAG }} + deployment_mode: ${{ env.DEPLOYMENT_MODE }} + cron_expression: ${{ env.CRON_EXPRESSION }} + graphql_process_job_configuration: ${{ env.GRAPHQL_PROCESS_JOB_CONFIGURATION }} + + deploy: + name: Deploy api-batch-processor infrastructure + if: ${{ inputs.what_if == false }} + needs: prepare + runs-on: ubuntu-latest + env: + STACK_RESOURCE_GROUP: ${{ needs.prepare.outputs.stack_resource_group }} + DEPLOYMENT_NAME: ${{ needs.prepare.outputs.deployment_name }} + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + ref: ${{ env.STACK_VERSION }} + + - name: Deploy api-batch-processor infrastructure + uses: ./.github/actions/stack-infra-run + with: + mode: deploy + stack_label: api-batch-processor + template_file: infra/stacks/api-batch-processor/subscription.bicep + azure_env_name: ${{ env.AZURE_ENV_NAME }} + azure_env_prefix: ${{ env.AZURE_ENV_PREFIX }} + azure_client_id: ${{ env.AZURE_CLIENT_ID }} + azure_tenant_id: ${{ env.AZURE_TENANT_ID }} + azure_subscription_id: ${{ env.AZURE_SUBSCRIPTION_ID }} + azure_location: ${{ env.AZURE_LOCATION }} + azure_monitoring_action_group_email: ${{ env.AZURE_MONITORING_ACTION_GROUP_EMAIL }} + azure_container_app_managed_environment_number: ${{ env.AZURE_CONTAINER_APP_MANAGED_ENVIRONMENT_NUMBER }} + azure_container_app_vnet: ${{ env.AZURE_CONTAINER_APP_VNET }} + azure_container_app_env_subnet: ${{ env.AZURE_CONTAINER_APP_ENV_SUBNET }} + azure_container_app_pe_subnet: ${{ env.AZURE_CONTAINER_APP_PE_SUBNET }} + tag_environment_name: ${{ env.AZURE_TAG_ENVIRONMENT_NAME }} + additional_tags: ${{ env.AZURE_ADDITIONAL_TAGS }} + azure_include_role_assignments: ${{ env.AZURE_INCLUDE_ROLE_ASSIGNMENTS }} + stack_resource_group: ${{ env.STACK_RESOURCE_GROUP }} + deployment_name: ${{ env.DEPLOYMENT_NAME }} + turn_on_alerts: ${{ env.AZURE_TURN_ON_ALERTS }} + graphql_process_job_image_tag: ${{ env.GRAPHQL_PROCESS_JOB_IMAGE_TAG }} + deployment_mode: ${{ env.DEPLOYMENT_MODE }} + cron_expression: ${{ env.CRON_EXPRESSION }} + graphql_process_job_configuration: ${{ env.GRAPHQL_PROCESS_JOB_CONFIGURATION }} + + summarize: + name: Summarize api-batch-processor run + if: always() + needs: + - prepare + - what_if + - deploy + runs-on: ubuntu-latest + env: + STACK_RESOURCE_GROUP: ${{ needs.prepare.outputs.stack_resource_group }} + DEPLOYMENT_NAME: ${{ needs.prepare.outputs.deployment_name }} + DEPLOY_RUN_MODE: ${{ needs.prepare.outputs.deployment_mode }} + PREPARE_RESULT: ${{ needs.prepare.result }} + WHAT_IF_RESULT: ${{ needs.what_if.result }} + DEPLOY_RESULT: ${{ needs.deploy.result }} + AZURE_ENV_NAME: ${{ inputs.environment }} + AZURE_ENV_PREFIX: ${{ inputs.environment_prefix }} + AZURE_LOCATION: ${{ vars.AZURE_LOCATION }} + steps: + - name: Write workflow summary + run: | + set -euo pipefail + EXECUTION_RESULT="${DEPLOY_RESULT}" + if [ "${DEPLOY_RUN_MODE}" = "what-if" ]; then + EXECUTION_RESULT="${WHAT_IF_RESULT}" + fi + + { + echo "## Workflow summary" + echo + echo "### Job results" + echo "- Prepare: \`${PREPARE_RESULT}\`" + echo "- Execution: \`${EXECUTION_RESULT}\`" + echo + echo "### Target" + echo "- Mode: \`${DEPLOY_RUN_MODE}\`" + echo "- Environment: \`${AZURE_ENV_NAME}\`" + echo "- Environment prefix: \`${AZURE_ENV_PREFIX}\`" + echo "- Azure location: \`${AZURE_LOCATION}\`" + echo "- Resource group: \`${STACK_RESOURCE_GROUP}\`" + echo "- Deployment name: \`${DEPLOYMENT_NAME}\`" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/infra/modules/api-batch-processor/graphql-process-job.bicep b/infra/modules/api-batch-processor/graphql-process-job.bicep new file mode 100644 index 00000000..729be875 --- /dev/null +++ b/infra/modules/api-batch-processor/graphql-process-job.bicep @@ -0,0 +1,140 @@ +@description('The location used for all deployed resources') +param location string + +@description('The prefix used for all deployed resources') +param environmentPrefix string + +@description('The lowercase environment name used for resource naming') +param lowercaseEnvironmentName string + +@description('The resource ID of the Container Apps managed environment') +param containerAppsEnvironmentId string + +@description('The resource ID of the managed identity') +param managedIdentityId string + +@description('The client ID of the managed identity') +param managedIdentityClientId string + +@description('The container registry server endpoint') +param containerRegistryServer string + +@description('The container image name') +param imageName string = 'sui-client-graphql-process-job' + +@description('The container image tag to deploy') +param imageTag string + +@secure() +@description('The Application Insights connection string') +param applicationInsightsConnectionString string + +@secure() +@description('Runtime configuration values for the GraphQL process job.') +param graphqlProcessJobConfiguration object + +@allowed([ + 'automatic' + 'manual' +]) +@description('The deployment mode for the job: automatic (scheduled) or manual (triggered on-demand)') +param deploymentMode string = 'manual' + +@description('The cron expression for the scheduled trigger when deploymentMode is automatic') +param cronExpression string = '0 9,12,15 * * 1-5' + +@description('Tags that will be applied to all resources') +param tags object = {} + +// https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/resource-name-rules +// 32 chars max +var jobName = toLower('${take(environmentPrefix, 8)}-${take(lowercaseEnvironmentName, 3)}-graphql-process-job') + +var graphqlProcessJobConfigurationEnvironment = [for setting in items(graphqlProcessJobConfiguration): { + name: setting.key + value: string(setting.value) +}] + +var triggerType = deploymentMode == 'automatic' ? 'Schedule' : 'Manual' + +resource graphqlProcessJob 'Microsoft.App/jobs@2024-10-02-preview' = { + name: jobName + location: location + tags: tags + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${managedIdentityId}': {} + } + } + properties: { + environmentId: containerAppsEnvironmentId + workloadProfileName: 'default' + configuration: { + triggerType: triggerType + replicaTimeout: 1800 // 30 minutes, ample time for GraphQL sync + replicaRetryLimit: 0 + registries: [ + { + server: containerRegistryServer + identity: managedIdentityId + } + ] + secrets: [ + { + name: 'app-insights-connection-string' + value: applicationInsightsConnectionString + } + ] + scheduleTriggerConfig: triggerType == 'Schedule' ? { + cronExpression: cronExpression + parallelism: 1 + replicaCompletionCount: 1 + } : null + manualTriggerConfig: triggerType == 'Manual' ? { + parallelism: 1 + replicaCompletionCount: 1 + } : null + } + template: { + containers: [ + { + name: 'graphql-process-job' + image: '${containerRegistryServer}/${imageName}:${imageTag}' + resources: { + cpu: json('1.0') + memory: '2Gi' + } + env: concat( + [ + { + name: 'AZURE_CLIENT_ID' + value: managedIdentityClientId + } + { + name: 'OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES' + value: 'true' + } + { + name: 'OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES' + value: 'true' + } + { + name: 'OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY' + value: 'in_memory' + } + { + name: 'APPLICATIONINSIGHTS_CONNECTION_STRING' + secretRef: 'app-insights-connection-string' + } + ], + graphqlProcessJobConfigurationEnvironment + ) + } + ] + } + } +} + +output jobName string = graphqlProcessJob.name +output jobId string = graphqlProcessJob.id diff --git a/infra/stacks/api-batch-processor/README.md b/infra/stacks/api-batch-processor/README.md index 72be52f4..4fac131c 100644 --- a/infra/stacks/api-batch-processor/README.md +++ b/infra/stacks/api-batch-processor/README.md @@ -4,16 +4,39 @@ This is the root for the planned batch-oriented architecture. It exists to reserve the stack boundary and deployment contract without introducing any dependency on `client-agent`. -`infra/stacks/api-batch-processor/subscription.bicep` is the stack-owned resource-group entrypoint for this stack, although no dedicated deploy workflow exists yet. +`infra/stacks/api-batch-processor/subscription.bicep` is the stack-owned resource-group entrypoint for this stack. -Current scope: +## Architecture Overview -- storage account for low-confidence match output -- blob private endpoint for storage access from the stack virtual network -- blob private DNS zone and virtual network link +The stack is designed as a secure, isolated batch processing environment, running: -The stack deliberately does not create Event Grid resources, storage queues, queue private endpoints, or queue private DNS. High-confidence matches are expected to be sent directly back to a GraphQL API by later application work. +- **GraphQL Process Job (`SUI.Client.GraphQLProcessJob`):** A containerized worker job designed to pull patient demographic data and synchronize match updates directly with an external GraphQL API. +- **Storage Account:** For storing low-confidence match output and diagnostic data. -The subscription entrypoint requires `containerAppVnet` and `containerAppPeSubnet` so the blob private endpoint can be placed in the stack virtual network. +### Stack Isolation & Security Components +The stack is fully self-contained and deploys all required networking and security infrastructure: +- **Virtual Network & Subnets:** Isolated subnets for both Container Apps environments (`containerAppEnvSubnet`) and Private Endpoints (`containerAppPeSubnet`). +- **Egress Firewall:** An isolated network firewall with predefined rule collections to securely restrict external internet egress to allowed registry data endpoints, monitoring endpoints, and verified NHS API domains. +- **User-Assigned Managed Identity:** For secure, passwordless authentication to the Azure Container Registry and other stack resources. +- **Azure Container Registry:** Stack-specific container registry hosting the job images. +- **Azure Key Vault:** Used for managing secure secrets and certificates. +- **Log Analytics Workspace & App Insights:** Central observability and telemetry storage for the stack's applications and jobs. -Follow-up work should add the scheduled processing, external API integration, identity, secret, and remaining networking resources required by this architecture directly to this stack. +--- + +## Deployments and Configuration + +The Container Apps Job can be deployed in two different triggers/modes depending on the desired operation: + +1. **`automatic` Mode:** + - Timed to run on a recurring schedule. + - Default schedule: **Every 3 hours during working hours** (`0 9,12,15 * * 1-5` UTC — Monday through Friday, 9:00 AM, 12:00 PM, and 3:00 PM). + - Fully customizable using the `cronExpression` Bicep parameter. + +2. **`manual` Mode:** + - No automatic timer runs. + - Deployed ready to be triggered on-demand via the Azure CLI, Azure Portal, or REST API for testing, validation, and debugging. + +### Custom Job Configuration + +Any job-specific environment variables or .NET configurations (such as GraphQL Url, TenantId, ClientId, ClientSecret, and Match API details) are supplied as a secure JSON object via the `graphqlProcessJobConfiguration` parameter. Keys in this dictionary are formatted as standard .NET environment variable names (e.g., `GraphQLProcessJob__Url`). diff --git a/infra/stacks/api-batch-processor/main.bicep b/infra/stacks/api-batch-processor/main.bicep index 5348c604..bb60ccaf 100644 --- a/infra/stacks/api-batch-processor/main.bicep +++ b/infra/stacks/api-batch-processor/main.bicep @@ -1,6 +1,7 @@ targetScope = 'resourceGroup' @minLength(1) +@maxLength(64) @description('Name of the deployment environment used for stack resource naming') param environmentName string @@ -8,34 +9,156 @@ param environmentName string @description('The prefix used for all deployed resources') param environmentPrefix string -@description('The location used for all deployed resources') -param location string = resourceGroup().location +@minLength(1) +@description('The version number of the container app managed environment, used for naming convention') +param containerAppManagedEnvironmentNumber string @minLength(1) @description('The address prefix for the virtual network') param containerAppVnet string +@minLength(1) +@description('Container App environment subnet') +param containerAppEnvSubnet string + @minLength(1) @description('The address prefix for the private endpoint subnet') param containerAppPeSubnet string +@minLength(1) +@description('The location used for all deployed resources') +param location string = resourceGroup().location + +@minLength(1) +@description('The email address to be used for monitoring alerts') +param monitoringActionGroupEmail string + +@description('Turn on monitoring alerts') +param turnOnAlerts bool = false + +@minLength(1) +@description('Container image tag for the GraphQL process job') +param graphqlProcessJobImageTag string = 'latest' + +@allowed([ + 'automatic' + 'manual' +]) +@description('The deployment mode for the job: automatic (scheduled) or manual (triggered on-demand)') +param deploymentMode string = 'manual' + +@description('The cron expression for the scheduled trigger when deploymentMode is automatic') +param cronExpression string = '0 9,12,15 * * 1-5' + +@description('Whether or not to include role assignments, since some environments may restrict these.') +param includeRoleAssignments bool = true + +@description('Optional value for the Environment tag when Azure Policy expects a different tag value than the deployment environment name.') +param tagEnvironmentName string = '' + +@description('Optional additional tags to apply to deployed resources.') +param additionalTags object = {} + +@secure() +@description('Runtime configuration values for the GraphQL process job.') +param graphqlProcessJobConfiguration object + var lowercaseEnvironmentName = toLower(environmentName) var stackNameSuffix = 'abp' -var noHyphensEnvironmentPrefix = replace(environmentPrefix, '-', '') -var storageAccountName = toLower('${take(noHyphensEnvironmentPrefix, 8)}${take(lowercaseEnvironmentName, 8)}${stackNameSuffix}${take(uniqueString(resourceGroup().id, noHyphensEnvironmentPrefix, lowercaseEnvironmentName), 5)}') -var tags = { +var isProductionEnvironment = lowercaseEnvironmentName == 'prod' || lowercaseEnvironmentName == 'production' +var allowedNhsFqdns = isProductionEnvironment ? [ + 'api.service.nhs.uk' +] : [ + 'int.api.service.nhs.uk' +] +var effectiveTagEnvironmentName = empty(tagEnvironmentName) ? environmentName : tagEnvironmentName + +var baseTags = { + 'azd-env-name': environmentName Product: 'SUI' - Environment: environmentName + Environment: effectiveTagEnvironmentName EnvironmentPrefix: environmentPrefix - 'Service Offering': 'SUI' Stack: 'api-batch-processor' } +var tags = union(baseTags, additionalTags) -module storage '../../modules/shared/storage-account.bicep' = { - name: 'api-batch-processor-storage' +var noHyphensEnvironmentPrefix = replace(environmentPrefix, '-', '') +var storageAccountName = toLower('${take(noHyphensEnvironmentPrefix, 8)}${take(lowercaseEnvironmentName, 8)}${stackNameSuffix}${take(uniqueString(resourceGroup().id, noHyphensEnvironmentPrefix, lowercaseEnvironmentName), 5)}') + +module identity '../../modules/shared/identity.bicep' = { + name: 'identity' params: { location: location - storageAccountName: storageAccountName + environmentPrefix: environmentPrefix + lowercaseEnvironmentName: lowercaseEnvironmentName + stackNameSuffix: stackNameSuffix + includeRoleAssignments: includeRoleAssignments + containerRegistryName: containerRegistry.outputs.name + tags: tags + } +} + +module containerRegistry '../../modules/shared/container-registry.bicep' = { + name: 'container-registry' + params: { + location: location + environmentPrefix: environmentPrefix + lowercaseEnvironmentName: lowercaseEnvironmentName + stackNameSuffix: stackNameSuffix + tags: tags + } +} + +module observability '../../modules/shared/observability.bicep' = { + name: 'observability' + params: { + location: location + environmentPrefix: environmentPrefix + lowercaseEnvironmentName: lowercaseEnvironmentName + stackNameSuffix: stackNameSuffix + tags: tags + } +} + +module secrets '../../modules/shared/secrets.bicep' = { + name: 'secrets' + params: { + location: location + environmentName: environmentName + environmentPrefix: environmentPrefix + stackNameSuffix: stackNameSuffix + tags: tags + } +} + +module keyVaultPrivateEndpoint '../../modules/shared/key-vault-private-endpoint.bicep' = { + name: 'secrets-private-endpoint' + params: { + location: location + tags: tags + keyVaultName: secrets.outputs.name + peSubnetId: containerAppNetwork.outputs.privateEndpointSubnetId + vnetId: containerAppNetwork.outputs.virtualNetworkId + } +} + +module egressFirewall '../../modules/shared/egress-firewall.bicep' = { + name: 'egress-firewall' + params: { + location: location + environmentName: environmentName + environmentPrefix: environmentPrefix + stackNameSuffix: stackNameSuffix + containerRegistryEndpoint: containerRegistry.outputs.endpoint + containerRegistryDataEndpointHostNames: containerRegistry.outputs.dataEndpointHostNames + applicationInsightsIngestionHost: observability.outputs.applicationInsightsIngestionHost + keyVaultName: secrets.outputs.name + allowKeyVaultPublicEgress: false + allowedNhsFqdns: allowedNhsFqdns + logAnalyticsWorkspaceId: observability.outputs.workspaceId + caeVnetAddressPrefixes: [ + containerAppVnet + ] tags: tags } } @@ -53,6 +176,56 @@ module containerAppNetwork '../../modules/shared/container-app-network.bicep' = } } +module caeFirewallPeering '../../modules/shared/virtual-network-peering.bicep' = { + name: 'cae-firewall-peering' + params: { + vnet1Name: containerAppNetwork.outputs.virtualNetworkName + vnet2Name: egressFirewall.outputs.firewallVnetName + vnet1ToVnet2PeeringName: 'peering-fw-01' + vnet2ToVnet1PeeringName: 'peering-cae-01' + vnet1AllowForwardedTraffic: true + vnet2AllowForwardedTraffic: true + } +} + +module containerAppEnvironment '../../modules/shared/container-app-environment.bicep' = { + name: 'container-app-environment' + params: { + location: location + environmentPrefix: environmentPrefix + lowercaseEnvironmentName: lowercaseEnvironmentName + stackNameSuffix: stackNameSuffix + containerAppManagedEnvironmentNumber: containerAppManagedEnvironmentNumber + virtualNetworkName: containerAppNetwork.outputs.virtualNetworkName + containerAppEnvSubnet: containerAppEnvSubnet + tags: tags + logAnalyticsWorkspaceName: observability.outputs.workspaceName + routeTableId: egressFirewall.outputs.routeTableId + } + dependsOn: [ + caeFirewallPeering + ] +} + +module monitoring '../../modules/shared/monitoring.bicep' = { + name: 'monitoring' + params: { + location: location + turnOnAlerts: turnOnAlerts + logAnalyticsWorkspaceId: observability.outputs.workspaceId + actionGroupEmail: monitoringActionGroupEmail + } +} + +module storage '../../modules/shared/storage-account.bicep' = { + name: 'api-batch-processor-storage' + params: { + location: location + storageAccountName: storageAccountName + tags: tags + } +} + module storagePrivateEndpoint '../../modules/shared/storage-private-endpoints.bicep' = { name: 'api-batch-processor-storage-private-endpoint' params: { @@ -68,9 +241,45 @@ module storagePrivateEndpoint '../../modules/shared/storage-private-endpoints.bi } } +module graphqlProcessJob '../../modules/api-batch-processor/graphql-process-job.bicep' = { + name: 'api-batch-processor-graphql-process-job' + params: { + location: location + environmentPrefix: environmentPrefix + lowercaseEnvironmentName: lowercaseEnvironmentName + containerAppsEnvironmentId: containerAppEnvironment.outputs.id + managedIdentityId: identity.outputs.id + managedIdentityClientId: identity.outputs.clientId + containerRegistryServer: containerRegistry.outputs.endpoint + imageTag: graphqlProcessJobImageTag + applicationInsightsConnectionString: observability.outputs.applicationInsightsConnectionString + graphqlProcessJobConfiguration: graphqlProcessJobConfiguration + deploymentMode: deploymentMode + cronExpression: cronExpression + tags: tags + } +} + output STACK_NAME string = 'api-batch-processor' output LOCATION string = location output TAGS object = tags +output MANAGED_IDENTITY_CLIENT_ID string = identity.outputs.clientId +output MANAGED_IDENTITY_NAME string = identity.outputs.name +output MANAGED_IDENTITY_PRINCIPAL_ID string = identity.outputs.principalId +output AZURE_LOG_ANALYTICS_WORKSPACE_NAME string = observability.outputs.workspaceName +output AZURE_LOG_ANALYTICS_WORKSPACE_ID string = observability.outputs.workspaceId +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = containerRegistry.outputs.endpoint +output AZURE_CONTAINER_REGISTRY_DATA_ENDPOINT_HOST_NAMES array = containerRegistry.outputs.dataEndpointHostNames +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = identity.outputs.id +output AZURE_CONTAINER_REGISTRY_NAME string = containerRegistry.outputs.name +output AZURE_CONTAINER_APPS_ENVIRONMENT_NAME string = containerAppEnvironment.outputs.name +output AZURE_CONTAINER_APPS_ENVIRONMENT_ID string = containerAppEnvironment.outputs.id +output AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN string = containerAppEnvironment.outputs.defaultDomain +output APPLICATION_INSIGHTS_CONNECTION_STRING string = observability.outputs.applicationInsightsConnectionString +output SECRETS_VAULTURI string = secrets.outputs.vaultUri +output SECRETS_VAULT_NAME string = secrets.outputs.name output STORAGE_ACCOUNT_NAME string = storage.outputs.accountName output STORAGE_ACCOUNT_ID string = storage.outputs.accountId output STORAGE_BLOB_ENDPOINT string = storage.outputs.blobEndpoint +output GRAPHQL_PROCESS_JOB_NAME string = graphqlProcessJob.outputs.jobName +output GRAPHQL_PROCESS_JOB_ID string = graphqlProcessJob.outputs.jobId diff --git a/infra/stacks/api-batch-processor/subscription.bicep b/infra/stacks/api-batch-processor/subscription.bicep index aa51e741..ec141a13 100644 --- a/infra/stacks/api-batch-processor/subscription.bicep +++ b/infra/stacks/api-batch-processor/subscription.bicep @@ -8,51 +8,137 @@ param environmentName string @description('The prefix used for all deployed resources and the resource-group naming convention') param environmentPrefix string -@description('The location used for all deployed resources') -param location string +@minLength(1) +@description('The version number of the container app managed environment, used for naming convention') +param containerAppManagedEnvironmentNumber string @minLength(1) @description('The address prefix for the virtual network') param containerAppVnet string +@minLength(1) +@description('Container App environment subnet') +param containerAppEnvSubnet string + @minLength(1) @description('The address prefix for the private endpoint subnet') param containerAppPeSubnet string +@minLength(1) +@description('The location used for all deployed resources') +param location string + +@minLength(1) +@description('The email address to be used for monitoring alerts') +param monitoringActionGroupEmail string + +@description('Turn on monitoring alerts') +param turnOnAlerts bool = false + +@minLength(1) +@description('Container image tag for the GraphQL process job') +param graphqlProcessJobImageTag string = 'latest' + +@allowed([ + 'automatic' + 'manual' +]) +@description('The deployment mode for the job: automatic (scheduled) or manual (triggered on-demand)') +param deploymentMode string = 'manual' + +@description('The cron expression for the scheduled trigger when deploymentMode is automatic') +param cronExpression string = '0 9,12,15 * * 1-5' + +@description('Whether or not to include role assignments, since some environments may restrict these.') +param includeRoleAssignments bool = true + +@allowed([ + 'create' + 'existing' +]) +@description('Whether the subscription wrapper should create the stack resource group or deploy into an existing one.') +param resourceGroupMode string = 'create' + +@description('The existing resource group name to deploy into when resourceGroupMode is existing.') +param targetResourceGroupName string = '' + +@description('Optional value for the Environment tag when Azure Policy expects a different tag value than the deployment environment name.') +param tagEnvironmentName string = '' + +@description('Optional additional tags to apply to deployed resources.') +param additionalTags object = {} + +@secure() +@description('Runtime configuration values for the GraphQL process job.') +param graphqlProcessJobConfiguration object + var lowercaseEnvironmentName = toLower(environmentName) var stackName = 'api-batch-processor' -var resourceGroupName = '${environmentPrefix}-${lowercaseEnvironmentName}-${stackName}' -var resourceGroupTags = { +var stackResourceGroupName = '${environmentPrefix}-${lowercaseEnvironmentName}-${stackName}' +var deploymentResourceGroupName = resourceGroupMode == 'existing' ? targetResourceGroupName : stackResourceGroupName +var effectiveTagEnvironmentName = empty(tagEnvironmentName) ? environmentName : tagEnvironmentName +var baseResourceGroupTags = { Product: 'SUI' - Environment: environmentName + Environment: effectiveTagEnvironmentName EnvironmentPrefix: environmentPrefix - 'Service Offering': 'SUI' Stack: stackName } +var resourceGroupTags = union(baseResourceGroupTags, additionalTags) -resource stackResourceGroup 'Microsoft.Resources/resourceGroups@2025-04-01' = { - name: resourceGroupName +resource stackResourceGroup 'Microsoft.Resources/resourceGroups@2025-04-01' = if (resourceGroupMode == 'create') { + name: stackResourceGroupName location: location tags: resourceGroupTags } module stackDeployment 'main.bicep' = { name: '${stackName}-deployment' - scope: stackResourceGroup + scope: resourceGroup(deploymentResourceGroupName) params: { environmentName: environmentName environmentPrefix: environmentPrefix - location: location + containerAppManagedEnvironmentNumber: containerAppManagedEnvironmentNumber containerAppVnet: containerAppVnet + containerAppEnvSubnet: containerAppEnvSubnet containerAppPeSubnet: containerAppPeSubnet + location: location + monitoringActionGroupEmail: monitoringActionGroupEmail + turnOnAlerts: turnOnAlerts + graphqlProcessJobImageTag: graphqlProcessJobImageTag + deploymentMode: deploymentMode + cronExpression: cronExpression + includeRoleAssignments: includeRoleAssignments + tagEnvironmentName: tagEnvironmentName + additionalTags: additionalTags + graphqlProcessJobConfiguration: graphqlProcessJobConfiguration } + dependsOn: [ + stackResourceGroup + ] } -output RESOURCE_GROUP_NAME string = stackResourceGroup.name -output RESOURCE_GROUP_ID string = stackResourceGroup.id +output RESOURCE_GROUP_NAME string = deploymentResourceGroupName +output RESOURCE_GROUP_ID string = subscriptionResourceId('Microsoft.Resources/resourceGroups', deploymentResourceGroupName) output STACK_NAME string = stackDeployment.outputs.STACK_NAME output LOCATION string = stackDeployment.outputs.LOCATION output TAGS object = stackDeployment.outputs.TAGS +output MANAGED_IDENTITY_CLIENT_ID string = stackDeployment.outputs.MANAGED_IDENTITY_CLIENT_ID +output MANAGED_IDENTITY_NAME string = stackDeployment.outputs.MANAGED_IDENTITY_NAME +output MANAGED_IDENTITY_PRINCIPAL_ID string = stackDeployment.outputs.MANAGED_IDENTITY_PRINCIPAL_ID +output AZURE_LOG_ANALYTICS_WORKSPACE_NAME string = stackDeployment.outputs.AZURE_LOG_ANALYTICS_WORKSPACE_NAME +output AZURE_LOG_ANALYTICS_WORKSPACE_ID string = stackDeployment.outputs.AZURE_LOG_ANALYTICS_WORKSPACE_ID +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = stackDeployment.outputs.AZURE_CONTAINER_REGISTRY_ENDPOINT +output AZURE_CONTAINER_REGISTRY_DATA_ENDPOINT_HOST_NAMES array = stackDeployment.outputs.AZURE_CONTAINER_REGISTRY_DATA_ENDPOINT_HOST_NAMES +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = stackDeployment.outputs.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID +output AZURE_CONTAINER_REGISTRY_NAME string = stackDeployment.outputs.AZURE_CONTAINER_REGISTRY_NAME +output AZURE_CONTAINER_APPS_ENVIRONMENT_NAME string = stackDeployment.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_NAME +output AZURE_CONTAINER_APPS_ENVIRONMENT_ID string = stackDeployment.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_ID +output AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN string = stackDeployment.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN +output APPLICATION_INSIGHTS_CONNECTION_STRING string = stackDeployment.outputs.APPLICATION_INSIGHTS_CONNECTION_STRING +output SECRETS_VAULTURI string = stackDeployment.outputs.SECRETS_VAULTURI +output SECRETS_VAULT_NAME string = stackDeployment.outputs.SECRETS_VAULT_NAME output STORAGE_ACCOUNT_NAME string = stackDeployment.outputs.STORAGE_ACCOUNT_NAME output STORAGE_ACCOUNT_ID string = stackDeployment.outputs.STORAGE_ACCOUNT_ID output STORAGE_BLOB_ENDPOINT string = stackDeployment.outputs.STORAGE_BLOB_ENDPOINT +output GRAPHQL_PROCESS_JOB_NAME string = stackDeployment.outputs.GRAPHQL_PROCESS_JOB_NAME +output GRAPHQL_PROCESS_JOB_ID string = stackDeployment.outputs.GRAPHQL_PROCESS_JOB_ID diff --git a/src/SUI.Client/SUI.Client.GraphQLProcessJob/Dockerfile b/src/SUI.Client/SUI.Client.GraphQLProcessJob/Dockerfile new file mode 100644 index 00000000..f6485a79 --- /dev/null +++ b/src/SUI.Client/SUI.Client.GraphQLProcessJob/Dockerfile @@ -0,0 +1,26 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +COPY global.json Directory.Build.props Directory.Packages.props NuGet.config ./ +COPY src/SUI.Client/SUI.Client.Core/SUI.Client.Core.csproj src/SUI.Client/SUI.Client.Core/ +COPY src/SUI.Client/SUI.Client.GraphQLProcessJob/SUI.Client.GraphQLProcessJob.csproj src/SUI.Client/SUI.Client.GraphQLProcessJob/ +RUN dotnet restore src/SUI.Client/SUI.Client.GraphQLProcessJob/SUI.Client.GraphQLProcessJob.csproj + +COPY src/SUI.Client/SUI.Client.Core/ src/SUI.Client/SUI.Client.Core/ +COPY src/SUI.Client/SUI.Client.GraphQLProcessJob/ src/SUI.Client/SUI.Client.GraphQLProcessJob/ +RUN dotnet publish src/SUI.Client/SUI.Client.GraphQLProcessJob/SUI.Client.GraphQLProcessJob.csproj \ + --configuration Release \ + --no-restore \ + --output /app/publish + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final +WORKDIR /app + +ENV DOTNET_ENVIRONMENT=Production + +HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \ + CMD echo "healthy" || exit 1 + +COPY --from=build /app/publish . +USER app +ENTRYPOINT ["dotnet", "SUI.Client.GraphQLProcessJob.dll"]