├── .gitignore ├── 1-deploy-service.sh ├── 2-start-chaos-fault.sh ├── 3-cleanup.sh ├── CONTRIBUTING.md ├── LICENSE ├── LICENSE.md ├── README.md ├── SECURITY.md ├── doc ├── API.md ├── SETUP.md └── img │ ├── image1.png │ └── image2.png ├── src ├── aksdeployment │ ├── app-namespace.yaml │ ├── container-azm-ms-agentconfig.yaml │ ├── controller-ingress-nginx.yaml │ └── deploy.sh ├── app │ └── Api │ │ ├── Api.csproj │ │ ├── Api.sln │ │ ├── Controllers │ │ ├── CartsController.cs │ │ ├── ConcertsController.cs │ │ ├── OrdersController.cs │ │ └── UsersController.cs │ │ ├── Dockerfile │ │ ├── Infrastructure │ │ ├── ApplicationInitializer.cs │ │ ├── CacheKeys.cs │ │ ├── DatabaseCleanupJob.cs │ │ ├── DatabaseContextHealthCheck.cs │ │ ├── Datastore │ │ │ ├── CachedCartRepository.cs │ │ │ ├── CachedConcertRepository.cs │ │ │ ├── DatabaseContext.cs │ │ │ ├── OrderRepository.cs │ │ │ ├── TicketRepository.cs │ │ │ └── UserRepository.cs │ │ ├── IDatabaseCleanupJob.cs │ │ ├── RedisCacheHealthCheck.cs │ │ └── SqlDbHealthCheck.cs │ │ ├── Middleware │ │ ├── AppInsightsMiddleware.cs │ │ └── HeaderEnrichmentMiddleware.cs │ │ ├── Models │ │ ├── Common │ │ │ ├── ErrorResponse.cs │ │ │ ├── ItemCollectionResponse.cs │ │ │ └── PagedResponse.cs │ │ ├── DTO │ │ │ ├── CartItemDto.cs │ │ │ ├── ConcertDto.cs │ │ │ ├── OrderDto.cs │ │ │ ├── OrderPaymentDetails.cs │ │ │ ├── TicketDto.cs │ │ │ ├── UserDto.cs │ │ │ └── UserRequest.cs │ │ └── Entities │ │ │ ├── Concert.cs │ │ │ ├── Order.cs │ │ │ ├── Ticket.cs │ │ │ └── User.cs │ │ ├── Program.cs │ │ ├── Properties │ │ └── launchSettings.json │ │ ├── Services │ │ ├── MappingProfile.cs │ │ ├── NonAtomicTicketPurchasingService.cs │ │ ├── Repositories │ │ │ ├── Exceptions │ │ │ │ └── NotFoundException.cs │ │ │ ├── ICartRepository.cs │ │ │ ├── IConcertRepository.cs │ │ │ ├── IOrderRepository.cs │ │ │ ├── ITicketRepository.cs │ │ │ └── IUserRepository.cs │ │ └── TicketPurchasingService.cs │ │ ├── Startup.cs │ │ ├── Telemetry │ │ ├── InfrastructureMetadata.cs │ │ ├── OperationBreadcrumb.cs │ │ ├── OperationStatus.cs │ │ ├── QueryStringConstants.cs │ │ └── TelemetryHelpers.cs │ │ ├── appsettings.Development.json │ │ ├── appsettings.json │ │ └── push-image.sh ├── chaos │ ├── chaosExperimentMain.bicep │ ├── chaosTarget.bicep │ ├── create-chaos-experiment.sh │ └── vmssRoleAssignment.bicep └── infra │ ├── acr.bicep │ ├── aks.bicep │ ├── appGateway.bicep │ ├── appInsights.bicep │ ├── azureSqlDatabase.bicep │ ├── deploy.sh │ ├── externalLoadBalancerIP.bicep │ ├── frontDoor.bicep │ ├── keyvault.bicep │ ├── logAnalytics.bicep │ ├── main.bicep │ ├── main.bicepparam │ ├── network.bicep │ ├── rbacGrantKeyVault.bicep │ ├── rbacGrantToAcr.bicep │ └── redisCache.bicep └── tests └── run-health-checks.sh /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | *\obj 3 | *\bin 4 | -------------------------------------------------------------------------------- /1-deploy-service.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Exit on errors 4 | set -o errexit -o pipefail -o noclobber 5 | SCRIPT_PATH=$( cd "$(dirname "${BASH_SOURCE[0]}")" ; pwd -P ) 6 | 7 | __usage=" 8 | To deploy a new environment, run this script with the location where you want to deploy the service to. 9 | A new resource group will be created in this location. 10 | Usage: $0 [-sl=service-location] 11 | -sl, --service-location Location to deploy the service to. A new resource group will be created in this location 12 | -h, --help Show this help message 13 | 14 | Examples: 15 | $0 -sl=eastus 16 | 17 | To apply changes to existing environment, run the script without any argument and it will use the existing resource group from .env file. 18 | Examples: 19 | $0 20 | " 21 | 22 | # Parse command line arguments 23 | for ARG in "$@"; do 24 | case $ARG in 25 | -sl=*|--service-location=*) 26 | SERVICE_LOCATION="${ARG#*=}" 27 | shift 28 | ;; 29 | -h|--help) 30 | echo "$__usage" 31 | exit 0 32 | ;; 33 | -*|--*) 34 | echo "Unknown argument '$ARG'" >&2 35 | exit 1 36 | ;; 37 | *) 38 | ;; 39 | esac 40 | done 41 | 42 | # Validate input, set defaults, and create resource group if needed 43 | if [ -z $SERVICE_LOCATION ]; then 44 | if [[ -f $SCRIPT_PATH/.env ]]; then 45 | source $SCRIPT_PATH/.env 46 | echo "Using existing resource group: '$RESOURCE_GROUP_NAME' from .env file" 47 | else 48 | echo "No service location provided and there is not .env file. Please provide a service location. Sample usage '$0 -sl=eastus'. Run with '--help' for more information" >&2 49 | exit 1 50 | fi 51 | else 52 | echo "Deploying to location: $SERVICE_LOCATION" 53 | GUID=$(uuidgen) 54 | RESOURCE_GROUP_NAME="refapp-public-${GUID:0:6}" 55 | 56 | echo "Deploying to new resource group: '$RESOURCE_GROUP_NAME'" 57 | az group create --name $RESOURCE_GROUP_NAME --location $SERVICE_LOCATION 58 | rm -rf $SCRIPT_PATH/.env 59 | echo "Saving resource group name to .env file" 60 | echo "RESOURCE_GROUP_NAME=$RESOURCE_GROUP_NAME" > $SCRIPT_PATH/.env 61 | fi 62 | 63 | 64 | echo "Deploy Az Ref App Infrastructure to $RESOURCE_GROUP_NAME" 65 | bash $SCRIPT_PATH/src/infra/deploy.sh -rg=$RESOURCE_GROUP_NAME 66 | 67 | 68 | echo "Fetching ACR URI and Id" 69 | 70 | ACR_URI=$(az acr list --resource-group $RESOURCE_GROUP_NAME --query "[0].loginServer" -o tsv) 71 | ACR_ID=$(az acr list --resource-group $RESOURCE_GROUP_NAME --query "[0].id" -o tsv) 72 | LOGGED_IN_USER=$(az ad signed-in-user show --query "id" -o tsv) 73 | echo "ACR URI: $ACR_URI" 74 | 75 | 76 | echo "Adding Container Registry Repository Contributor role to user" 77 | az role assignment create --assignee "$LOGGED_IN_USER" \ 78 | --role "Container Registry Repository Contributor" \ 79 | --scope "$ACR_ID" 80 | 81 | 82 | echo "Adding AKS Azure Kubernetes Service RBAC Cluster Admin to user" 83 | AKS_ID=$(az aks list --resource-group $RESOURCE_GROUP_NAME --query "[0].id" -o tsv) 84 | 85 | az role assignment create --assignee "$LOGGED_IN_USER" \ 86 | --role "Azure Kubernetes Service RBAC Cluster Admin" \ 87 | --scope "$AKS_ID" 88 | 89 | 90 | echo "Push the image to ACR" 91 | 92 | bash $SCRIPT_PATH/src/app/Api/push-image.sh -tag=latest -acr=$ACR_URI 93 | 94 | echo "Deploy AKS services to cluster" 95 | 96 | bash $SCRIPT_PATH/src/aksdeployment/deploy.sh -rg=$RESOURCE_GROUP_NAME 97 | 98 | echo "Deploy Chaos Resources" 99 | 100 | bash $SCRIPT_PATH/src/chaos/create-chaos-experiment.sh -rg=$RESOURCE_GROUP_NAME 101 | 102 | echo "Fetching Azure Front Door endpoint" 103 | FRONT_DOOR_PROFILE=$(az afd profile list --resource-group $RESOURCE_GROUP_NAME --query "[0].name" -o tsv) 104 | FRONT_DOOR_HOSTNAME=$(az afd endpoint list --profile-name $FRONT_DOOR_PROFILE --resource-group $RESOURCE_GROUP_NAME --query "[0].hostName" -o tsv) 105 | 106 | echo "Health check for $FRONT_DOOR_HOSTNAME" 107 | 108 | bash $SCRIPT_PATH/tests/run-health-checks.sh --host=$FRONT_DOOR_HOSTNAME 109 | 110 | echo "Deployment completed successfully" 111 | 112 | -------------------------------------------------------------------------------- /2-start-chaos-fault.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Exit on errors 4 | set -o errexit -o pipefail -o noclobber 5 | 6 | SCRIPT_PATH=$( cd "$(dirname "${BASH_SOURCE[0]}")" ; pwd -P ) 7 | 8 | if [[ -f $SCRIPT_PATH/.env ]]; then 9 | source $SCRIPT_PATH/.env 10 | else 11 | echo "No .env file found. Please run 1-deploy-service.sh first" 12 | exit 1 13 | fi 14 | 15 | DEFAULT_DOMAIN=$(az rest --method get --url https://graph.microsoft.com/v1.0/domains --query 'value[?isDefault].id' -o tsv) 16 | SUBSCRIPTION_ID=$(az account show --query id -o tsv) 17 | 18 | echo "Fetching AKS cluster name from resource group '$RESOURCE_GROUP_NAME'" 19 | VMSS_RESOURCE_GROUP=$(az aks list --resource-group "$RESOURCE_GROUP_NAME" --query "[0].nodeResourceGroup" -o tsv) 20 | 21 | echo "Fetching VMSS name from resource group '$VMSS_RESOURCE_GROUP'" 22 | VMSS_NAME=$(az vmss list --resource-group "$VMSS_RESOURCE_GROUP" --query "[?contains(name, 'aks-user')] | [0].name" -o tsv) 23 | 24 | 25 | az rest --method post --uri https://management.azure.com/subscriptions/$SUBSCRIPTION_ID/resourceGroups/$RESOURCE_GROUP_NAME/providers/Microsoft.Chaos/experiments/VMSS-ZoneDown-Experiment-$VMSS_NAME/start?api-version=2023-11-01 26 | 27 | echo "Waiting for the Chaos experiment to be active" 28 | 29 | 30 | while true; do 31 | STATUS=$(az rest --method get --uri https://management.azure.com/subscriptions/$SUBSCRIPTION_ID/resourceGroups/$RESOURCE_GROUP_NAME/providers/Microsoft.Chaos/experiments/VMSS-ZoneDown-Experiment-$VMSS_NAME/executions?api-version=2023-11-01 --query 'value | [0].properties.status' -o tsv) 32 | if [[ "$STATUS" == "Running" || "$STATUS" == "Success" || "$STATUS" == "Failed" ]]; then 33 | echo "Chaos experiment is now $STATUS" 34 | break 35 | else 36 | echo "Chaos experiment status: $STATUS. Checking again in 5 seconds..." 37 | sleep 5 38 | fi 39 | done 40 | 41 | if [[ "$STATUS" == "Running" ]]; then 42 | 43 | echo "Fetching Azure Front Door endpoint" 44 | FRONT_DOOR_PROFILE=$(az afd profile list --resource-group $RESOURCE_GROUP_NAME --query "[0].name" -o tsv) 45 | FRONT_DOOR_HOSTNAME=$(az afd endpoint list --profile-name $FRONT_DOOR_PROFILE --resource-group $RESOURCE_GROUP_NAME --query "[0].hostName" -o tsv) 46 | 47 | echo "Health check for $FRONT_DOOR_HOSTNAME" 48 | 49 | bash $SCRIPT_PATH/tests/run-health-checks.sh --host=$FRONT_DOOR_HOSTNAME 50 | else 51 | echo "Chaos experiment is not Running. Exiting..." 52 | exit 1 53 | fi -------------------------------------------------------------------------------- /3-cleanup.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Exit on errors 4 | set -o errexit -o pipefail -o noclobber 5 | 6 | SCRIPT_PATH=$( cd "$(dirname "${BASH_SOURCE[0]}")" ; pwd -P ) 7 | 8 | if [[ -f $SCRIPT_PATH/.env ]]; then 9 | source $SCRIPT_PATH/.env 10 | else 11 | echo "No .env file found. Please run 1-deploy-service.sh first" 12 | exit 1 13 | fi 14 | 15 | echo "Cleaning up resources" 16 | az group delete --name $RESOURCE_GROUP_NAME --yes 17 | 18 | echo "Deleting .env file" 19 | rm -rf $SCRIPT_PATH/.env -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | This project welcomes contributions and suggestions. Most contributions require you to agree to a 4 | Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us 5 | the rights to use your contribution. For details, visit . 6 | 7 | When you submit a pull request, a CLA bot will automatically determine whether you need to provide 8 | a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions 9 | provided by the bot. You will only need to do this once across all repos using our CLA. 10 | 11 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 12 | For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or 13 | contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. 14 | 15 | - [Code of Conduct](#code-of-conduct) 16 | - [Found an Issue?](#found-an-issue) 17 | - [Want a Feature?](#want-a-feature) 18 | - [Submission Guidelines](#submission-guidelines) 19 | - [Submitting an Issue](#submitting-an-issue) 20 | - [Submitting a Pull Request (PR)](#submitting-a-pull-request-pr) 21 | - [Setting up the development environment](#setting-up-the-development-environment) 22 | - [Running unit tests](#running-unit-tests) 23 | - [Running E2E tests](#running-e2e-tests) 24 | - [Code Style](#code-style) 25 | 26 | ## Code of Conduct 27 | 28 | Help us keep this project open and inclusive. Please read and follow our [Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 29 | 30 | ## Found an Issue? 31 | 32 | If you find a bug in the source code or a mistake in the documentation, you can help us by 33 | [submitting an issue](#submitting-an-issue) to the GitHub Repository. Even better, you can 34 | [submit a Pull Request](#submitting-a-pull-request-pr) with a fix. 35 | 36 | ## Want a Feature? 37 | 38 | You can *request* a new feature by [submitting an issue](#submitting-an-issue) to the GitHub 39 | Repository. If you would like to *implement* a new feature, please submit an issue with 40 | a proposal for your work first, to be sure that we can use it. 41 | 42 | - **Small Features** can be crafted and directly [submitted as a Pull Request](#submitting-a-pull-request-pr). 43 | 44 | ## Submission Guidelines 45 | 46 | ### Submitting an Issue 47 | 48 | Before you submit an issue, search the archive, maybe your question was already answered. 49 | 50 | If your issue appears to be a bug, and hasn't been reported, open a new issue. 51 | Help us to maximize the effort we can spend fixing issues and adding new 52 | features, by not reporting duplicate issues. Providing the following information will increase the 53 | chances of your issue being dealt with quickly: 54 | 55 | - **Overview of the Issue** - if an error is being thrown a non-minified stack trace helps 56 | - **Version** - what version is affected (e.g. 0.1.2) 57 | - **Motivation for or Use Case** - explain what are you trying to do and why the current behavior is a bug for you 58 | - **Browsers and Operating System** - is this a problem with all browsers? 59 | - **Reproduce the Error** - provide a live example or a unambiguous set of steps 60 | - **Related Issues** - has a similar issue been reported before? 61 | - **Suggest a Fix** - if you can't fix the bug yourself, perhaps you can point to what might be 62 | causing the problem (line of code or commit) 63 | 64 | You can file new issues by providing the above information at the corresponding repository's issues link: ]/[repository-name]/issues/new]. 65 | 66 | ### Submitting a Pull Request (PR) 67 | 68 | Before you submit your Pull Request (PR) consider the following guidelines: 69 | 70 | - Search the repository (]/[repository-name]/pulls) for an open or closed PR 71 | that relates to your submission. You don't want to duplicate effort. 72 | - Make your changes in a new git fork 73 | - Follow [Code style conventions](#code-style) 74 | - [Run the tests](#running-unit-tests) (and write new ones, if needed) 75 | - Commit your changes using a descriptive commit message 76 | - Push your fork to GitHub 77 | - In GitHub, create a pull request to the `main` branch of the repository 78 | - Ask a maintainer to review your PR and address any comments they might have 79 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Microsoft 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Azure Samples 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Security 4 | 5 | Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/Microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin), and [our GitHub organizations](https://opensource.microsoft.com/). 6 | 7 | If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](), please report it to us as described below. 8 | 9 | ## Reporting Security Issues 10 | 11 | **Please do not report security vulnerabilities through public GitHub issues.** 12 | 13 | Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://msrc.microsoft.com/create-report). 14 | 15 | If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://www.microsoft.com/msrc/pgp-key-msrc). 16 | 17 | You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://www.microsoft.com/msrc). 18 | 19 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: 20 | 21 | - Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) 22 | - Full paths of source file(s) related to the manifestation of the issue 23 | - The location of the affected source code (tag/branch/commit or direct URL) 24 | - Any special configuration required to reproduce the issue 25 | - Step-by-step instructions to reproduce the issue 26 | - Proof-of-concept or exploit code (if possible) 27 | - Impact of the issue, including how an attacker might exploit the issue 28 | 29 | This information will help us triage your report more quickly. 30 | 31 | If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://microsoft.com/msrc/bounty) page for more details about our active programs. 32 | 33 | ## Preferred Languages 34 | 35 | We prefer all communications to be in English. 36 | 37 | ## Policy 38 | 39 | Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://www.microsoft.com/msrc/cvd). 40 | 41 | -------------------------------------------------------------------------------- /doc/API.md: -------------------------------------------------------------------------------- 1 | # API Documentation 2 | 3 | ## General description 4 | 5 | The API provides a an approach to the operations available on any usual e-commerce platform for ticket sales. The typical client workflow, enabled by this API's endpoints, would look something like: 6 | 7 | 1. Create a new user. 8 | 2. Retrieve a list of all upcoming concerts. 9 | 3. Add tickets to the cart. 10 | 4. Create a new order by checking out the tickets in the cart. 11 | 5. Retrieve all orders associated with the user. 12 | 13 | Please note that orders are created based on the current state of the cart; items present in the cart at the time of order creation will be included in the order. 14 | 15 | When a new order is created (a check-out operation is triggered), the following takes place: 16 | 17 | 1. All the tickets found in the user's cart are created in the DB -- a ticket entity for each. 18 | 2. A new order entity is created in the DB, linked to all the tickets created. 19 | 3. The payment is processed. 20 | 4. The cart is cleared. 21 | 22 | ## The API 23 | 24 | ### Carts 25 | 26 | #### PUT /api/users/{userId}/Carts 27 | ##### Parameters 28 | 29 | | Name | Located in | Description | Required | Schema | 30 | | ---- | ---------- | ----------- | -------- | ---- | 31 | | userId | path | Puts a new item ({concertId}-{quantity} mapping) in the cart. | Yes | string | 32 | 33 | ##### Responses 34 | 35 | | Code | Description | 36 | | ---- | ----------- | 37 | | 200 | OK | 38 | | 404 | Not Found | 39 | 40 | #### GET /api/users/{userId}/Carts 41 | ##### Parameters 42 | 43 | | Name | Located in | Description | Required | Schema | 44 | | ---- | ---------- | ----------- | -------- | ---- | 45 | | userId | path | The ID of the user whose cart to retrieve. | Yes | string | 46 | 47 | ##### Responses 48 | 49 | | Code | Description | 50 | | ---- | ----------- | 51 | | 200 | OK | 52 | 53 | #### DELETE /api/users/{userId}/Carts 54 | ##### Parameters 55 | 56 | | Name | Located in | Description | Required | Schema | 57 | | ---- | ---------- | ----------- | -------- | ---- | 58 | | userId | path | The ID of the user whose cart to clear. | Yes | string | 59 | 60 | ##### Responses 61 | 62 | | Code | Description | 63 | | ---- | ----------- | 64 | | 200 | OK | 65 | 66 | ### Concerts 67 | 68 | #### GET /api/Concerts 69 | ##### Parameters 70 | 71 | | Name | Located in | Description | Required | Schema | 72 | | ---- | ---------- | ----------- | -------- | ---- | 73 | | take | query | The endpoint returns a paged result. Specifies the size of the page (# concerts to retrieve). | No | integer | 74 | 75 | ##### Responses 76 | 77 | | Code | Description | 78 | | ---- | ----------- | 79 | | 200 | OK | 80 | 81 | #### GET /api/Concerts/{concertId} 82 | ##### Parameters 83 | 84 | | Name | Located in | Description | Required | Schema | 85 | | ---- | ---------- | ----------- | -------- | ---- | 86 | | concertId | path | The ID of the concert to retrieve. | Yes | string | 87 | 88 | ##### Responses 89 | 90 | | Code | Description | 91 | | ---- | ----------- | 92 | | 200 | OK | 93 | | 404 | Not Found | 94 | 95 | ### Orders 96 | 97 | #### POST /api/users/{userId}/Orders 98 | ##### Parameters 99 | 100 | | Name | Located in | Description | Required | Schema | 101 | | ---- | ---------- | ----------- | -------- | ---- | 102 | | userId | path | The ID of the user initiating a new order. | Yes | string | 103 | 104 | ##### Responses 105 | 106 | | Code | Description | 107 | | ---- | ----------- | 108 | | 201 | Created | 109 | | 400 | Bad Request | 110 | | 404 | Not Found | 111 | 112 | #### GET /api/users/{userId}/Orders 113 | ##### Parameters 114 | 115 | | Name | Located in | Description | Required | Schema | 116 | | ---- | ---------- | ----------- | -------- | ---- | 117 | | userId | path | The ID of the user whose orders to retrieve. | Yes | string | 118 | | skip | query | The endpoint returns a paged result. Specifies the number of orders, ordered by time, to skip. | No | integer | 119 | | take | query | The endpoint returns a paged result. Specifies the size of the page (# orders to retrieve). | No | integer | 120 | 121 | ##### Responses 122 | 123 | | Code | Description | 124 | | ---- | ----------- | 125 | | 200 | OK | 126 | | 404 | Not Found | 127 | 128 | #### GET /api/users/{userId}/Orders/{orderId} 129 | ##### Parameters 130 | 131 | | Name | Located in | Description | Required | Schema | 132 | | ---- | ---------- | ----------- | -------- | ---- | 133 | | orderId | path | The ID of the order to retrieve. | Yes | string | 134 | | userId | path | The ID of the user whose order to retrieve. | Yes | string | 135 | 136 | ##### Responses 137 | 138 | | Code | Description | 139 | | ---- | ----------- | 140 | | 200 | OK | 141 | | 404 | Not Found | 142 | 143 | ### Users 144 | 145 | #### GET /api/Users/{userId} 146 | ##### Parameters 147 | 148 | | Name | Located in | Description | Required | Schema | 149 | | ---- | ---------- | ----------- | -------- | ---- | 150 | | userId | path | The ID of the user to retrieve. | Yes | string | 151 | 152 | ##### Responses 153 | 154 | | Code | Description | 155 | | ---- | ----------- | 156 | | 200 | OK | 157 | | 404 | Not Found | 158 | 159 | #### PATCH: /api/Users/{userId} 160 | ##### Parameters 161 | 162 | | Name | Located in | Description | Required | Schema | 163 | | ---- | ---------- | ----------- | -------- | ---- | 164 | | userId | path | The ID of the user to update. | Yes | string | 165 | 166 | ##### Responses 167 | 168 | | Code | Description | 169 | | ---- | ----------- | 170 | | 202 | Accepted | 171 | | 400 | Bad Request | 172 | | 404 | Not Found | 173 | 174 | #### POST /api/Users 175 | ##### Responses 176 | 177 | | Code | Description | 178 | | ---- | ----------- | 179 | | 201 | Created | 180 | | 400 | Bad Request | 181 | 182 | 183 | ## Tracking & Telemetry 184 | 185 | All API requests accept query parameters for tracking. The provided values are captured and reported in the logs published to Azure Application Insights. The query parameters in question are: 186 | | Query Parameter Key | Type | Description | 187 | | ---- | ---- | ----------- | 188 | | SESSION_ID | string | The session ID, used to correlate multiple subsequent requests together. | 189 | | REQUEST_ID | string | The request ID, used to uniquely identify a client's request. | 190 | | RETRY_COUNT | int | The number of the retry attempt, corellating to the client's retry strategy. | 191 | 192 | All API responses also report, as part of their headers, the following: 193 | | Header | Description | 194 | | ---- | ----------- | 195 | | AzRef-AppGwIp | The IP of the Application Gateway that has handled & routed the request. | 196 | | AzRef-NodeIp | The private IP of the node that has handled the request. | 197 | | AzRef-PodName | The name of the Kubernetes pod that has handled the request. | 198 | 199 | ## Test environment 200 | The API requires a Redis Cache and a Database to run. To run the webapp locally, there are two options of provisioning the necessary datastore resources: 201 | 202 | ### 1. Self-host in local Docker containers 203 | 204 | Run the `docker-compose-test.yml` file in the `test` folder found in the root of the repository. To start the webapp, along with self-hosted SQL Server and Redis Cache containers, run: 205 | ```bash 206 | docker-compose -f docker-compose-test.yml up -d 207 | ``` 208 | 209 | The API is ready to process requests. See the SwaggerUI at `localhost:8080/api/swagger` for the available endpoints. 210 | 211 | When you're done testing, stop the containers with: 212 | ```bash 213 | docker-compose -f docker-compose-test.yml down 214 | ``` 215 | 216 | You can also run the health check to quickly validate the API works as expected: 217 | ```bash 218 | bash run-healthcheck.sh 219 | ``` 220 | 221 | ### 2. Link local webapp to datastore hosted in Azure 222 | 223 | 1. Create a SQL Server Database and a Redis Cache in Azure. **Note:** disable, during creation, the authentication through SAS tokens / connection strings. 224 | 2. Make sure you assign your identity the necessary permissions to access the resources: 225 | - For the SQL Server, during creation, set your identity as the DB admin. On the first page of the SQL Server creation wizard: 226 | - Select the **"Use Microsoft Entra-only authentication"** option. 227 | - Add your identity as the admin by selecting the **"Set admin"** next to the **"Set Microsoft Entra admin"**. Set your identity as the admin. 228 | - Additionally, go to the **"Networking"** blade in the portal and select **"Selected networks"** for **"Public network access"**. Then **"Add your client IPv4 address"** from just below to enable network access from your local instance of the App to the DB. 229 | - For the Redis Cache, after creation, go to the **"Data Access Configuration"** blade in the portal and **"Add"** a new Redis user. 230 | - For the **"Role"**, select **"Data Contributor"**. 231 | - For the **"User"**, select your identity (your `@microsoft` alias). 232 | 3. Set the following environment variables (either directly, or through `.env`/`launchSettings.json`): 233 | - `"REDIS_ENDPOINT"`: "{RedisName}.redis.cache.windows.net", 234 | - `"SQL_ENDPOINT"`: "{SqlServerName}.database.windows.net", 235 | - `"SQL_APP_DATABASE_NAME"`: "{DataBaseName}", 236 | 4. Run the `Api.sln` solution to start the API on `localhost:51277`. 237 | 5. Login, when prompted through the browser, with your `@microsoft` account. 238 | 239 | The API is ready to process requests. See the SwaggerUI at `localhost:51277/api/swagger` for the available endpoints. 240 | -------------------------------------------------------------------------------- /doc/SETUP.md: -------------------------------------------------------------------------------- 1 | # Setting up the Resilient ECommerce Application 2 | 3 | The Resilient ECommerce Reference Application is a synthetic workload that mirrors a simple, bare-bones, e-commerce platform. The purpose of it is to demonstrate how to use Azure Resiliency best practices to achieve availability during zonal outages or components outages. 4 | 5 | ## Getting Started 6 | 7 | The automated deployment is designed to work with Linux/MACOS/WSL as all scripts are written in bash. Before running the deployment, the following prerequisites have to be met: 8 | 9 | Install Az Cli 10 | 11 | Install docker or 12 | 13 | Azure subscription where you have owner permissions 14 | 15 | ## Deploy test App 16 | 17 | Clone the git repo 18 | 19 | `git clone https://github.com/Azure/AzRefApp-Ecommerce` 20 | 21 | Login using Az Cli: `az login` (or any other variation, depending how you authenticate, e.g. --use-device-code) 22 | 23 | Select a subscription where you have owner permissions 24 | 25 | Ensure the OperationsManagement resource provider is registered 26 | 27 | `az provider register --namespace 'Microsoft.OperationsManagement'` 28 | 29 | Deploy using: 30 | 31 | ` ./1-deploy-service.sh --service-location=region ` 32 | 33 | Region code mapping can be found by running: `az account list-locations -o table` 34 | 35 | The output format looks like this: 36 | 37 | | DisplayName | Name | RegionalDisplayName | 38 | |--------------|------------|-----------------------------| 39 | | East US | eastus | (US) East US | 40 | | West Europe | westeurope | (Europe) West Europe | 41 | 42 | The script will take the name (e.g. westus, westeurope) as input. 43 | 44 | Start the Chaos Studio experiment that takes down AKS Zone 1 with: 45 | ` ./2-start-chaos-fault.sh ` 46 | 47 | The app saves the resource group where everything was deployed in a .env file which will be cleand-up at the end. 48 | 49 | To clean up the deployed resources: 50 | 51 | `./3-cleanup.sh` 52 | -------------------------------------------------------------------------------- /doc/img/image1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/resilient-ecommerce-reference-app/16d0f544e30a5a09dbfd354f2f926e178fdbaa18/doc/img/image1.png -------------------------------------------------------------------------------- /doc/img/image2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/resilient-ecommerce-reference-app/16d0f544e30a5a09dbfd354f2f926e178fdbaa18/doc/img/image2.png -------------------------------------------------------------------------------- /src/aksdeployment/app-namespace.yaml: -------------------------------------------------------------------------------- 1 | kind: Namespace 2 | apiVersion: v1 3 | metadata: 4 | name: app 5 | labels: 6 | name: app 7 | --- 8 | apiVersion: v1 9 | kind: ServiceAccount 10 | metadata: 11 | annotations: 12 | azure.workload.identity/client-id: $CLIENT_ID 13 | name: workload 14 | namespace: app 15 | --- 16 | apiVersion: secrets-store.csi.x-k8s.io/v1 17 | kind: SecretProviderClass 18 | metadata: 19 | name: app-secrets-provider 20 | namespace: app 21 | spec: 22 | provider: azure 23 | secretObjects: 24 | - data: 25 | - key: azure-sql-endpoint 26 | objectName: azure-sql-endpoint 27 | - key: sql-app-database-name 28 | objectName: sql-app-database-name 29 | - key: app-insights-connection-string 30 | objectName: app-insights-connection-string 31 | - key: redis-endpoint 32 | objectName: redis-endpoint 33 | secretName: webapp-secret 34 | type: Opaque 35 | parameters: 36 | usePodIdentity: "false" 37 | clientID: "${USER_ASSIGNED_CLIENT_ID}" 38 | keyvaultName: ${KEYVAULT_NAME} 39 | cloudName: "" 40 | objects: | 41 | array: 42 | - | 43 | objectName: azure-sql-endpoint 44 | objectType: secret 45 | objectVersion: "" # [OPTIONAL] object versions, default to latest if empty 46 | - | 47 | objectName: sql-app-database-name 48 | objectType: secret 49 | objectVersion: "" 50 | - | 51 | objectName: app-insights-connection-string 52 | objectType: secret 53 | objectVersion: "" 54 | - | 55 | objectName: redis-endpoint 56 | objectType: secret 57 | objectVersion: "" 58 | tenantId: "${IDENTITY_TENANT}" 59 | --- 60 | apiVersion: apps/v1 61 | kind: Deployment 62 | metadata: 63 | name: api-webapp 64 | namespace: app 65 | spec: 66 | replicas: 3 67 | selector: 68 | matchLabels: 69 | app: api-webapp 70 | template: 71 | metadata: 72 | labels: 73 | app: api-webapp 74 | azure.workload.identity/use: "true" 75 | spec: 76 | serviceAccountName: workload 77 | nodeSelector: 78 | agentpool: user 79 | topologySpreadConstraints: 80 | - maxSkew: 1 81 | topologyKey: kubernetes.io/hostname 82 | whenUnsatisfiable: DoNotSchedule 83 | labelSelector: 84 | matchLabels: 85 | app: api-webapp 86 | - maxSkew: 1 87 | topologyKey: kubernetes.io/zone 88 | whenUnsatisfiable: ScheduleAnyway 89 | labelSelector: 90 | matchLabels: 91 | app: api-webapp 92 | containers: 93 | - name: api-webapp 94 | image: $ACR_URI/api-webapp:latest 95 | resources: 96 | requests: 97 | memory: "5Gi" 98 | limits: 99 | memory: "5Gi" 100 | envFrom: 101 | - configMapRef: 102 | name: web-app-config 103 | imagePullPolicy: Always 104 | ports: 105 | - containerPort: 8080 106 | livenessProbe: 107 | httpGet: 108 | path: /api/live 109 | port: 8080 110 | initialDelaySeconds: 5 111 | periodSeconds: 20 112 | readinessProbe: 113 | httpGet: 114 | path: /api/live 115 | port: 8080 116 | initialDelaySeconds: 5 117 | periodSeconds: 20 118 | volumeMounts: 119 | - name: secrets-store01-inline 120 | mountPath: "/mnt/secrets-store" 121 | readOnly: true 122 | env: 123 | - name: APPLICATIONINSIGHTS_CONNECTION_STRING 124 | valueFrom: 125 | secretKeyRef: 126 | name: webapp-secret 127 | key: app-insights-connection-string 128 | - name: MY_NODE_NAME 129 | valueFrom: 130 | fieldRef: 131 | fieldPath: spec.nodeName 132 | - name: MY_POD_NAME 133 | valueFrom: 134 | fieldRef: 135 | fieldPath: metadata.name 136 | - name: MY_POD_NAMESPACE 137 | valueFrom: 138 | fieldRef: 139 | fieldPath: metadata.namespace 140 | - name: MY_POD_IP 141 | valueFrom: 142 | fieldRef: 143 | fieldPath: status.podIP 144 | - name: MY_NODE_IP 145 | valueFrom: 146 | fieldRef: 147 | fieldPath: status.hostIP 148 | - name: MY_POD_SERVICE_ACCOUNT 149 | valueFrom: 150 | fieldRef: 151 | fieldPath: spec.serviceAccountName 152 | - name: SQL_ENDPOINT 153 | valueFrom: 154 | secretKeyRef: 155 | name: webapp-secret 156 | key: azure-sql-endpoint 157 | - name: SQL_APP_DATABASE_NAME 158 | valueFrom: 159 | secretKeyRef: 160 | name: webapp-secret 161 | key: sql-app-database-name 162 | - name: REDIS_ENDPOINT 163 | valueFrom: 164 | secretKeyRef: 165 | name: webapp-secret 166 | key: redis-endpoint 167 | volumes: 168 | - name: secrets-store01-inline 169 | csi: 170 | driver: secrets-store.csi.k8s.io 171 | readOnly: true 172 | volumeAttributes: 173 | secretProviderClass: "app-secrets-provider" 174 | --- 175 | apiVersion: v1 176 | kind: Service 177 | metadata: 178 | name: api-webapp 179 | namespace: app 180 | spec: 181 | type: ClusterIP 182 | ports: 183 | - port: 80 184 | targetPort: 8080 185 | selector: 186 | app: api-webapp 187 | --- 188 | apiVersion: networking.k8s.io/v1 189 | kind: Ingress 190 | metadata: 191 | name: api-webapp 192 | namespace: app 193 | annotations: 194 | nginx.ingress.kubernetes.io/ssl-redirect: "false" 195 | spec: 196 | ingressClassName: nginx 197 | rules: 198 | - http: 199 | paths: 200 | - path: /api 201 | pathType: Prefix 202 | backend: 203 | service: 204 | name: api-webapp 205 | port: 206 | number: 80 207 | --- 208 | apiVersion: v1 209 | kind: ConfigMap 210 | metadata: 211 | name: web-app-config 212 | namespace: app 213 | data: 214 | DATABASE_CLEANUP_ENABLED: "true" 215 | DATABASE_CLEANUP_RECORD_COUNT: "1000" 216 | DATABASE_CLEANUP_THRESHOLD_MINUTES: "30" 217 | RPOL_CONNECT_RETRY: "3" 218 | RPOL_BACKOFF_DELTA: "500" 219 | Logging__LogLevel__OrderClient__Controllers__OrdersController: "Information" 220 | Logging__LogLevel__OrderClient__Controllers__UserController: "Information" 221 | -------------------------------------------------------------------------------- /src/aksdeployment/controller-ingress-nginx.yaml: -------------------------------------------------------------------------------- 1 | controller: 2 | replicaCount: 3 3 | 4 | nodeSelector: 5 | kubernetes.io/os: linux 6 | agentpool: user 7 | 8 | image: 9 | registry: mcr.microsoft.com 10 | image: oss/kubernetes/ingress/nginx-ingress-controller 11 | tag: v1.9.6-patched 12 | digest: "" 13 | 14 | admissionWebhooks: 15 | patch: 16 | nodeSelector: 17 | kubernetes.io/os: linux 18 | agentpool: user 19 | image: 20 | registry: mcr.microsoft.com 21 | image: oss/kubernetes/ingress/kube-webhook-certgen 22 | tag: v1.9.6 23 | digest: "" 24 | 25 | service: 26 | annotations: 27 | service.beta.kubernetes.io/azure-load-balancer-internal: "true" 28 | service.beta.kubernetes.io/azure-load-balancer-ipv4: $ILB_IP 29 | service.beta.kubernetes.io/azure-load-balancer-health-probe-request-path: /api/live 30 | externalTrafficPolicy: Local 31 | 32 | topologySpreadConstraints: 33 | - maxSkew: 1 34 | topologyKey: kubernetes.io/hostname 35 | whenUnsatisfiable: DoNotSchedule 36 | labelSelector: 37 | matchLabels: 38 | app.kubernetes.io/name: ingress-nginx 39 | - maxSkew: 1 40 | topologyKey: kubernetes.io/zone 41 | whenUnsatisfiable: ScheduleAnyway 42 | labelSelector: 43 | matchLabels: 44 | app.kubernetes.io/name: ingress-nginx 45 | -------------------------------------------------------------------------------- /src/aksdeployment/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Exit on errors 4 | set -o errexit -o pipefail -o noclobber 5 | 6 | # Parse command line arguments 7 | for ARG in "$@"; do 8 | case $ARG in 9 | -rg=*|--resource-group=*) 10 | RESOURCE_GROUP_NAME="${ARG#*=}" 11 | shift 12 | ;; 13 | -*|--*) 14 | echo "Unknown argument '$ARG'" >&2 15 | exit 1 16 | ;; 17 | *) 18 | ;; 19 | esac 20 | done 21 | 22 | # Validate command line arguments 23 | if [ -z $RESOURCE_GROUP_NAME ]; then 24 | echo "No resource group provided. Please provide a resource group name as command line argument. E.g. '$0 -rg=my-rg-name'" >&2 25 | exit 1 26 | fi 27 | 28 | SCRIPT_PATH=$( cd "$(dirname "${BASH_SOURCE[0]}")" ; pwd -P ) 29 | 30 | export AKS_CLUSTER_NAME=$(az aks list --resource-group $RESOURCE_GROUP_NAME --query "[0].name" -o tsv) 31 | if [ -z $AKS_CLUSTER_NAME ]; then 32 | echo "No AKS cluster found in resource group '$RESOURCE_GROUP_NAME'" >&2 33 | exit 1 34 | fi 35 | 36 | 37 | export ILB_IP="10.0.3.250" 38 | export APP_IDENTITY_NAME=$(az identity list --resource-group $RESOURCE_GROUP_NAME --query "[0].name" -o tsv) 39 | export AKS_OIDC_ISSUER=$(az aks show -n ${AKS_CLUSTER_NAME} -g $RESOURCE_GROUP_NAME --query "oidcIssuerProfile.issuerUrl" -o tsv) 40 | export CLIENT_ID=$(az identity show --resource-group $RESOURCE_GROUP_NAME --name $APP_IDENTITY_NAME --query "clientId" -o tsv) 41 | export KEYVAULT_NAME=$(az keyvault list --resource-group $RESOURCE_GROUP_NAME --query "[0].name" -o tsv) 42 | export USER_ASSIGNED_CLIENT_ID=$(az identity show --resource-group $RESOURCE_GROUP_NAME --name $APP_IDENTITY_NAME --query "clientId" -o tsv) 43 | export IDENTITY_TENANT=$(az account show --query "tenantId" -o tsv) 44 | 45 | echo "Fetching ACR URI" 46 | export ACR_URI=$(az acr list --resource-group $RESOURCE_GROUP_NAME --query "[0].loginServer" -o tsv) 47 | echo "ACR URI: $ACR_URI" 48 | 49 | rm -rf $SCRIPT_PATH/.tmp 50 | mkdir $SCRIPT_PATH/.tmp 51 | 52 | echo "Step 1 - Creating ingress" 53 | envsubst < $SCRIPT_PATH/controller-ingress-nginx.yaml > $SCRIPT_PATH/.tmp/controller-ingress-nginx-processed.yaml 54 | az aks command invoke --name $AKS_CLUSTER_NAME \ 55 | --resource-group $RESOURCE_GROUP_NAME \ 56 | --command 'helm upgrade --install ingress-nginx ingress-nginx \ 57 | --repo https://kubernetes.github.io/ingress-nginx \ 58 | --namespace ingress-controller \ 59 | --version 4.9.1 \ 60 | --wait-for-jobs \ 61 | --debug \ 62 | --create-namespace \ 63 | -f controller-ingress-nginx-processed.yaml' \ 64 | --file $SCRIPT_PATH/.tmp/controller-ingress-nginx-processed.yaml 65 | 66 | echo "Step 2 - Creating app namespace" 67 | envsubst < $SCRIPT_PATH/app-namespace.yaml > $SCRIPT_PATH/.tmp/app-namespace-processed.yaml 68 | az aks command invoke --name $AKS_CLUSTER_NAME \ 69 | --resource-group $RESOURCE_GROUP_NAME \ 70 | --command 'kubectl apply -f app-namespace-processed.yaml' \ 71 | --file $SCRIPT_PATH/.tmp/app-namespace-processed.yaml 72 | 73 | az identity federated-credential create \ 74 | --name $APP_IDENTITY_NAME \ 75 | --identity-name $APP_IDENTITY_NAME \ 76 | --resource-group $RESOURCE_GROUP_NAME \ 77 | --issuer $AKS_OIDC_ISSUER \ 78 | --subject system:serviceaccount:app:workload 79 | 80 | echo "Step 3 - Container insights config" 81 | az aks command invoke --name $AKS_CLUSTER_NAME \ 82 | --resource-group $RESOURCE_GROUP_NAME \ 83 | --command 'kubectl apply -f container-azm-ms-agentconfig.yaml' \ 84 | --file $SCRIPT_PATH/container-azm-ms-agentconfig.yaml 85 | 86 | rm -rf $SCRIPT_PATH/.tmp 87 | -------------------------------------------------------------------------------- /src/app/Api/Api.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | all 20 | runtime; build; native; contentfiles; analyzers; buildtransitive 21 | 22 | 23 | 24 | all 25 | runtime; build; native; contentfiles; analyzers; buildtransitive 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /src/app/Api/Api.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.7.34302.85 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Api", "Api.csproj", "{35D6B3AC-ABF9-458C-B5F5-5C22A5726F20}" 7 | EndProject 8 | Global 9 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 10 | Debug|Any CPU = Debug|Any CPU 11 | Release|Any CPU = Release|Any CPU 12 | EndGlobalSection 13 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 14 | {35D6B3AC-ABF9-458C-B5F5-5C22A5726F20}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 15 | {35D6B3AC-ABF9-458C-B5F5-5C22A5726F20}.Debug|Any CPU.Build.0 = Debug|Any CPU 16 | {35D6B3AC-ABF9-458C-B5F5-5C22A5726F20}.Release|Any CPU.ActiveCfg = Release|Any CPU 17 | {35D6B3AC-ABF9-458C-B5F5-5C22A5726F20}.Release|Any CPU.Build.0 = Release|Any CPU 18 | EndGlobalSection 19 | GlobalSection(SolutionProperties) = preSolution 20 | HideSolutionNode = FALSE 21 | EndGlobalSection 22 | GlobalSection(ExtensibilityGlobals) = postSolution 23 | SolutionGuid = {C1047E05-4D02-4287-A2E9-1947BBB6694B} 24 | EndGlobalSection 25 | EndGlobal 26 | -------------------------------------------------------------------------------- /src/app/Api/Controllers/CartsController.cs: -------------------------------------------------------------------------------- 1 | using Api.Models.Common; 2 | using Api.Models.DTO; 3 | using Api.Services.Repositories; 4 | using Api.Services.Repositories.Exceptions; 5 | using Api.Telemetry; 6 | using AutoMapper; 7 | using Microsoft.AspNetCore.Mvc; 8 | using System.Net.Mime; 9 | 10 | namespace Api.Controllers; 11 | 12 | [ApiController] 13 | [Route("api/users/{userId}/[controller]")] 14 | public class CartsController : ControllerBase 15 | { 16 | private readonly ILogger _logger; 17 | private readonly IMapper _mapper; 18 | private readonly ICartRepository _cartRepository; 19 | private readonly IConcertRepository _concertRepository; 20 | 21 | public CartsController(ILogger logger, IMapper mapper, ICartRepository cartRepository, IConcertRepository concertRepository) 22 | { 23 | _logger = logger; 24 | _mapper = mapper; 25 | _cartRepository = cartRepository; 26 | _concertRepository = concertRepository; 27 | } 28 | 29 | [HttpPut(Name = "AddToCart")] 30 | [Consumes(MediaTypeNames.Application.Json)] 31 | [Produces(MediaTypeNames.Application.Json)] 32 | [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ItemCollectionResponse))] 33 | [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] 34 | public async Task AddToCartAsync([FromRoute] string userId, [FromBody] CartItemDto request) 35 | { 36 | try 37 | { 38 | // Validates that the concert exists before adding it to the cart 39 | var concert = await _concertRepository.GetConcertByIdAsync(request.ConcertId); 40 | var updatedUserCart = await _cartRepository.UpdateCartAsync(userId, request.ConcertId, request.Quantity); 41 | 42 | return Ok(new ItemCollectionResponse() { Items = _mapper.Map>(updatedUserCart) }); 43 | } 44 | catch (NotFoundException ex) 45 | { 46 | _logger.LogError(ex, "Client requested a nonexistent concert with ID '{}'", request.ConcertId); 47 | return NotFound(new ErrorResponse(ex.Message)); 48 | } 49 | catch (Exception ex) 50 | { 51 | _logger.LogError(ex, "Unexpected server error encountered during '{}' action", nameof(AddToCartAsync)); 52 | return Problem(new ErrorResponse(ex).Serialize()); 53 | } 54 | } 55 | 56 | [HttpGet(Name = "GetCart")] 57 | [Produces(MediaTypeNames.Application.Json)] 58 | [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ItemCollectionResponse))] 59 | public async Task GetCartAsync(string userId) 60 | { 61 | try 62 | { 63 | var cart = await _cartRepository.GetCartAsync(userId); 64 | return Ok(new ItemCollectionResponse() { Items = _mapper.Map>(cart) }); 65 | } 66 | catch (Exception ex) 67 | { 68 | _logger.LogError(ex, "Unexpected server error encountered during '{}' action", nameof(GetCartAsync)); 69 | return Problem(new ErrorResponse(ex).Serialize()); 70 | } 71 | } 72 | 73 | [HttpDelete(Name = "ClearCart")] 74 | [Consumes(MediaTypeNames.Application.Json)] 75 | [Produces(MediaTypeNames.Application.Json)] 76 | [ProducesResponseType(StatusCodes.Status200OK)] 77 | public async Task ClearCartAsync(string userId) 78 | { 79 | try 80 | { 81 | await _cartRepository.ClearCartAsync(userId); 82 | return Ok(); 83 | } 84 | catch (Exception ex) 85 | { 86 | _logger.LogError(ex, "Unexpected server error encountered during '{}' action", nameof(ClearCartAsync)); 87 | return Problem(new ErrorResponse(ex).Serialize()); 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/app/Api/Controllers/ConcertsController.cs: -------------------------------------------------------------------------------- 1 | using Api.Models.Common; 2 | using Api.Models.DTO; 3 | using Api.Services.Repositories; 4 | using Api.Services.Repositories.Exceptions; 5 | using Api.Telemetry; 6 | using AutoMapper; 7 | using Microsoft.AspNetCore.Mvc; 8 | using System.Net.Mime; 9 | 10 | namespace Api.Controllers 11 | { 12 | [Route("api/[controller]")] 13 | public class ConcertsController : ControllerBase 14 | { 15 | private readonly ILogger _logger; 16 | private readonly IMapper _mapper; 17 | private readonly IConcertRepository _concertRepository; 18 | 19 | public ConcertsController(ILogger logger, 20 | IMapper mapper, 21 | IConcertRepository concertRepository) 22 | { 23 | _logger = logger; 24 | _mapper = mapper; 25 | _concertRepository = concertRepository; 26 | } 27 | 28 | [HttpGet(Name = "GetUpcomingConcerts")] 29 | [Produces(MediaTypeNames.Application.Json)] 30 | [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ItemCollectionResponse))] 31 | public async Task GetUpcomingConcerts([FromQuery] int take = 10) 32 | { 33 | try 34 | { 35 | var concerts = await _concertRepository.GetUpcomingConcertsAsync(take); 36 | return Ok(new ItemCollectionResponse { Items = _mapper.Map>(concerts) }); 37 | } 38 | catch (Exception ex) 39 | { 40 | _logger.LogError(ex, "Unexpected server error encountered during '{}' action", nameof(GetUpcomingConcerts)); 41 | return Problem(new ErrorResponse(ex).Serialize()); 42 | } 43 | } 44 | 45 | [HttpGet("{concertId}", Name = "GetConcertById")] 46 | [Produces(MediaTypeNames.Application.Json)] 47 | [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ConcertDto))] 48 | [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] 49 | public async Task GetConcertByIdAsync([FromRoute] string concertId) 50 | { 51 | try 52 | { 53 | var concert = await _concertRepository.GetConcertByIdAsync(concertId); 54 | return Ok(_mapper.Map(concert)); 55 | } 56 | catch (NotFoundException ex) 57 | { 58 | _logger.LogError(ex, "Client requested a nonexistent concert with ID '{}'", concertId); 59 | return NotFound(new ErrorResponse(ex.Message)); 60 | } 61 | catch (Exception ex) 62 | { 63 | _logger.LogError(ex, "Unexpected server error encountered during '{}' action", nameof(GetConcertByIdAsync)); 64 | return Problem(new ErrorResponse(ex).Serialize()); 65 | } 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/app/Api/Controllers/OrdersController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | using System.Net.Mime; 3 | using Api.Telemetry; 4 | using Api.Services.Repositories; 5 | using Api.Services; 6 | using AutoMapper; 7 | using Api.Models.DTO; 8 | using Api.Models.Common; 9 | using Api.Services.Repositories.Exceptions; 10 | 11 | namespace OrderClient.Controllers; 12 | 13 | [ApiController] 14 | [Route("api/users/{userId}/[controller]")] 15 | public class OrdersController : ControllerBase 16 | { 17 | private readonly ILogger _logger; 18 | private readonly IMapper _mapper; 19 | private readonly IUserRepository _userRepository; 20 | private readonly IOrderRepository _orderRepository; 21 | private readonly TicketPurchasingService _ticketService; 22 | 23 | public OrdersController(ILogger logger, 24 | IMapper mapper, 25 | IUserRepository userRepository, 26 | IOrderRepository orderRepository, 27 | TicketPurchasingService ticketService) 28 | { 29 | _logger = logger; 30 | _mapper = mapper; 31 | _userRepository = userRepository; 32 | _orderRepository = orderRepository; 33 | _ticketService = ticketService; 34 | } 35 | 36 | [HttpPost(Name = "CreateOrder")] 37 | [Consumes(MediaTypeNames.Application.Json)] 38 | [Produces(MediaTypeNames.Application.Json)] 39 | [ProducesResponseType(StatusCodes.Status201Created, Type = typeof(OrderDto))] 40 | [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ErrorResponse))] 41 | [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] 42 | public async Task CreateOrderAsync([FromRoute] string userId, [FromBody] OrderPaymentDetails purchaseTicketRequest) 43 | { 44 | try 45 | { 46 | var user = await _userRepository.GetUserByIdAsync(userId); 47 | var newOrder = await _ticketService.TryCheckoutTickets(userId); 48 | 49 | return CreatedAtAction(nameof(GetOrderByIdAsync), new { userId, orderId = newOrder.Id }, _mapper.Map(newOrder)); 50 | } 51 | catch (NotFoundException ex) 52 | { 53 | _logger.LogError(ex, "Client requested an order for a nonexistend user with ID '{}'", userId); 54 | return NotFound(new ErrorResponse(ex.Message)); 55 | } 56 | catch (InvalidOperationException ex) 57 | { 58 | _logger.LogError(ex, "Invalid cart state for user with ID '{}'", userId); 59 | return BadRequest(new ErrorResponse(ex)); 60 | } 61 | catch (Exception ex) 62 | { 63 | _logger.LogError(ex, "Unexpected server error encountered during '{}' action", nameof(CreateOrderAsync)); 64 | return Problem(new ErrorResponse(ex).Serialize()); 65 | } 66 | } 67 | 68 | [HttpGet(Name = "GetAllOrdersForUser")] 69 | [Produces(MediaTypeNames.Application.Json)] 70 | [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PagedResponse))] 71 | [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] 72 | public async Task GetAllOrdersAsync([FromRoute] string userId, [FromQuery] int skip = 0, [FromQuery] int take = 5) 73 | { 74 | try 75 | { 76 | var orders = await _orderRepository.GetAllOrdersForUserAsync(userId, skip, take); 77 | 78 | return Ok(new PagedResponse() 79 | { 80 | PageData = _mapper.Map>(orders.PageData), 81 | Skipped = orders.Skipped, 82 | PageSize = orders.PageSize, 83 | TotalCount = orders.TotalCount, 84 | }); 85 | } 86 | catch (Exception ex) 87 | { 88 | _logger.LogError(ex, "Unexpected server error encountered during '{}' action", nameof(GetAllOrdersAsync)); 89 | return Problem(new ErrorResponse(ex).Serialize()); 90 | } 91 | } 92 | 93 | [HttpGet("{orderId}", Name = "GetOrder")] 94 | [Produces(MediaTypeNames.Application.Json)] 95 | [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(OrderDto))] 96 | [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] 97 | public async Task GetOrderByIdAsync([FromRoute] string orderId) 98 | { 99 | try 100 | { 101 | var order = await _orderRepository.GetOrderByIdAsync(orderId); 102 | return Ok(_mapper.Map(order)); 103 | } 104 | catch (NotFoundException ex) 105 | { 106 | _logger.LogError(ex, "Client requested a nonexistent order with ID '{}'", orderId); 107 | return NotFound(new ErrorResponse(ex.Message)); 108 | } 109 | catch (Exception ex) 110 | { 111 | _logger.LogError(ex, "Unexpected server error encountered during '{}' action", nameof(GetOrderByIdAsync)); 112 | return Problem(new ErrorResponse(ex).Serialize()); 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/app/Api/Controllers/UsersController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | using System.Net.Mime; 3 | using Api.Telemetry; 4 | using Api.Services.Repositories; 5 | using Api.Models.DTO; 6 | using Api.Services.Repositories.Exceptions; 7 | using AutoMapper; 8 | using Api.Models.Entities; 9 | using Api.Models.Common; 10 | 11 | namespace Api.Controllers 12 | { 13 | [Route("api/[controller]")] 14 | [ApiController] 15 | public class UsersController : ControllerBase 16 | { 17 | private readonly ILogger _logger; 18 | private readonly IMapper _mapper; 19 | private readonly IUserRepository _userRepository; 20 | 21 | public UsersController(ILogger logger, IMapper mapper, IUserRepository userRepository) 22 | { 23 | _logger = logger; 24 | _mapper = mapper; 25 | _userRepository = userRepository; 26 | } 27 | 28 | [HttpGet("{userId}", Name = "GetUser")] 29 | [Produces(MediaTypeNames.Application.Json)] 30 | [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(UserDto))] 31 | [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] 32 | public async Task GetUserByIdAsync(string userId) 33 | { 34 | try 35 | { 36 | var userEntity = await _userRepository.GetUserByIdAsync(userId); 37 | return Ok(_mapper.Map(userEntity)); 38 | } 39 | catch (NotFoundException ex) 40 | { 41 | _logger.LogError(ex, "Client requested a nonexistent user with ID '{}'", userId); 42 | return NotFound(new ErrorResponse(ex.Message)); 43 | } 44 | catch (Exception ex) 45 | { 46 | _logger.LogError(ex, "Unexpected server error encountered during '{}' action", nameof(GetUserByIdAsync)); 47 | return Problem(new ErrorResponse(ex).Serialize()); 48 | } 49 | } 50 | 51 | [HttpPost(Name = "CreateUser")] 52 | [Consumes(MediaTypeNames.Application.Json)] 53 | [Produces(MediaTypeNames.Application.Json)] 54 | [ProducesResponseType(StatusCodes.Status201Created, Type = typeof(UserDto))] 55 | [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ErrorResponse))] 56 | public async Task CreateUserAsync([FromBody] UserRequest request) 57 | { 58 | try 59 | { 60 | var userEntity = await _userRepository.CreateUserAsync(_mapper.Map(request)); 61 | return CreatedAtAction(nameof(GetUserByIdAsync), new { userId = userEntity.Id }, _mapper.Map(userEntity)); 62 | } 63 | catch (Exception ex) 64 | { 65 | _logger.LogError(ex, "Unexpected server error encountered during '{}' action", nameof(CreateUserAsync)); 66 | return Problem(new ErrorResponse(ex).Serialize()); 67 | } 68 | } 69 | 70 | [HttpPatch("{userId}", Name = "UpdateUser")] 71 | [Consumes(MediaTypeNames.Application.Json)] 72 | [Produces(MediaTypeNames.Application.Json)] 73 | [ProducesResponseType(StatusCodes.Status202Accepted, Type = typeof(UserDto))] 74 | [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ErrorResponse))] 75 | [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] 76 | public async Task UpdateUserAsync([FromRoute] string userId, [FromBody] UserRequest request) 77 | { 78 | try 79 | { 80 | var userEntity = _mapper.Map(request); 81 | userEntity.Id = userId; 82 | 83 | await _userRepository.UpdateUserAsync(userEntity); 84 | return Accepted(_mapper.Map(userEntity)); 85 | } 86 | catch (NotFoundException ex) 87 | { 88 | _logger.LogError(ex, "Client requested to update a nonexistent user with ID '{}'", userId); 89 | return NotFound(new ErrorResponse($"Invalid user ID. No user found with ID {userId}")); 90 | } 91 | catch (Exception ex) 92 | { 93 | _logger.LogError(ex, "Unexpected server error encountered during '{}' action", nameof(UpdateUserAsync)); 94 | return Problem(new ErrorResponse(ex).Serialize()); 95 | } 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/app/Api/Dockerfile: -------------------------------------------------------------------------------- 1 | # ===== Build image ===== 2 | FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build-env 3 | 4 | # Copy everything 5 | WORKDIR /App 6 | COPY . ./ 7 | 8 | # Restore dependencies as distinct layers 9 | RUN dotnet restore 10 | 11 | # Build and publish a release 12 | RUN dotnet publish -c Release -o out 13 | 14 | 15 | # ===== Runtime image ===== 16 | FROM mcr.microsoft.com/dotnet/aspnet:8.0 17 | 18 | WORKDIR /App 19 | COPY --from=build-env /App/out . 20 | 21 | # Required for health checks 22 | RUN apt update && apt install -y curl 23 | 24 | ENTRYPOINT ["dotnet", "Api.dll"] 25 | -------------------------------------------------------------------------------- /src/app/Api/Infrastructure/ApplicationInitializer.cs: -------------------------------------------------------------------------------- 1 | using Api.Infrastructure.Datastore; 2 | using Hangfire; 3 | using Microsoft.ApplicationInsights; 4 | 5 | namespace Api.Infrastructure 6 | { 7 | /// 8 | /// Initializer class for the application. Built and initialized at app startup, 9 | /// the class initializes all the services required for the app to run. 10 | /// Also starts, if configured, all the specified recurring jobs. 11 | /// 12 | public class ApplicationInitializer 13 | { 14 | private readonly TelemetryClient _telemetryClient; 15 | private readonly IConfiguration _configuration; 16 | private readonly IDatabaseCleanupJob _databaseCleanupJob; 17 | private readonly IRecurringJobManager _recurringJobManager; 18 | private readonly DatabaseContext _dbContext; 19 | 20 | public ApplicationInitializer( 21 | TelemetryClient telemetryClient, 22 | IConfiguration configuration, 23 | IDatabaseCleanupJob databaseCleanupJob, 24 | IRecurringJobManager recurringJobManager, 25 | DatabaseContext dbContext) 26 | { 27 | _telemetryClient = telemetryClient; 28 | _configuration = configuration; 29 | _databaseCleanupJob = databaseCleanupJob; 30 | _recurringJobManager = recurringJobManager; 31 | _dbContext = dbContext; 32 | } 33 | 34 | /// 35 | /// Should be called at application startup to initialize all services 36 | /// and start all background cron jobs. 37 | /// 38 | public void Initialize() 39 | { 40 | // Initialize datastore at application startup 41 | _dbContext.Initialize(); 42 | 43 | _telemetryClient.TrackEvent("DATABASE_INITIALIZED"); 44 | 45 | var isDatabaseCleanupEnabled = bool.Parse(_configuration?["DATABASE_CLEANUP_ENABLED"] ?? "false"); 46 | if (isDatabaseCleanupEnabled) 47 | { 48 | _recurringJobManager.AddOrUpdate("database-cleanup", job => _databaseCleanupJob.ExecuteAsync(), Cron.Minutely); 49 | 50 | _telemetryClient.TrackEvent("DATABASE_CLEANUP_SETUP"); 51 | } 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/app/Api/Infrastructure/CacheKeys.cs: -------------------------------------------------------------------------------- 1 | namespace Api.Infrastructure 2 | { 3 | /// 4 | /// Defines and uniformizes all the caching keys used in the application. 5 | /// 6 | public static class CacheKeys 7 | { 8 | // Caching key referencing the IDs of all the upcoming concerts. 9 | // The implementation handles what concerts are considered "upcoming". 10 | public const string UpcomingConcerts = "UpcomingConcerts"; 11 | 12 | // Caching key referencing the cart of a specific user. 13 | // The implementation handles how cart items are stored in the cache. 14 | public static readonly Func Cart = (userId) => $"Cart_{userId}"; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/app/Api/Infrastructure/DatabaseCleanupJob.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.ApplicationInsights; 2 | using Microsoft.EntityFrameworkCore; 3 | using Api.Infrastructure.Datastore; 4 | 5 | namespace Api.Infrastructure 6 | { 7 | /// 8 | /// Class that handles the cleanup of the database. 9 | /// Can be registered as a Hangfire background cron job to run at a specified interval. 10 | /// This specific implementation clears a configuration-specified number of rows 11 | /// that are older than a configuration-specified number of minutes. 12 | /// See "DATABASE_CLEANUP_RECORD_COUNT" and "DATABASE_CLEANUP_THRESHOLD_MINUTES" in the configuration file of the app. 13 | /// 14 | public class DatabaseCleanupJob : IDatabaseCleanupJob 15 | { 16 | // See: https://learn.microsoft.com/en-us/sql/relational-databases/errors-events/database-engine-events-and-errors?view=sql-server-ver16 17 | private const int SQL_ERROR_DEADLOCK = 1205; 18 | 19 | private readonly TelemetryClient _telemetryClient; 20 | private readonly ILogger _logger; 21 | private readonly DatabaseContext _dataContext; 22 | private readonly int _cleanupBatchSize; 23 | private readonly int _recordAgeThreshold; 24 | 25 | public DatabaseCleanupJob( 26 | TelemetryClient telemetryClient, 27 | ILogger logger, 28 | IConfiguration configuration, 29 | DatabaseContext dataContext 30 | ) 31 | { 32 | _telemetryClient = telemetryClient; 33 | _logger = logger; 34 | _dataContext = dataContext; 35 | 36 | _cleanupBatchSize = int.Parse(configuration["DATABASE_CLEANUP_RECORD_COUNT"] ?? "1000"); 37 | _recordAgeThreshold = int.Parse(configuration["DATABASE_CLEANUP_THRESHOLD_MINUTES"] ?? "30"); 38 | } 39 | 40 | /// 41 | public async Task ExecuteAsync() 42 | { 43 | try 44 | { 45 | var cutOffThreshold = -1 * _recordAgeThreshold; 46 | var cutOffTime = DateTime.UtcNow.AddMinutes(cutOffThreshold); 47 | var count = _cleanupBatchSize; 48 | 49 | var eventAtts = new Dictionary(); 50 | eventAtts.Add("Count", _cleanupBatchSize.ToString()); 51 | _telemetryClient.TrackEvent("DATABASE_CLEANUP_ATTEMPT", eventAtts); 52 | 53 | var timeout = 180; // Set the timeout to 180 seconds (3 minutes) 54 | _dataContext.Database.SetCommandTimeout(timeout); 55 | 56 | // Tickets 57 | var ticketsDeleted = await DeleteOldTicketsAsync(); 58 | 59 | // Users 60 | var usersDeleted = await DeleteOldUsersAsync(); 61 | 62 | var eventAtts2 = new Dictionary(); 63 | eventAtts2.Add("BatchSize", _cleanupBatchSize.ToString()); 64 | eventAtts2.Add("UsersDeleted", usersDeleted.ToString()); 65 | _telemetryClient.TrackEvent("DATABASE_CLEANUP", eventAtts2); 66 | } 67 | catch (Exception ex) 68 | { 69 | if (ex is Microsoft.Data.SqlClient.SqlException sqlEx) 70 | { 71 | bool isDeadlock = false; 72 | 73 | foreach (Microsoft.Data.SqlClient.SqlError error in sqlEx.Errors) 74 | { 75 | // This IS going to happen due to how this was implemented and the number of instances running. 76 | // This code should be refactored so that we don't have multiple instances of the AKS pods running cleanup 77 | if (error.Number == SQL_ERROR_DEADLOCK) 78 | { 79 | isDeadlock = true; 80 | _logger.LogInformation("Cleanup job deadlock encountered"); 81 | break; 82 | } 83 | } 84 | 85 | if (!isDeadlock) 86 | { 87 | _logger.LogError(ex, "Unhandled exception from DatabaseCleanupJob.ExecuteAsync"); 88 | } 89 | } 90 | else 91 | { 92 | _logger.LogError(ex, "Unhandled exception from DatabaseCleanupJob.ExecuteAsync"); 93 | } 94 | } 95 | } 96 | 97 | /// 98 | /// Executes the cleanup strategy for the "Tickets" table. 99 | /// 100 | /// the total number of rows deleted 101 | private async Task DeleteOldTicketsAsync() 102 | { 103 | var cutOffTime = DateTime.UtcNow.AddMinutes(-1 * _recordAgeThreshold); 104 | var totalDeleted = 0; 105 | while (true) 106 | { 107 | var rowsAffected = await _dataContext.Database.ExecuteSqlRawAsync( 108 | "DELETE FROM Tickets WHERE Id IN (SELECT TOP (@p0) Id FROM Tickets WHERE CreatedDate < @p1 ORDER BY CreatedDate ASC)", 109 | _cleanupBatchSize, cutOffTime 110 | ); 111 | 112 | if (rowsAffected == 0) 113 | { 114 | break; 115 | } 116 | 117 | totalDeleted += rowsAffected; // Update the total number of records deleted 118 | } 119 | return totalDeleted; 120 | } 121 | 122 | /// 123 | /// Executes the cleanup strategy for the "Users" table. 124 | /// 125 | /// the total number of rows deleted 126 | private async Task DeleteOldUsersAsync() 127 | { 128 | var cutOffTime = DateTime.UtcNow.AddMinutes(-1 * _recordAgeThreshold); 129 | var totalDeleted = 0; 130 | while (true) 131 | { 132 | var rowsAffected = await _dataContext.Database.ExecuteSqlRawAsync( 133 | "DELETE FROM Users WITH (READPAST) WHERE Id IN (SELECT TOP (@p0) Id FROM Users WHERE CreatedDate < @p1 ORDER BY CreatedDate ASC)", 134 | _cleanupBatchSize, 135 | cutOffTime 136 | ); 137 | 138 | if (rowsAffected == 0) 139 | { 140 | break; 141 | } 142 | 143 | totalDeleted += rowsAffected; // Update the total number of records deleted 144 | } 145 | return totalDeleted; 146 | } 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/app/Api/Infrastructure/DatabaseContextHealthCheck.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using Microsoft.Extensions.Diagnostics.HealthChecks; 3 | using Api.Infrastructure.Datastore; 4 | 5 | namespace Api.Infrastructure 6 | { 7 | /// 8 | /// Executes a simple heartbeat check against the configured database context, in case of usage of Entity Framework. 9 | /// Can be run as a Hangfire job. 10 | /// 11 | public class DatabaseContextHealthCheck : IHealthCheck 12 | { 13 | private readonly ILogger _logger; 14 | private readonly DatabaseContext _concertDataContext; 15 | 16 | public DatabaseContextHealthCheck(ILogger logger, DatabaseContext concertDataContext) 17 | { 18 | _logger = logger; 19 | _concertDataContext = concertDataContext; 20 | } 21 | 22 | /// 23 | /// Executes the health check against the database context, logging the total number 24 | /// of rows for each table in the database. 25 | /// 26 | /// the health check context of the current operation 27 | /// cancellation token of the async operation 28 | /// whether the heartbeat is healthy or unhealthy 29 | public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) 30 | { 31 | try 32 | { 33 | var ticketCount = await _concertDataContext.Orders.AsNoTracking().CountAsync(); 34 | var orderCount = await _concertDataContext.Orders.AsNoTracking().CountAsync(); 35 | var concertCount = await _concertDataContext.Concerts.AsNoTracking().CountAsync(); 36 | var userCount = await _concertDataContext.Users.AsNoTracking().CountAsync(); 37 | 38 | _logger.LogInformation($"Ticket count: {orderCount}"); 39 | _logger.LogInformation($"Order count: {orderCount}"); 40 | _logger.LogInformation($"Concert count: {concertCount}"); 41 | _logger.LogInformation($"User count: {userCount}"); 42 | 43 | return HealthCheckResult.Healthy(); 44 | } 45 | catch (Exception ex) 46 | { 47 | return HealthCheckResult.Unhealthy("SQL Database is unhealthy.", ex); 48 | } 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/app/Api/Infrastructure/Datastore/CachedCartRepository.cs: -------------------------------------------------------------------------------- 1 | using Api.Services.Repositories; 2 | using Microsoft.Extensions.Caching.Distributed; 3 | using Newtonsoft.Json; 4 | 5 | namespace Api.Infrastructure.Datastore 6 | { 7 | /// 8 | /// Implementation of the cart data model repository that uses a Redis cache 9 | /// 10 | public class CachedCartRepository : ICartRepository, IDisposable 11 | { 12 | private readonly IDistributedCache cache; 13 | 14 | public CachedCartRepository(IDistributedCache cache) 15 | { 16 | this.cache = cache; 17 | } 18 | 19 | /// 20 | public async Task ClearCartAsync(string userId) 21 | { 22 | var currentCart = new Dictionary(); 23 | await UpdateCartAsync(userId, currentCart); 24 | } 25 | 26 | /// 27 | public async Task> GetCartAsync(string userId) 28 | { 29 | var cacheKey = CacheKeys.Cart(userId); 30 | var cartData = await cache.GetStringAsync(cacheKey); 31 | 32 | if (cartData == null) 33 | { 34 | return new Dictionary(); 35 | } 36 | else 37 | { 38 | var cartObject = JsonConvert.DeserializeObject>(cartData); 39 | return cartObject ?? new Dictionary(); 40 | } 41 | } 42 | 43 | /// 44 | public async Task> UpdateCartAsync(string userId, string concertId, int count) 45 | { 46 | var currentCart = await GetCartAsync(userId); 47 | 48 | if (count == 0) 49 | { 50 | currentCart.Remove(concertId); 51 | } 52 | else 53 | { 54 | currentCart[concertId] = count; 55 | } 56 | 57 | await UpdateCartAsync(userId, currentCart); 58 | return currentCart; 59 | } 60 | 61 | /// 62 | private async Task UpdateCartAsync(string userId, IDictionary currentCart) 63 | { 64 | var cartData = JsonConvert.SerializeObject(currentCart); 65 | var cacheOptions = new DistributedCacheEntryOptions 66 | { 67 | AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1) 68 | }; 69 | var cacheKey = CacheKeys.Cart(userId); 70 | 71 | await cache.SetStringAsync(cacheKey, cartData, cacheOptions); 72 | } 73 | 74 | public void Dispose() 75 | { } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/app/Api/Infrastructure/Datastore/CachedConcertRepository.cs: -------------------------------------------------------------------------------- 1 | using Api.Models.Entities; 2 | using Api.Services.Repositories; 3 | using Api.Services.Repositories.Exceptions; 4 | using Microsoft.EntityFrameworkCore; 5 | using Microsoft.Extensions.Caching.Distributed; 6 | using System.Text.Json; 7 | 8 | namespace Api.Infrastructure.Datastore 9 | { 10 | /// 11 | /// Implementation of the concert data model repository that uses 12 | /// the Entity Framework and a Redis cache to store and retrieve data. 13 | /// 14 | public class CachedConcertRepository : IConcertRepository, IDisposable 15 | { 16 | private readonly DatabaseContext _database; 17 | private readonly IDistributedCache _cache; 18 | 19 | public CachedConcertRepository(DatabaseContext database, IDistributedCache cache) 20 | { 21 | _database = database; 22 | _cache = cache; 23 | } 24 | 25 | /// 26 | public async Task CreateConcertAsync(Concert newConcert) 27 | { 28 | _database.Add(newConcert); 29 | await _database.SaveChangesAsync(); 30 | 31 | _cache.Remove(CacheKeys.UpcomingConcerts); 32 | return newConcert; 33 | } 34 | 35 | /// 36 | public async Task UpdateConcertAsync(Concert concert) 37 | { 38 | var updatedConcert = await _database.Concerts.FindAsync(concert.Id); 39 | if (updatedConcert == null) 40 | { 41 | throw new NotFoundException($"Can't update concert info. Concert with id '{concert.Id}' not found"); 42 | } 43 | 44 | updatedConcert.StartTime = concert.StartTime; 45 | updatedConcert.Price = concert.Price; 46 | updatedConcert.UpdatedOn = DateTimeOffset.UtcNow; 47 | 48 | await _database.SaveChangesAsync(); 49 | 50 | _cache.Remove(CacheKeys.UpcomingConcerts); 51 | return updatedConcert; 52 | } 53 | 54 | /// 55 | public async Task DeleteConcertAsync(string concertId) 56 | { 57 | var existingConcert = _database.Concerts.SingleOrDefault(c => c.Id == concertId); 58 | if (existingConcert == null) 59 | { 60 | throw new NotFoundException($"Can't delete concert. Concert with id '{concertId}' not found"); 61 | } 62 | 63 | _database.Remove(existingConcert); 64 | await _database.SaveChangesAsync(); 65 | 66 | _cache.Remove(CacheKeys.UpcomingConcerts); 67 | } 68 | 69 | /// 70 | public async Task GetConcertByIdAsync(string concertId) 71 | { 72 | var concert = await _database.Concerts.AsNoTracking().Where(c => c.Id == concertId).SingleOrDefaultAsync(); 73 | return concert ?? throw new NotFoundException($"Concert with id '{concertId}' does not exist"); 74 | } 75 | 76 | /// 77 | public async Task> GetUpcomingConcertsAsync(int count) 78 | { 79 | var concertsJson = await _cache.GetStringAsync(CacheKeys.UpcomingConcerts); 80 | if (concertsJson != null) 81 | { 82 | // We have cached data, deserialize the JSON data 83 | var cachedConcerts = JsonSerializer.Deserialize>(concertsJson); 84 | 85 | if (cachedConcerts?.Count >= count) 86 | { 87 | // We have enough data cached. No need to fetch from DB 88 | return cachedConcerts.Take(count); 89 | } 90 | } 91 | 92 | // Insufficient data in the cache, retrieve data from the repository and cache it for one hour 93 | var concerts = await _database.Concerts.AsNoTracking() 94 | .Where(c => c.StartTime > DateTimeOffset.UtcNow && c.IsVisible) 95 | .OrderBy(c => c.StartTime) 96 | .Take(count) 97 | .ToListAsync(); 98 | concertsJson = JsonSerializer.Serialize(concerts); 99 | var cacheOptions = new DistributedCacheEntryOptions 100 | { 101 | AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1) 102 | }; 103 | await _cache.SetStringAsync(CacheKeys.UpcomingConcerts, concertsJson, cacheOptions); 104 | 105 | return concerts ?? []; 106 | } 107 | 108 | public void Dispose() => _database?.Dispose(); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/app/Api/Infrastructure/Datastore/OrderRepository.cs: -------------------------------------------------------------------------------- 1 | using Api.Models.Common; 2 | using Api.Models.Entities; 3 | using Api.Services.Repositories; 4 | using Api.Services.Repositories.Exceptions; 5 | using Microsoft.EntityFrameworkCore; 6 | 7 | namespace Api.Infrastructure.Datastore 8 | { 9 | /// 10 | /// Implementation of the order data model repository that uses the Entity Framework. 11 | /// 12 | public class OrderRepository : IOrderRepository, IDisposable 13 | { 14 | private readonly DatabaseContext _database; 15 | 16 | public OrderRepository(DatabaseContext database) 17 | { 18 | _database = database; 19 | } 20 | 21 | /// 22 | public async Task> GetAllOrdersForUserAsync(string userId, int skip, int take) 23 | { 24 | var pageOfData = await _database.Orders.AsNoTracking().Include(order => order.Tickets).Where(order => order.UserId == userId) 25 | .OrderByDescending(order => order.Id).Skip(skip).Take(take).ToListAsync(); 26 | var totalCount = await _database.Orders.Where(order => order.UserId == userId).CountAsync(); 27 | 28 | return new PagedResponse() { PageData = pageOfData, Skipped = skip, PageSize = pageOfData.Count, TotalCount = totalCount }; 29 | } 30 | 31 | /// 32 | public async Task GetOrderByIdAsync(string orderId) 33 | { 34 | var order = await _database.Orders.AsNoTracking().Where(order => order.Id == orderId).SingleOrDefaultAsync(); 35 | return order ?? throw new NotFoundException($"Order with ID '{orderId}' does not exist"); 36 | } 37 | 38 | /// 39 | public async Task CreateOrder(string userId, ICollection tickets) 40 | { 41 | var order = new Order() 42 | { 43 | Tickets = tickets, 44 | UserId = userId, 45 | }; 46 | _database.Orders.Add(order); 47 | await _database.SaveChangesAsync(); 48 | 49 | return order; 50 | } 51 | 52 | public void Dispose() => _database?.Dispose(); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/app/Api/Infrastructure/Datastore/TicketRepository.cs: -------------------------------------------------------------------------------- 1 | using Api.Models.Common; 2 | using Api.Models.Entities; 3 | using Api.Services.Repositories; 4 | using Api.Services.Repositories.Exceptions; 5 | using Microsoft.EntityFrameworkCore; 6 | 7 | namespace Api.Infrastructure.Datastore 8 | { 9 | /// 10 | /// Implementation of the ticket data model repository that uses the Entity Framework. 11 | /// 12 | public class TicketRepository : ITicketRepository, IDisposable 13 | { 14 | private readonly DatabaseContext _database; 15 | 16 | public TicketRepository(DatabaseContext database) 17 | { 18 | _database = database; 19 | } 20 | 21 | /// 22 | public async Task> GetAllTicketsForUserAsync(string userId, int skip, int take) 23 | { 24 | var pageOfData = await _database.Tickets.AsNoTracking().Include(ticket => ticket.UserId).Where(ticket => ticket.UserId == userId) 25 | .OrderByDescending(ticket => ticket.Id).Skip(skip).Take(take).ToListAsync(); 26 | var totalCount = await _database.Tickets.Where(ticket => ticket.UserId == userId).CountAsync(); 27 | 28 | return new PagedResponse() { PageData = pageOfData, Skipped = skip, PageSize = pageOfData.Count, TotalCount = totalCount }; 29 | } 30 | 31 | /// 32 | public async Task GetTicketByIdAsync(string ticketId) 33 | { 34 | var ticket = await _database.Tickets.AsNoTracking().Where(ticket => ticket.Id == ticketId).SingleOrDefaultAsync(); 35 | return ticket ?? throw new NotFoundException($"Ticket with ID '{ticketId}' does not exist"); 36 | } 37 | 38 | /// 39 | public async Task> TryEmitTickets(string userId, IDictionary cartData) 40 | { 41 | 42 | var retryStrategy = _database.Database.CreateExecutionStrategy(); 43 | 44 | return await retryStrategy.ExecuteAsync(async () => 45 | { 46 | using var transaction = _database.Database.BeginTransaction(); 47 | var reservedTickets = new List(); 48 | 49 | try 50 | { 51 | foreach (var cartItem in cartData) 52 | { 53 | string concertId = cartItem.Key; 54 | int numTickets = cartItem.Value; 55 | 56 | var ticketsForConcert = Enumerable.Range(0, numTickets).Select( 57 | _ => new Ticket 58 | { 59 | ConcertId = concertId, 60 | UserId = userId, 61 | }); 62 | 63 | _database.Tickets.AddRange(ticketsForConcert); 64 | reservedTickets.AddRange(ticketsForConcert); 65 | } 66 | 67 | await _database.SaveChangesAsync(); 68 | transaction.Commit(); 69 | 70 | return reservedTickets; 71 | } 72 | catch (Exception) 73 | { 74 | transaction.Rollback(); 75 | throw; 76 | } 77 | }); 78 | } 79 | 80 | public void Dispose() => _database?.Dispose(); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/app/Api/Infrastructure/Datastore/UserRepository.cs: -------------------------------------------------------------------------------- 1 | using Api.Models.Entities; 2 | using Api.Services.Repositories; 3 | using Api.Services.Repositories.Exceptions; 4 | using Microsoft.EntityFrameworkCore; 5 | 6 | namespace Api.Infrastructure.Datastore 7 | { 8 | /// 9 | /// Implementation of the ticket data model repository that uses the Entity Framework. 10 | /// 11 | public class UserRepository : IUserRepository, IDisposable 12 | { 13 | private readonly DatabaseContext _database; 14 | 15 | public UserRepository(DatabaseContext database) 16 | { 17 | _database = database; 18 | } 19 | 20 | /// 21 | public async Task CreateUserAsync(User user) 22 | { 23 | _database.Users.Add(user); 24 | await _database.SaveChangesAsync(); 25 | 26 | return user; 27 | } 28 | 29 | /// 30 | public async Task UpdateUserAsync(User user) 31 | { 32 | var updatedUser = await _database.Users.FindAsync(user.Id); 33 | if (updatedUser == null) 34 | { 35 | throw new NotFoundException($"Can't update user info. User with id '{user.Id}' not found"); 36 | } 37 | 38 | updatedUser.DisplayName = user.DisplayName; 39 | updatedUser.Email = user.Email; 40 | updatedUser.Phone = user.Phone; 41 | 42 | await _database.SaveChangesAsync(); 43 | return updatedUser; 44 | } 45 | 46 | /// 47 | public async Task GetUserByIdAsync(string userId) 48 | { 49 | var user = await _database.Users.AsNoTracking().Where(user => user.Id == userId).SingleOrDefaultAsync(); 50 | return user ?? throw new NotFoundException($"User with id '{userId}' does not exist"); 51 | } 52 | 53 | public void Dispose() => _database?.Dispose(); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/app/Api/Infrastructure/IDatabaseCleanupJob.cs: -------------------------------------------------------------------------------- 1 | namespace Api.Infrastructure 2 | { 3 | public interface IDatabaseCleanupJob 4 | { 5 | /// 6 | /// Executes the database cleanup job. 7 | /// 8 | Task ExecuteAsync(); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/app/Api/Infrastructure/RedisCacheHealthCheck.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Diagnostics.HealthChecks; 2 | using StackExchange.Redis; 3 | 4 | namespace Api.Infrastructure 5 | { 6 | /// 7 | /// Executes a simple heartbeat check against the configured Redis Cache instance. 8 | /// Can be run as a Hangfire job. 9 | /// 10 | public class RedisCacheHealthCheck : IHealthCheck 11 | { 12 | private readonly IConnectionMultiplexer _connectionMultiplexer; 13 | 14 | public RedisCacheHealthCheck(IConnectionMultiplexer connectionMultiplexer) 15 | { 16 | _connectionMultiplexer = connectionMultiplexer ?? throw new ArgumentNullException(nameof(connectionMultiplexer)); 17 | } 18 | 19 | /// 20 | /// Executes the health check against the Redis Cache instance. 21 | /// The heartbeat check is a simple PING command. 22 | /// 23 | /// the health check context of the current operation 24 | /// cancellation token of the async operation 25 | /// whether the heartbeat is healthy or unhealthy 26 | public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) 27 | { 28 | try 29 | { 30 | var database = _connectionMultiplexer.GetDatabase(); 31 | 32 | var result = await database.ExecuteAsync("PING"); 33 | if (result.IsNull) 34 | { 35 | return HealthCheckResult.Unhealthy("Redis did not respond to PING."); 36 | } 37 | return HealthCheckResult.Healthy(); 38 | } 39 | catch (Exception ex) 40 | { 41 | return HealthCheckResult.Unhealthy("Redis connectivity check failed.", ex); 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/app/Api/Infrastructure/SqlDbHealthCheck.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Data.SqlClient; 2 | using Microsoft.Extensions.Diagnostics.HealthChecks; 3 | 4 | namespace Api.Infrastructure 5 | { 6 | /// 7 | /// Executes a simple heartbeat check against the configured SQL database. 8 | /// Can be run as a Hangfire job. 9 | /// 10 | public class SqlDbHealthCheck : IHealthCheck 11 | { 12 | private readonly string _connectionString; 13 | 14 | public SqlDbHealthCheck(string connectionString) 15 | { 16 | _connectionString = connectionString; 17 | } 18 | 19 | /// 20 | /// Runs a simple heartbeat check against the SQL Database instance 21 | /// 22 | /// the health check context of the current operation 23 | /// cancellation token of the async operation 24 | /// whether the heartbeat is healthy or unhealthy 25 | public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) 26 | { 27 | try 28 | { 29 | using (var connection = new SqlConnection(_connectionString)) 30 | { 31 | await connection.OpenAsync(cancellationToken); 32 | 33 | using var command = new SqlCommand("SELECT 1 FROM [sys].[objects] WHERE 1=0;", connection); 34 | await command.ExecuteScalarAsync(cancellationToken); 35 | } 36 | return HealthCheckResult.Healthy(); 37 | } 38 | catch (Exception ex) 39 | { 40 | return HealthCheckResult.Unhealthy("SQL Database is unhealthy.", ex); 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/app/Api/Middleware/AppInsightsMiddleware.cs: -------------------------------------------------------------------------------- 1 | using Api.Telemetry; 2 | using Microsoft.ApplicationInsights; 3 | 4 | namespace Api.Middleware 5 | { 6 | /// 7 | /// ASP.Net factory-based middleware that runs as part of the execution chain of each processed API request. 8 | /// This middleware captures and reports custom telemetry data to Azure Application Insights. 9 | /// 10 | /// This middleware needs to be registered as a scoped/transient service in the DI container 11 | /// as it requires a new instance of the TelemetryClient. 12 | /// 13 | public class AppInsightsMiddleware : IMiddleware 14 | { 15 | private readonly TelemetryClient _telemetryClient; 16 | 17 | public AppInsightsMiddleware(TelemetryClient telemetryClient) 18 | { 19 | _telemetryClient = telemetryClient; 20 | } 21 | 22 | public async Task InvokeAsync(HttpContext context, RequestDelegate next) 23 | { 24 | string actionName = context.Request.RouteValues["action"]?.ToString() ?? "Unknown"; 25 | _telemetryClient.CaptureEvent(actionName, context.Request, context.Response); 26 | 27 | await next.Invoke(context); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/app/Api/Middleware/HeaderEnrichmentMiddleware.cs: -------------------------------------------------------------------------------- 1 | namespace Api.Middleware 2 | { 3 | /// 4 | /// ASP.Net middleware that enriches the response data with additional headers 5 | /// containing information about the application gateway, node IP, and pod name 6 | /// used to handle the request. 7 | /// 8 | public class HeaderEnrichmentMiddleware 9 | { 10 | private readonly RequestDelegate _next; 11 | 12 | public HeaderEnrichmentMiddleware(RequestDelegate next) 13 | { 14 | _next = next; 15 | } 16 | 17 | public async Task InvokeAsync(HttpContext context) 18 | { 19 | var appGatewayIp = context.Request.Headers.FirstOrDefault(h => h.Key == "X-Forwarded-For"); 20 | var nodeIp = Environment.GetEnvironmentVariable("MY_NODE_IP"); 21 | var podName = Environment.GetEnvironmentVariable("MY_POD_NAME"); 22 | 23 | context.Response.Headers.Append("AzRef-AppGwIp", appGatewayIp.Value.FirstOrDefault()); 24 | context.Response.Headers.Append("AzRef-NodeIp", nodeIp); 25 | context.Response.Headers.Append("AzRef-PodName", podName); 26 | 27 | await _next(context); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/app/Api/Models/Common/ErrorResponse.cs: -------------------------------------------------------------------------------- 1 | namespace Api.Models.Common 2 | { 3 | /// 4 | /// HTTP response model the API returns when an error is encountered. 5 | /// Contains the error message and stack trace of the exception that occurred, if any. 6 | /// 7 | public class ErrorResponse 8 | { 9 | public string ErrorMessage { get; set; } 10 | public string StackTrace { get; set; } 11 | 12 | public ErrorResponse(Exception ex) 13 | { 14 | ErrorMessage = ex.Message; 15 | StackTrace = ex.StackTrace ?? string.Empty; 16 | } 17 | 18 | public ErrorResponse(string errorMessage) 19 | { 20 | ErrorMessage = errorMessage; 21 | StackTrace = string.Empty; 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/app/Api/Models/Common/ItemCollectionResponse.cs: -------------------------------------------------------------------------------- 1 | namespace Api.Models.Common 2 | { 3 | /// 4 | /// HTTP response model of the API, wrapping multiple items in one response. 5 | /// 6 | /// the type of objects returned 7 | public class ItemCollectionResponse 8 | { 9 | public ICollection Items { get; set; } = []; 10 | public int TotalCount { get { return Items.Count; } } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/app/Api/Models/Common/PagedResponse.cs: -------------------------------------------------------------------------------- 1 | namespace Api.Models.Common 2 | { 3 | /// 4 | /// HTTP response model for paged data. 5 | /// 6 | /// the type of objects the page holds 7 | public class PagedResponse 8 | { 9 | public int TotalCount { get; set; } 10 | public int PageSize { get; set; } 11 | public int Skipped { get; set; } 12 | public ICollection PageData { get; set; } = []; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/app/Api/Models/DTO/CartItemDto.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace Api.Models.DTO 4 | { 5 | /// 6 | /// The representation of a cart item. 7 | /// This is the model the API reports to clients. 8 | /// 9 | public class CartItemDto 10 | { 11 | public required string ConcertId { get; set; } 12 | 13 | [Range(0, 10)] 14 | public required int Quantity { get; set; } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/app/Api/Models/DTO/ConcertDto.cs: -------------------------------------------------------------------------------- 1 | namespace Api.Models.DTO 2 | { 3 | /// 4 | /// The representation of a concert. 5 | /// This is the model the API reports to clients. 6 | /// 7 | public class ConcertDto 8 | { 9 | public required string Id { get; set; } 10 | public string Artist { get; set; } = string.Empty; 11 | public string Location { get; set; } = string.Empty; 12 | public string Title { get; set; } = string.Empty; 13 | public double Price { get; set; } 14 | public DateTimeOffset StartTime { get; set; } = DateTimeOffset.UtcNow.AddDays(30); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/app/Api/Models/DTO/OrderDto.cs: -------------------------------------------------------------------------------- 1 | using Api.Models.Entities; 2 | 3 | namespace Api.Models.DTO 4 | { 5 | /// 6 | /// The representation of an order. 7 | /// This is the model the API reports to clients. 8 | /// 9 | public class OrderDto 10 | { 11 | public required string Id { get; set; } 12 | 13 | public ICollection Tickets { get; set; } = []; 14 | 15 | public required string UserId { get; set; } 16 | 17 | public DateTime CreatedDate { get; set; } = DateTime.UtcNow; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/app/Api/Models/DTO/OrderPaymentDetails.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | using System.ComponentModel; 3 | 4 | namespace Api.Models.DTO 5 | { 6 | /// 7 | /// The request body expected when creating a new order item. 8 | /// 9 | public class OrderPaymentDetails : IValidatableObject 10 | { 11 | [Required] 12 | [MaxLength(75)] 13 | [DisplayName("Cardholder Name")] 14 | public required string Cardholder { get; set; } 15 | 16 | [Required] 17 | [CreditCard] 18 | public required string CardNumber { get; set; } 19 | 20 | [Required] 21 | [MaxLength(3)] 22 | [DisplayName("Security Code")] 23 | [RegularExpression("^[0-9]{3}$", ErrorMessage = "Please enter a valid security code (3-letter CVV)")] 24 | public required string SecurityCode { get; set; } 25 | 26 | [Required] 27 | [MaxLength(4)] 28 | [DisplayName("Expiration")] 29 | [RegularExpression("^[0,1][0-9][2,3][0-9]$", ErrorMessage = "The expiration date must be MMYY format")] 30 | public required string ExpirationMonthYear { get; set; } 31 | 32 | public IEnumerable Validate(ValidationContext validationContext) 33 | { 34 | if (!string.IsNullOrEmpty(ExpirationMonthYear) && ExpirationMonthYear.Length == 4) 35 | { 36 | var monthSplitString = ExpirationMonthYear.Substring(0, 2); 37 | var yearSplitString = ExpirationMonthYear.Substring(2, 2); 38 | if (int.TryParse(monthSplitString, out int cardExpirationMonth) && int.TryParse(yearSplitString, out int cardExpirationYear)) 39 | { 40 | if (DateTimeOffset.UtcNow.Year > cardExpirationYear + 2000 41 | || DateTimeOffset.UtcNow.Year == cardExpirationYear + 2000 && DateTime.UtcNow.Month > cardExpirationMonth) 42 | { 43 | yield return new ValidationResult("Please use a card that has not expired", new[] { nameof(ExpirationMonthYear) }); 44 | } 45 | if (cardExpirationMonth > 12) 46 | { 47 | yield return new ValidationResult("The expiration date must be MMYY format", new[] { nameof(ExpirationMonthYear) }); 48 | } 49 | } 50 | } 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/app/Api/Models/DTO/TicketDto.cs: -------------------------------------------------------------------------------- 1 | using Api.Models.Entities; 2 | 3 | namespace Api.Models.DTO 4 | { 5 | /// 6 | /// The representation of a ticket. 7 | /// This is the model the API reports to clients. 8 | /// 9 | public class TicketDto 10 | { 11 | public required string Id { get; set; } 12 | 13 | public required string ConcertId { get; set; } 14 | public Concert? Concert { get; set; } 15 | 16 | public required string UserId { get; set; } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/app/Api/Models/DTO/UserDto.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace Api.Models.DTO 4 | { 5 | /// 6 | /// The representation of a user. 7 | /// This is the model the API reports to clients. 8 | /// 9 | public class UserDto 10 | { 11 | [Required] 12 | public required string Id { get; set; } 13 | 14 | [Required] 15 | [EmailAddress] 16 | public required string Email { get; set; } 17 | 18 | [StringLength(16)] 19 | public string Phone { get; set; } = string.Empty; 20 | 21 | [Required] 22 | [StringLength(64, MinimumLength = 4)] 23 | public required string DisplayName { get; set; } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/app/Api/Models/DTO/UserRequest.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace Api.Models.DTO 4 | { 5 | /// 6 | /// The HTTP request model the API accepts when creating a new user. 7 | /// 8 | public class UserRequest 9 | { 10 | [Required] 11 | [EmailAddress] 12 | public required string Email { get; set; } 13 | 14 | [StringLength(16)] 15 | public string Phone { get; set; } = string.Empty; 16 | 17 | [Required] 18 | [StringLength(32, MinimumLength = 4)] 19 | public required string DisplayName { get; set; } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/app/Api/Models/Entities/Concert.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations.Schema; 2 | 3 | namespace Api.Models.Entities 4 | { 5 | /// 6 | /// The representation of a concert. 7 | /// This is the entity modeled as the DB schema. 8 | /// 9 | public class Concert 10 | { 11 | [DatabaseGenerated(DatabaseGeneratedOption.Identity)] 12 | public string? Id { get; set; } 13 | public bool IsVisible { get; set; } 14 | public string Artist { get; set; } = string.Empty; 15 | public string Genre { get; set; } = string.Empty; 16 | public string Location { get; set; } = string.Empty; 17 | public string Title { get; set; } = string.Empty; 18 | public string Description { get; set; } = string.Empty; 19 | public double Price { get; set; } 20 | public DateTimeOffset StartTime { get; set; } = DateTimeOffset.UtcNow.AddDays(30); 21 | public DateTimeOffset CreatedOn { get; set; } = DateTimeOffset.UtcNow; 22 | public string CreatedBy { get; set; } = string.Empty; 23 | public DateTimeOffset UpdatedOn { get; set; } = DateTimeOffset.UtcNow; 24 | public string UpdatedBy { get; set; } = string.Empty; 25 | 26 | /// 27 | /// Required when the selected Ticket Management Service is not ReleCloud Api 28 | /// 29 | public string? TicketManagementServiceConcertId { get; set; } = string.Empty; 30 | } 31 | } -------------------------------------------------------------------------------- /src/app/Api/Models/Entities/Order.cs: -------------------------------------------------------------------------------- 1 | using System.Collections; 2 | using System.ComponentModel.DataAnnotations.Schema; 3 | 4 | namespace Api.Models.Entities 5 | { 6 | /// 7 | /// The representation of an order. 8 | /// This is the entity modeled as the DB schema. 9 | /// 10 | public class Order 11 | { 12 | [DatabaseGenerated(DatabaseGeneratedOption.Identity)] 13 | public string? Id { get; set; } 14 | 15 | public ICollection Tickets { get; set; } = []; 16 | 17 | public string? UserId { get; set; } 18 | public User? User { get; set; } 19 | 20 | public DateTime CreatedDate { get; set; } = DateTime.UtcNow; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/app/Api/Models/Entities/Ticket.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations.Schema; 2 | 3 | namespace Api.Models.Entities 4 | { 5 | /// 6 | /// The representation of a ticket. 7 | /// This is the entity modeled as the DB schema. 8 | /// 9 | public class Ticket 10 | { 11 | [DatabaseGenerated(DatabaseGeneratedOption.Identity)] 12 | public string? Id { get; set; } 13 | 14 | public string? ConcertId { get; set; } 15 | public Concert? Concert { get; set; } 16 | 17 | public string? UserId { get; set; } 18 | public User? User { get; set; } 19 | 20 | public DateTime CreatedDate { get; set; } = DateTime.UtcNow; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/app/Api/Models/Entities/User.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations.Schema; 2 | 3 | namespace Api.Models.Entities 4 | { 5 | /// 6 | /// The representation of a user. 7 | /// This is the entity modeled as the DB schema. 8 | /// 9 | public class User 10 | { 11 | [DatabaseGenerated(DatabaseGeneratedOption.Identity)] 12 | public string? Id { get; set; } 13 | 14 | public string DisplayName { get; set; } = string.Empty; 15 | 16 | public string Email { get; set; } = string.Empty; 17 | 18 | public string Phone { get; set; } = string.Empty; 19 | 20 | public DateTime CreatedDate { get; set; } = DateTime.UtcNow; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/app/Api/Program.cs: -------------------------------------------------------------------------------- 1 | using Api; 2 | using System.Configuration; 3 | 4 | // Load .env values 5 | DotNetEnv.Env.Load(); 6 | 7 | // Configure webapp builder. Enable developers to override settings with user secrets 8 | var builder = WebApplication.CreateBuilder(args); 9 | builder.Configuration.AddUserSecrets(optional: true); 10 | builder.Logging.AddConsole(); 11 | 12 | // Configure DI services 13 | var logger = LoggerFactory.Create(loggingBuilder => 14 | { 15 | loggingBuilder.AddConfiguration(builder.Configuration.GetSection("Logging")); 16 | }).CreateLogger(); 17 | 18 | var startup = new Startup(builder.Configuration, logger); 19 | startup.ConfigureServices(builder.Services); 20 | 21 | // Start the application 22 | var app = builder.Build(); 23 | startup.Configure(app, app.Environment); 24 | 25 | app.Run(); 26 | -------------------------------------------------------------------------------- /src/app/Api/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "Api": { 4 | "commandName": "Project", 5 | "launchBrowser": false, 6 | "environmentVariables": { 7 | "ASPNETCORE_ENVIRONMENT": "Development" 8 | }, 9 | "applicationUrl": "https://localhost:51276;http://localhost:51277" 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /src/app/Api/Services/MappingProfile.cs: -------------------------------------------------------------------------------- 1 | using Api.Models.DTO; 2 | using Api.Models.Entities; 3 | using AutoMapper; 4 | 5 | namespace Api.Services 6 | { 7 | /// 8 | /// AutoMapper configuration for mapping between DTOs and entities. 9 | /// 10 | public class MappingProfile : Profile 11 | { 12 | public MappingProfile() 13 | { 14 | CreateMap(); 15 | CreateMap(); 16 | 17 | CreateMap(); 18 | CreateMap(); 19 | 20 | CreateMap(); 21 | CreateMap(); 22 | 23 | CreateMap(); 24 | CreateMap(); 25 | 26 | CreateMap(); 27 | CreateMap(); 28 | 29 | CreateMap, CartItemDto>() 30 | .ConstructUsing(entry => new CartItemDto { ConcertId = entry.Key, Quantity = entry.Value }); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/app/Api/Services/NonAtomicTicketPurchasingService.cs: -------------------------------------------------------------------------------- 1 | using Api.Models.Entities; 2 | using Api.Services.Repositories; 3 | 4 | namespace Api.Services 5 | { 6 | /// 7 | /// Service for managing tickets. This particular implementation has no seating 8 | /// restrictions whatsoever. Tickets are available indefinitely, with a maximum purchase 9 | /// order of 10. 10 | /// 11 | /// Executes a synchronous best-effort of purchasing tickets, without any atomicity guarantees. 12 | /// This is an anti-pattern for production-grade systems, but is useful for testing purposes. 13 | /// 14 | public class NonAtomicTicketPurchasingService : TicketPurchasingService 15 | { 16 | public NonAtomicTicketPurchasingService(IConcertRepository concertRepository, ITicketRepository ticketRepository, IOrderRepository orderRepository, ICartRepository cartRepository, ILogger logger) 17 | : base(concertRepository, ticketRepository, orderRepository, cartRepository, logger) 18 | { 19 | } 20 | 21 | /// 22 | /// At all times will return 10 tickets available, without any other restrictions (infinite seating). 23 | /// A single purchase should not exceed 10 tickets. 24 | /// 25 | /// the concert ID to query the remaining ticket availability of 26 | /// the number of tickets trying to be purchased 27 | /// if the number of tickets exceeds 10" 28 | protected override Task CheckTicketAvailability(string concertId, int numTickets) 29 | { 30 | if (numTickets > 10) 31 | { 32 | throw new InvalidOperationException("Can't purchase more than 10 tickets at once."); 33 | } 34 | return Task.CompletedTask; 35 | } 36 | 37 | /// 38 | /// Executes a synchronous best-effort of purchasing tickets, without any atomicity guarantees. 39 | /// This is an anti-pattern for production-grade systems, but is useful for testing purposes. 40 | /// 41 | /// the user ID for which the purchase was requested 42 | /// the associated cart of {concertId}-{quantity} tickets to purchase 43 | /// 44 | protected async override Task ExecutePurchaseAsync(string userId, IDictionary cartData) 45 | { 46 | var tickets = await ticketRepository.TryEmitTickets(userId, cartData); 47 | var order = await orderRepository.CreateOrder(userId, tickets); 48 | await cartRepository.ClearCartAsync(userId); 49 | 50 | return order; 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/app/Api/Services/Repositories/Exceptions/NotFoundException.cs: -------------------------------------------------------------------------------- 1 | namespace Api.Services.Repositories.Exceptions 2 | { 3 | /// 4 | /// Exception thrown by any repository implementation 5 | /// when the resource requested is not found. 6 | /// 7 | public class NotFoundException : Exception 8 | { 9 | public NotFoundException(string message): base(message) 10 | { } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/app/Api/Services/Repositories/ICartRepository.cs: -------------------------------------------------------------------------------- 1 | namespace Api.Services.Repositories 2 | { 3 | /// 4 | /// Interface exposing methods for interacting with the 'Cart' entities in the database. 5 | /// 6 | public interface ICartRepository 7 | { 8 | /// 9 | /// Clears all the items in cart for the given user. 10 | /// 11 | /// the user ID whose cart to wipe 12 | Task ClearCartAsync(string userId); 13 | 14 | /// 15 | /// Retrieves the cart for the specified user. 16 | /// 17 | /// the uesr ID whose cart to retrieve 18 | /// a mapping of {concertID}-{ticketsCount} representing the items in the user's cart 19 | Task> GetCartAsync(string userId); 20 | 21 | /// 22 | /// Updates the cart for the specified user with the specified number of tickets 23 | /// for the specified concert. 24 | /// 25 | /// the user ID whose cart to update 26 | /// the concert ID the new tickets are for 27 | /// the number of tickets for the specified concert to update the cart with 28 | Task> UpdateCartAsync(string userId, string concertId, int count); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/app/Api/Services/Repositories/IConcertRepository.cs: -------------------------------------------------------------------------------- 1 | using Api.Models.Entities; 2 | 3 | namespace Api.Services.Repositories 4 | { 5 | /// 6 | /// Interface exposing methods for interacting with the 'Concert' entities in the database. 7 | /// 8 | public interface IConcertRepository 9 | { 10 | /// 11 | /// Creates and stores a new concert entry in the database. 12 | /// 13 | /// the concert entity representation to store 14 | /// the newly created and tracked entity model in the DB 15 | /// if the concert entity could not be found 16 | Task CreateConcertAsync(Concert newConcert); 17 | 18 | /// 19 | /// Updates an existing concert entry in the database. 20 | /// 21 | /// the concert entity representation to update, containing the updates to apply 22 | /// the newly updated and tracked entity model in the DB 23 | /// if the concert entity could not be found 24 | Task UpdateConcertAsync(Concert model); 25 | 26 | /// 27 | /// Deletes the concert entry with the specified ID from the database. 28 | /// 29 | /// the ID of the concert to delete 30 | /// if the user entity could not be found 31 | Task DeleteConcertAsync(string concertId); 32 | 33 | /// 34 | /// Retrieves the concert entry with the specified ID from the database. 35 | /// 36 | /// 37 | /// the tracked concert entity 38 | /// if the user entity could not be found 39 | Task GetConcertByIdAsync(string concertId); 40 | 41 | /// 42 | /// Retrieves the latest upcoming concerts from the database. 43 | /// 44 | /// the maximum number of concerts to retrieve 45 | /// the concert model entities of the first upcoming concerts 46 | Task> GetUpcomingConcertsAsync(int count); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/app/Api/Services/Repositories/IOrderRepository.cs: -------------------------------------------------------------------------------- 1 | using Api.Models.Common; 2 | using Api.Models.Entities; 3 | 4 | namespace Api.Services.Repositories 5 | { 6 | /// 7 | /// Interface exposing methods for interacting with the 'Order' entities in the database. 8 | /// 9 | public interface IOrderRepository 10 | { 11 | /// 12 | /// Retrieve an order by its ID. 13 | /// 14 | /// the ID of the order to retrieve 15 | /// the tracked model entity of the order 16 | /// if the order entity could not be found 17 | Task GetOrderByIdAsync(string orderId); 18 | 19 | /// 20 | /// Creates a new order for the specified user with the specified tickets. 21 | /// 22 | /// the ID of the user the order is for 23 | /// the collection of tickets to checkout as part of this order 24 | /// the tracked order database entity that has been newly created 25 | Task CreateOrder(string userId, ICollection tickets); 26 | 27 | /// 28 | /// Retrieves all orders for the specified user, paginated. 29 | /// 30 | /// the ID of the user whose orders to retrieve 31 | /// the number of orders, ordered by timestamp, to skip 32 | /// the number of orders to retrieve, starting from {skip} onwards 33 | /// a page of tracked order entities 34 | Task> GetAllOrdersForUserAsync(string userId, int skip, int take); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/app/Api/Services/Repositories/ITicketRepository.cs: -------------------------------------------------------------------------------- 1 | using Api.Models.Common; 2 | using Api.Models.Entities; 3 | 4 | namespace Api.Services.Repositories 5 | { 6 | /// 7 | /// Interface exposing methods for interacting with the 'Ticket' entities in the database. 8 | /// 9 | public interface ITicketRepository 10 | { 11 | /// 12 | /// Retrieves the ticket with the specified ID. 13 | /// 14 | /// the ID of the ticket to retrieve 15 | /// 16 | Task GetTicketByIdAsync(string ticketId); 17 | 18 | /// 19 | /// Emits (i.e. creates the ticket entities in the DB) for the given user and the associated cart data. 20 | /// 21 | /// the user ID to emit the tickets for 22 | /// the cart data mapping of {concertID}-{ticketsCount} to emit 23 | /// the collection of tickets that were successfully committed to the DB 24 | Task> TryEmitTickets(string userId, IDictionary cart); 25 | 26 | /// 27 | /// Retrieves all tickets for the specified user, paginated. 28 | /// 29 | /// the user ID whose tickets to retrieve 30 | /// the number of tickets, ordered by timestamp, to skip 31 | /// the number of tickets to retrieve, starting from {skip} onwards 32 | /// 33 | Task> GetAllTicketsForUserAsync(string userId, int skip, int take); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/app/Api/Services/Repositories/IUserRepository.cs: -------------------------------------------------------------------------------- 1 | using Api.Models.Entities; 2 | 3 | namespace Api.Services.Repositories 4 | { 5 | /// 6 | /// Interface exposing methods for interacting with the 'User' entities in the database. 7 | /// 8 | public interface IUserRepository 9 | { 10 | /// 11 | /// Creates a new user, as specified by the provided user entity. 12 | /// 13 | /// the user model representation of the new user to create 14 | /// the tracked user entity that has been created 15 | Task CreateUserAsync(User user); 16 | 17 | /// 18 | /// Updates an existing user. 19 | /// 20 | /// the user entity to update. Contains all the new attributes to update, alongside the ID 21 | /// the tracked, updated user model entity 22 | Task UpdateUserAsync(User user); 23 | 24 | /// 25 | /// Retrieves a user by their ID. 26 | /// 27 | /// the ID of the user to retrieve 28 | /// the tracked user entity found 29 | /// if the user entity could not be found" 30 | Task GetUserByIdAsync(string id); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/app/Api/Services/TicketPurchasingService.cs: -------------------------------------------------------------------------------- 1 | using Api.Models.Entities; 2 | using Api.Services.Repositories; 3 | using Microsoft.IdentityModel.Tokens; 4 | 5 | namespace Api.Services 6 | { 7 | /// 8 | /// The base interface for the ticket management service. 9 | /// Each implementation will represent a different strategy of managing 10 | /// tickets' availability and reservation. 11 | /// 12 | public abstract class TicketPurchasingService 13 | { 14 | protected readonly IConcertRepository concertRepository; 15 | protected readonly ITicketRepository ticketRepository; 16 | protected readonly IOrderRepository orderRepository; 17 | protected readonly ICartRepository cartRepository; 18 | protected readonly ILogger logger; 19 | 20 | public TicketPurchasingService(IConcertRepository concertRepository, 21 | ITicketRepository ticketRepository, 22 | IOrderRepository orderRepository, 23 | ICartRepository cartRepository, 24 | ILogger logger) 25 | { 26 | this.concertRepository = concertRepository; 27 | this.ticketRepository = ticketRepository; 28 | this.orderRepository = orderRepository; 29 | this.cartRepository = cartRepository; 30 | this.logger = logger; 31 | } 32 | 33 | /// 34 | /// Returns whether or not the specified number of tickets are available for purchase. 35 | /// 36 | /// the concert to query the availability of 37 | /// the number of available tickets 38 | /// if there are no tickets available for the given purchase 39 | protected abstract Task CheckTicketAvailability(string concertId, int numTickets); 40 | 41 | protected abstract Task ExecutePurchaseAsync(string userId, IDictionary cartData); 42 | 43 | /// 44 | /// Checks out and purchases the tickets found in the given user's cart. 45 | /// If for any reason the tickets couldn't be purchased, an exception is thrown. 46 | /// If the purchase is successful, the cart is cleared and the purchased tickets are returned. 47 | /// 48 | /// the user ID whose cart to checkout 49 | /// the purchased tickets 50 | /// the checkout operation could not be processed due to ticket availability or user input 51 | public async Task TryCheckoutTickets(string userId) 52 | { 53 | var cart = await cartRepository.GetCartAsync(userId); 54 | if (cart.IsNullOrEmpty()) 55 | { 56 | throw new InvalidOperationException("Can't checkout tickets. Cart is empty."); 57 | } 58 | 59 | // Validate availability for all concerts 60 | await Task.WhenAll(cart.Select(cartItem => CheckTicketAvailability(cartItem.Key, cartItem.Value))); 61 | 62 | // TODO: This is the async part that should be implemented by a derived, async-running, service 63 | // that guarantees the atomicity of the all the operations involved in a checkout. 64 | // I.e. a good opportunity to integrate another QCS decoupling Azure service (ServiceBus / LogicApps / Functions etc.). 65 | // The API will expose the necessary REST endpoints for all the required operations. The async service 66 | // will orchestrate them. 67 | return await ExecutePurchaseAsync(userId, cart); 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/app/Api/Startup.cs: -------------------------------------------------------------------------------- 1 | using StackExchange.Redis; 2 | using Azure.Identity; 3 | using Microsoft.ApplicationInsights.AspNetCore.Extensions; 4 | using AutoMapper; 5 | using Api.Infrastructure; 6 | using Hangfire; 7 | using Hangfire.MemoryStorage; 8 | using Api.Services.Repositories; 9 | using Api.Services; 10 | using Microsoft.Data.SqlClient; 11 | using Microsoft.EntityFrameworkCore; 12 | using Api.Infrastructure.Datastore; 13 | using Api.Middleware; 14 | 15 | namespace Api 16 | { 17 | public class Startup 18 | { 19 | private readonly bool _isDevelopment; 20 | private readonly bool _useSelfHostedDatastore; 21 | 22 | private readonly IConfiguration _configuration; 23 | private readonly ILogger _logger; 24 | 25 | public Startup(IConfiguration configuration, ILogger logger) 26 | { 27 | Configuration = configuration; 28 | _logger = logger; 29 | 30 | _isDevelopment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")?.Equals("Development", StringComparison.OrdinalIgnoreCase) ?? false; 31 | _useSelfHostedDatastore = Environment.GetEnvironmentVariable("USE_SELF_HOSTED_DATASTORE") != null; 32 | } 33 | 34 | public IConfiguration Configuration { get; } 35 | 36 | public void ConfigureServices(IServiceCollection services) 37 | { 38 | services.AddControllers(options => options.SuppressAsyncSuffixInActionNames = false); 39 | 40 | // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle 41 | services.AddEndpointsApiExplorer(); 42 | services.AddSwaggerGen(); 43 | services.AddHealthChecks(); 44 | 45 | // Configure Hangfire 46 | services.AddHangfire(config => config.UseMemoryStorage()); 47 | services.AddHangfireServer(); 48 | 49 | // Configure Azure Application Insights for capturing telemetry 50 | var aiOptions = new ApplicationInsightsServiceOptions(); 51 | aiOptions.EnableAdaptiveSampling = false; 52 | aiOptions.EnableQuickPulseMetricStream = false; 53 | 54 | services.AddApplicationInsightsTelemetry(aiOptions); 55 | 56 | // Configure AutoMapper with our DTO <-> Entity mappings 57 | var mapperConfig = new MapperConfiguration(mc => 58 | { 59 | mc.AddProfile(new MappingProfile()); 60 | }); 61 | 62 | IMapper mapper = mapperConfig.CreateMapper(); 63 | services.AddSingleton(mapper); 64 | 65 | // Configure Azure Redis cache 66 | AddAzureCacheForRedis(services); 67 | 68 | // Configure DB Context 69 | var maxRetryCount = int.Parse(Configuration["RPOL_CONNECT_RETRY"] ?? "3"); 70 | var maxRetryDelay = int.Parse(Configuration["RPOL_BACKOFF_DELTA"] ?? "1500"); 71 | 72 | var sqlConnectionString = GetSqlConnectionString(); 73 | 74 | services.AddDbContextPool(options => options.UseSqlServer(sqlConnectionString, 75 | sqlServerOptionsAction: sqlOptions => 76 | { 77 | sqlOptions.EnableRetryOnFailure( 78 | maxRetryCount: maxRetryCount, 79 | maxRetryDelay: TimeSpan.FromMilliseconds(maxRetryDelay), 80 | errorNumbersToAdd: null); 81 | })); 82 | 83 | // Register factory-based middlewares as scoped services inside the DI container 84 | services.AddScoped(); 85 | 86 | // Register the app's services 87 | services.AddScoped(); 88 | services.AddScoped(); 89 | services.AddScoped(); 90 | services.AddScoped(); 91 | services.AddScoped(); 92 | 93 | services.AddScoped(); 94 | services.AddScoped(); 95 | 96 | // Add health checks for our DBs 97 | services.AddHealthChecks() 98 | .AddCheck("redis") 99 | .AddCheck("sql"); 100 | 101 | services.AddScoped(); 102 | services.AddScoped(); 103 | } 104 | 105 | private void AddAzureCacheForRedis(IServiceCollection services) 106 | { 107 | ConfigurationOptions redisOptions = GetRedisConfiguration(); 108 | 109 | services.AddSingleton(sp => { 110 | var connection = ConnectionMultiplexer.Connect(redisOptions); 111 | return connection; 112 | }); 113 | 114 | services.AddStackExchangeRedisCache(options => 115 | { 116 | options.ConfigurationOptions = redisOptions; 117 | }); 118 | } 119 | 120 | private ConfigurationOptions GetRedisConfiguration() 121 | { 122 | var serviceEndpoint = Environment.GetEnvironmentVariable("REDIS_ENDPOINT"); 123 | 124 | if (string.IsNullOrEmpty(serviceEndpoint)) 125 | { 126 | throw new Exception("No 'REDIS_ENDPOINT' set. Can't connect to any Redis instance."); 127 | } 128 | 129 | var serviceEndpointWithPort = serviceEndpoint.Contains(':') ? serviceEndpoint : $"{serviceEndpoint}:6380"; 130 | var configurationOptions = ConfigurationOptions.Parse(serviceEndpointWithPort); 131 | 132 | if (!_useSelfHostedDatastore) 133 | { 134 | _logger.LogInformation("Using Managed Identity to authenticate against Redis."); 135 | 136 | configurationOptions = configurationOptions.ConfigureForAzureWithTokenCredentialAsync( 137 | new DefaultAzureCredential(includeInteractiveCredentials: _isDevelopment)).GetAwaiter().GetResult(); 138 | } 139 | 140 | return configurationOptions; 141 | } 142 | 143 | /// 144 | /// Returns the SQL connection string used to connect to the SQL database. The connection 145 | /// configuration is based on the environment variables set in the app's configuration. 146 | /// Authorization is done using: 147 | /// - Managed Identity by default -- when the datastore is deployed in Azure 148 | /// - Interactive login if running in development mode -- when the datastore is deployed in Azure, but app is running locally 149 | /// - SQL Server username & password if the datastore is self-hosted (in VMs/containers) 150 | /// 151 | /// the managed connection string used to connect to the SQL database, using the most restrictive authorization configuration 152 | /// if the app is misconfigured (missing configurations) 153 | private string GetSqlConnectionString() 154 | { 155 | var sqlEndpoint = Environment.GetEnvironmentVariable("SQL_ENDPOINT"); 156 | var sqlAppDatabaseName = Environment.GetEnvironmentVariable("SQL_APP_DATABASE_NAME"); 157 | var sqlUser = Environment.GetEnvironmentVariable("SQL_USER"); 158 | var sqlPassword = Environment.GetEnvironmentVariable("SQL_PASSWORD"); 159 | 160 | if (string.IsNullOrEmpty(sqlEndpoint) || string.IsNullOrEmpty(sqlAppDatabaseName)) 161 | { 162 | throw new Exception("No 'SQL_ENDPOINT' or 'SQL_APP_DATABASE_NAME' set. Can't connect to any SQL instance."); 163 | } 164 | 165 | if (_useSelfHostedDatastore && (string.IsNullOrEmpty(sqlUser) || string.IsNullOrEmpty(sqlPassword))) 166 | { 167 | throw new Exception("App is configured to run with self hosted datastores but no 'SQL_USER' and/or 'SQL_PASSWORD' set."); 168 | } 169 | 170 | 171 | var sqlConnectionStringBuilder = new SqlConnectionStringBuilder 172 | { 173 | DataSource = sqlEndpoint, 174 | InitialCatalog = sqlAppDatabaseName, 175 | Authentication = SqlAuthenticationMethod.ActiveDirectoryDefault, 176 | Encrypt = true, 177 | TrustServerCertificate = false 178 | }; 179 | 180 | if (_isDevelopment) 181 | { 182 | _logger.LogWarning("Running in development mode. Allowing for interactive Entra ID login."); 183 | 184 | sqlConnectionStringBuilder.Encrypt = false; 185 | sqlConnectionStringBuilder.TrustServerCertificate = true; 186 | sqlConnectionStringBuilder.Authentication = SqlAuthenticationMethod.ActiveDirectoryInteractive; 187 | } 188 | 189 | if (_useSelfHostedDatastore) 190 | { 191 | _logger.LogWarning("Using a self-hosted SQL Server. Connecting through server username & password."); 192 | 193 | sqlConnectionStringBuilder.Authentication = SqlAuthenticationMethod.SqlPassword; 194 | sqlConnectionStringBuilder.UserID = sqlUser; 195 | sqlConnectionStringBuilder.Password = sqlPassword; 196 | } 197 | 198 | return sqlConnectionStringBuilder.ConnectionString; 199 | } 200 | 201 | public void Configure(WebApplication app, IWebHostEnvironment env) 202 | { 203 | // Migrate DB and seed data 204 | using (var scope = app.Services.CreateScope()) 205 | { 206 | var services = scope.ServiceProvider; 207 | services.GetRequiredService().Initialize(); 208 | } 209 | 210 | // Setup API Swagger documentation 211 | app.UseSwagger(c => 212 | { 213 | c.RouteTemplate = "api/swagger/{documentName}/swagger.json"; 214 | }); 215 | app.UseSwaggerUI(c => 216 | { 217 | c.SwaggerEndpoint("/api/swagger/v1/swagger.json", "ECommerce API V1"); 218 | c.RoutePrefix = "api/swagger"; 219 | }); 220 | 221 | using var serviceScope = app.Services.CreateScope(); 222 | 223 | // Configure the HTTP request pipeline. 224 | if (!env.IsDevelopment()) 225 | { 226 | app.UseExceptionHandler("/Home/Error"); 227 | // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. 228 | app.UseHsts(); 229 | } 230 | 231 | // Register middleware 232 | app.UseMiddleware(); 233 | app.UseMiddleware(); 234 | 235 | // Setup controllers, along with a health check endpoint 236 | app.UseRouting(); 237 | app.MapControllers(); 238 | 239 | app.MapGet("/api/live", async context => 240 | { 241 | await context.Response.WriteAsync("Healthy"); 242 | }); 243 | 244 | app.UseHangfireDashboard(); 245 | } 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /src/app/Api/Telemetry/InfrastructureMetadata.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Primitives; 2 | 3 | namespace Api.Telemetry 4 | { 5 | /// 6 | /// POCO representing custom infrastructure information to report as telemetry 7 | /// to Azure Application Insights for each processed API request. 8 | /// 9 | public class InfrastructureMetadata 10 | { 11 | public string? Namespace { get; set; } 12 | public string? PodName { get; set; } 13 | public string? PodIP { get; set; } 14 | public string? ServiceAccount { get; set; } 15 | public string? NodeName { get; set; } 16 | public string? NodeIP { get; set; } 17 | public string? AppGatewayIP { get; set; } 18 | 19 | public static InfrastructureMetadata FromHttpRequest(HttpRequest request) 20 | { 21 | request.Headers.TryGetValue("X-Forwarded-For", out StringValues appGwIps); 22 | 23 | var metadata = new InfrastructureMetadata 24 | { 25 | NodeName = Environment.GetEnvironmentVariable("MY_NODE_NAME"), 26 | NodeIP = Environment.GetEnvironmentVariable("MY_NODE_IP"), 27 | PodName = Environment.GetEnvironmentVariable("MY_POD_NAME"), 28 | Namespace = Environment.GetEnvironmentVariable("MY_POD_NAMESPACE"), 29 | PodIP = Environment.GetEnvironmentVariable("MY_POD_IP"), 30 | ServiceAccount = Environment.GetEnvironmentVariable("MY_POD_SERVICE_ACCOUNT"), 31 | AppGatewayIP = appGwIps.FirstOrDefault() 32 | }; 33 | 34 | return metadata; 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/app/Api/Telemetry/OperationBreadcrumb.cs: -------------------------------------------------------------------------------- 1 | namespace Api.Telemetry 2 | { 3 | /// 4 | /// POCO representing the custom model of telemetry data reported 5 | /// to Azure Application Insights for each processed API request. 6 | /// 7 | public class OperationBreadcrumb 8 | { 9 | public required string OperationStatus { get; set; } 10 | public required string OperationName { get; set; } 11 | public string? SessionId { get; set; } 12 | public string? RequestId { get; set; } 13 | public int RetryCount { get; internal set; } 14 | public InfrastructureMetadata? Infrastructure { get; set; } 15 | 16 | public Dictionary ToDictionary() 17 | { 18 | return new Dictionary 19 | { 20 | { "Status", OperationStatus }, 21 | { "OperationName", OperationName }, 22 | { "SessionID", SessionId ?? string.Empty }, 23 | { "RequestID", RequestId ?? string.Empty }, 24 | { "RetryCount", RetryCount.ToString() }, 25 | { "Infrastructure", Infrastructure.Serialize() } 26 | }; 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/app/Api/Telemetry/OperationStatus.cs: -------------------------------------------------------------------------------- 1 | namespace Api.Telemetry 2 | { 3 | /// 4 | /// Available operation statuses reported to telemetry. Represent the possible 5 | /// outcomes of a processed API request. 6 | /// 7 | public class OperationStatus 8 | { 9 | private OperationStatus(string value) { Value = value; } 10 | 11 | public string Value { get; private set; } 12 | 13 | public static OperationStatus Ok { get { return new OperationStatus("OK"); } } 14 | public static OperationStatus ClientError { get { return new OperationStatus("CLIENT_ERROR"); } } 15 | public static OperationStatus ServerError { get { return new OperationStatus("SERVER_ERROR"); } } 16 | 17 | public static OperationStatus FromResponse(HttpResponse response) 18 | { 19 | if (response.StatusCode >= 200 && response.StatusCode < 300) 20 | { 21 | return Ok; 22 | } 23 | if (response.StatusCode >= 400 && response.StatusCode < 500) 24 | { 25 | return ClientError; 26 | } 27 | return ServerError; 28 | } 29 | 30 | public override string ToString() 31 | { 32 | return Value; 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/app/Api/Telemetry/QueryStringConstants.cs: -------------------------------------------------------------------------------- 1 | using System.Web; 2 | 3 | namespace Api.Telemetry 4 | { 5 | /// 6 | /// Static class enhancing the HttpRequest object with utility methods 7 | /// for processing & extracting query string parameters. 8 | /// 9 | public static class QueryStringConstants 10 | { 11 | public const string SESSION_ID = "AZREF_SESSION_ID"; 12 | public const string REQUEST_ID = "AZREF_REQUEST_ID"; 13 | public const string RETRY_COUNT = "AZREF_RETRY_COUNT"; 14 | 15 | public static string ExtractQueryString(this HttpRequest request, string queryParamKey) 16 | { 17 | string? headerValue = null; 18 | if (request.QueryString.HasValue) 19 | { 20 | var queryParams = HttpUtility.ParseQueryString(request.QueryString.Value); 21 | headerValue = queryParams[queryParamKey]; 22 | } 23 | return headerValue ?? string.Empty; 24 | } 25 | 26 | public static int ExtractIntegerFromQueryString(this HttpRequest request, string queryParamKey) 27 | { 28 | string? headerValue = null; 29 | if (request.QueryString.HasValue) 30 | { 31 | var queryParams = HttpUtility.ParseQueryString(request.QueryString.Value); 32 | headerValue = queryParams[queryParamKey]; 33 | } 34 | 35 | int.TryParse(headerValue ?? "-1", out var hearderIntValue); 36 | return hearderIntValue; 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/app/Api/Telemetry/TelemetryHelpers.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.ApplicationInsights; 2 | using System.Text.Json; 3 | 4 | namespace Api.Telemetry 5 | { 6 | /// Static class enhancing the TelemetryClient object with utility methods 7 | /// for capturing and reporting custom telemetry data to Azure Application Insights. 8 | /// 9 | public static class TelemetryHelpers 10 | { 11 | public static void CaptureEvent (this TelemetryClient telemetryClient, string operationName, HttpRequest request, HttpResponse response) 12 | { 13 | var operationStatus = OperationStatus.FromResponse(response); 14 | var breadCrumb = new OperationBreadcrumb() 15 | { 16 | OperationStatus = operationStatus.Value, 17 | OperationName = operationName, 18 | SessionId = request.ExtractQueryString(QueryStringConstants.SESSION_ID), 19 | RequestId = request.ExtractQueryString(QueryStringConstants.REQUEST_ID), 20 | RetryCount = request.ExtractIntegerFromQueryString(QueryStringConstants.RETRY_COUNT), 21 | Infrastructure = InfrastructureMetadata.FromHttpRequest(request) 22 | }; 23 | 24 | telemetryClient.TrackEvent(breadCrumb.OperationName, breadCrumb.ToDictionary()); 25 | } 26 | 27 | public static string Serialize(this T objectData) 28 | { 29 | return JsonSerializer.Serialize(objectData); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/app/Api/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning", 6 | "Microsoft.Hosting.Lifetime": "Warning", 7 | "OrderClient.Controllers.CartsController": "Information", 8 | "OrderClient.Controllers.OrdersController": "Information", 9 | "OrderClient.Controllers.UsersController": "Information" 10 | } 11 | }, 12 | "AllowedHosts": "*", 13 | "RPOL_CONNECT_RETRY": 3, 14 | "RPOL_BACKOFF_DELTA": 1000, 15 | "DATABASE_CLEANUP_ENABLED": false, 16 | "DATABASE_CLEANUP_RECORD_COUNT": 100, 17 | "DATABASE_CLEANUP_THRESHOLD_MINUTES": 60 18 | } 19 | -------------------------------------------------------------------------------- /src/app/Api/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning", 6 | "Microsoft.Hosting.Lifetime": "Warning", 7 | "OrderClient.Controllers.CartsController": "Information", 8 | "OrderClient.Controllers.OrdersController": "Information", 9 | "OrderClient.Controllers.UsersController": "Information" 10 | } 11 | }, 12 | "AllowedHosts": "*", 13 | "RPOL_CONNECT_RETRY": 3, 14 | "RPOL_BACKOFF_DELTA": 1000, 15 | "DATABASE_CLEANUP_ENABLED": false, 16 | "DATABASE_CLEANUP_RECORD_COUNT": 100, 17 | "DATABASE_CLEANUP_THRESHOLD_MINUTES": 60 18 | } 19 | -------------------------------------------------------------------------------- /src/app/Api/push-image.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Exit on errors 4 | set -o errexit -o pipefail -o noclobber 5 | 6 | # Parse command line arguments 7 | for ARG in "$@"; do 8 | case $ARG in 9 | -tag=*|--image-tag=*) 10 | TAG="${ARG#*=}" 11 | shift 12 | ;; 13 | -acr=*|--acr-name=*) 14 | ACR="${ARG#*=}" 15 | shift 16 | ;; 17 | -h|--help) 18 | echo " 19 | Usage: $0 [-rg=rg-name] [-cl=client-location] [-sl=service-location] [--what-if] 20 | 21 | -tag, --image-tag Image tag. e.g. staging, latest 22 | -acr, --acr-name Azure Container Registry name. e.g. test.azureacr.io 23 | -bn, --build-number Build number. Required when tag is latest. e.g. 1 24 | -h, --help Show this help message 25 | 26 | Examples: 27 | $0 -sl=eastus 28 | " 29 | exit 0 30 | ;; 31 | -*|--*) 32 | echo "Unknown argument '$ARG'" >&2 33 | exit 1 34 | ;; 35 | *) 36 | ;; 37 | esac 38 | done 39 | 40 | 41 | if [[ -z $TAG ]];then 42 | echo "Need valid image TAG on command line, arg1. Example -tag=staging -acr=test.azureacr.io" 43 | exit 1 44 | fi 45 | 46 | if [[ -z $ACR ]];then 47 | echo "Need valid ACR on command line, arg1. Example -tag=staging -acr=test.azureacr.io" 48 | exit 1 49 | fi 50 | 51 | 52 | SCRIPT_PATH=$( cd "$(dirname "${BASH_SOURCE[0]}")" ; pwd -P ) 53 | IMAGE=api-webapp 54 | 55 | set -e 56 | az acr login --name $ACR 57 | 58 | docker buildx build --platform linux/amd64 -t $ACR/$IMAGE:$TAG -f $SCRIPT_PATH/Dockerfile $SCRIPT_PATH/. 59 | 60 | docker push $ACR/$IMAGE:$TAG -------------------------------------------------------------------------------- /src/chaos/chaosExperimentMain.bicep: -------------------------------------------------------------------------------- 1 | @description('The existing VMSS resource you want to target in this experiment') 2 | param vmssName string 3 | 4 | @description('VMSS resource group name') 5 | param vmssResourceGroup string 6 | 7 | @description('Desired name for your Chaos Experiment') 8 | param experimentName string = 'VMSS-ZoneDown-Experiment-${vmssName}' 9 | 10 | 11 | // Define Chaos Studio experiment steps for a VMSS Zone Down Experiment 12 | var experimentSteps = [ 13 | { 14 | name: 'Step1' 15 | branches: [ 16 | { 17 | name: 'Branch1' 18 | actions: [ 19 | { 20 | name: 'urn:csci:microsoft:virtualMachineScaleSet:shutdown/2.0' 21 | type: 'continuous' 22 | duration: 'PT5M' 23 | parameters: [ 24 | { 25 | key: 'abruptShutdown' 26 | value: 'true' 27 | } 28 | ] 29 | selectorId: 'Selector1' 30 | } 31 | ] 32 | } 33 | ] 34 | } 35 | ] 36 | 37 | // Create a Chaos Target. This is a child of the VMSS resource and needs to be deployed to the same resource group 38 | module chaosTarget './chaosTarget.bicep' = { 39 | name: 'chaosTarget' 40 | scope: resourceGroup(vmssResourceGroup) 41 | params: { 42 | vmssName: vmssName 43 | } 44 | } 45 | 46 | 47 | // Deploy the Chaos Studio experiment resource 48 | resource chaosExperiment 'Microsoft.Chaos/experiments@2024-01-01' = { 49 | name: experimentName 50 | location: 'westus' 51 | identity: { 52 | type: 'SystemAssigned' 53 | } 54 | properties: { 55 | selectors: [ 56 | { 57 | id: 'Selector1' 58 | type: 'List' 59 | targets: [ 60 | { 61 | id: chaosTarget.outputs.targetResourceId 62 | type: 'ChaosTarget' 63 | } 64 | ] 65 | filter: { 66 | type:'Simple' 67 | parameters: { 68 | zones:['1'] 69 | } 70 | } 71 | } 72 | ] 73 | steps: experimentSteps 74 | } 75 | } 76 | 77 | // Assign RBAC roles to the Chaos Experiment to allow it to target the VMSS 78 | module vmssRoleAssignment './vmssRoleAssignment.bicep' = { 79 | name: 'vmssRoleAssignment' 80 | scope: resourceGroup(vmssResourceGroup) 81 | params: { 82 | vmssName: vmssName 83 | chaosExperimentPrincipalId: chaosExperiment.identity.principalId 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/chaos/chaosTarget.bicep: -------------------------------------------------------------------------------- 1 | @description('The existing VMSS resource you want to target in this experiment') 2 | param vmssName string 3 | 4 | // Reference the existing Virtual Machine resource 5 | resource vmss 'Microsoft.Compute/virtualMachineScaleSets@2024-03-01' existing = { 6 | name: vmssName 7 | } 8 | 9 | // Deploy the Chaos Studio target resource to the Virtual Machine Scale Set 10 | resource chaosTarget 'Microsoft.Chaos/targets@2024-01-01' = { 11 | name: 'Microsoft-VirtualMachineScaleSet' 12 | scope: vmss 13 | properties: {} 14 | 15 | // Define the capability -- in this case, VM Shutdown 16 | resource chaosCapability 'capabilities' = { 17 | name: 'Shutdown-2.0' 18 | } 19 | } 20 | 21 | output targetResourceId string = chaosTarget.id 22 | -------------------------------------------------------------------------------- /src/chaos/create-chaos-experiment.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Exit on errors 4 | set -o errexit -o pipefail -o noclobber 5 | 6 | [[ -f .env ]] && source .env 7 | 8 | # Parse command line arguments 9 | for ARG in "$@"; do 10 | case $ARG in 11 | -rg=*|--resource-group=*) 12 | RESOURCE_GROUP_NAME="${ARG#*=}" 13 | shift 14 | ;; 15 | -*|--*) 16 | echo "Unknown argument '$ARG'" >&2 17 | exit 1 18 | ;; 19 | *) 20 | ;; 21 | esac 22 | done 23 | 24 | # Validate command line arguments 25 | if [ -z $RESOURCE_GROUP_NAME ]; then 26 | echo "No resource group provided and .env file is empty. Please provide a resource group name as command line argument. E.g. '$0 -rg=my-rg-name'" >&2 27 | exit 1 28 | fi 29 | 30 | echo "Fetching AKS cluster name from resource group '$RESOURCE_GROUP_NAME'" 31 | VMSS_RESOURCE_GROUP=$(az aks list --resource-group "$RESOURCE_GROUP_NAME" --query "[0].nodeResourceGroup" -o tsv) 32 | 33 | echo "Fetching VMSS name from resource group '$VMSS_RESOURCE_GROUP'" 34 | VMSS_NAME=$(az vmss list --resource-group "$VMSS_RESOURCE_GROUP" --query "[?contains(name, 'aks-user')] | [0].name" -o tsv) 35 | 36 | SCRIPT_PATH=$( cd "$(dirname "${BASH_SOURCE[0]}")" ; pwd -P ) 37 | 38 | 39 | echo "Deploying Chaos Resources for VMSS '$VMSS_NAME'" 40 | az deployment group create \ 41 | --resource-group $RESOURCE_GROUP_NAME \ 42 | --name "Deploy-Chaos-Resources" \ 43 | --template-file $SCRIPT_PATH/chaosExperimentMain.bicep \ 44 | --parameters vmssName="$VMSS_NAME" \ 45 | --parameters vmssResourceGroup="$VMSS_RESOURCE_GROUP" \ 46 | --verbose 47 | 48 | DEFAULT_DOMAIN=$(az rest --method get --url https://graph.microsoft.com/v1.0/domains --query 'value[?isDefault].id' -o tsv) 49 | 50 | SUBSCRIPTION_ID=$(az account show --query id -o tsv) 51 | 52 | echo "You can use the following link to access the Chaos Experiment in the Azure Portal and start it:" 53 | echo "https://portal.azure.com/#@$DEFAULT_DOMAIN/resource/subscriptions/$SUBSCRIPTION_ID/resourceGroups/$RESOURCE_GROUP_NAME/providers/Microsoft.Chaos/experiments/VMSS-ZoneDown-Experiment-$VMSS_NAME/experimentOverview" 54 | 55 | echo "Or you can use the following command to start the Chaos Experiment:" 56 | echo "./start-chaos-fault.sh" 57 | 58 | -------------------------------------------------------------------------------- /src/chaos/vmssRoleAssignment.bicep: -------------------------------------------------------------------------------- 1 | @description('The existing VMSS resource you want to target in this experiment') 2 | param vmssName string 3 | 4 | @description('The existing Chaos Experiment resource') 5 | param chaosExperimentPrincipalId string 6 | 7 | // Reference the existing Virtual Machine resource 8 | resource vmss 'Microsoft.Compute/virtualMachineScaleSets@2024-03-01' existing = { 9 | name: vmssName 10 | } 11 | 12 | 13 | // Define the role definition for the Chaos experiment 14 | resource chaosRoleDefinition 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { 15 | scope: vmss 16 | // In this case, Virtual Machine Contributor role -- see https://learn.microsoft.com/azure/role-based-access-control/built-in-roles 17 | name: '9980e02c-c2be-4d73-94e8-173b1dc7cf3c' 18 | } 19 | 20 | // Define the role assignment for the Chaos experiment 21 | resource chaosRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { 22 | name: guid(vmss.id, chaosExperimentPrincipalId, chaosRoleDefinition.id) 23 | scope: vmss 24 | properties: { 25 | roleDefinitionId: chaosRoleDefinition.id 26 | principalId: chaosExperimentPrincipalId 27 | principalType: 'ServicePrincipal' 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/infra/acr.bicep: -------------------------------------------------------------------------------- 1 | @description('The region where the ACR is deployed in.') 2 | param location string 3 | 4 | @description(''' 5 | The suffix of the unique identifier for the resources of the current deployment. 6 | Used to avoid name collisions and to link resources part of the same deployment together. 7 | ''') 8 | param resourceSuffixUID string 9 | 10 | @description('The resource ID of the VNET where the ACR is connected to.') 11 | param vnetId string 12 | 13 | @description('The resource ID of the subnet where the ACR is connected to.') 14 | param infraSubnetId string 15 | 16 | @description('The resource ID of the Log Analytics workspace to which the ACR is connected to.') 17 | param workspaceId string 18 | 19 | var containerRegistryName = 'containerregistry${resourceSuffixUID}' 20 | 21 | resource containerRegistry 'Microsoft.ContainerRegistry/registries@2021-09-01' = { 22 | name: containerRegistryName 23 | location: location 24 | sku: { 25 | name: 'Premium' 26 | } 27 | properties: { 28 | adminUserEnabled: true 29 | dataEndpointEnabled: false 30 | policies: { 31 | quarantinePolicy: { 32 | status: 'disabled' 33 | } 34 | retentionPolicy: { 35 | status: 'enabled' 36 | days: 7 37 | } 38 | trustPolicy: { 39 | status: 'disabled' 40 | type: 'Notary' 41 | } 42 | } 43 | publicNetworkAccess: 'Enabled' 44 | zoneRedundancy: 'Enabled' 45 | } 46 | } 47 | 48 | // private DNS zone 49 | resource privateDnsZone 'Microsoft.Network/privateDnsZones@2020-06-01' = { 50 | name: 'privatelink.azurecr.io' 51 | location: 'global' 52 | } 53 | // link to VNET 54 | resource privateDnsZoneVnetLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2020-06-01' = { 55 | parent: privateDnsZone 56 | name: 'vnetLink' 57 | location: 'global' 58 | properties: { 59 | registrationEnabled: false 60 | virtualNetwork: { 61 | id: vnetId 62 | } 63 | } 64 | } 65 | 66 | resource privateEndpoint 'Microsoft.Network/privateEndpoints@2022-01-01' = { 67 | name: '${containerRegistryName}-acr' 68 | location: location 69 | properties: { 70 | privateLinkServiceConnections: [ 71 | { 72 | name: 'acr-link' 73 | properties: { 74 | privateLinkServiceId: containerRegistry.id 75 | groupIds: [ 76 | 'registry' 77 | ] 78 | } 79 | } 80 | ] 81 | subnet: { 82 | id: infraSubnetId 83 | } 84 | } 85 | 86 | // register in DNS 87 | resource privateDnsZoneGroup 'privateDnsZoneGroups@2022-01-01' = { 88 | name: 'acr-dns' 89 | properties:{ 90 | privateDnsZoneConfigs: [ 91 | { 92 | name: 'acr-config' 93 | properties:{ 94 | privateDnsZoneId: privateDnsZone.id 95 | } 96 | } 97 | ] 98 | } 99 | } 100 | } 101 | 102 | resource diagnosticLogs 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { 103 | name: containerRegistry.name 104 | scope:containerRegistry 105 | properties: { 106 | workspaceId: workspaceId 107 | logs: [ 108 | { 109 | categoryGroup: 'audit' 110 | enabled: true 111 | } 112 | { 113 | categoryGroup: 'allLogs' 114 | enabled: true 115 | } 116 | ] 117 | metrics: [ 118 | { 119 | category: 'AllMetrics' 120 | enabled: true 121 | } 122 | ] 123 | } 124 | } 125 | 126 | output containerRegistryId string = containerRegistry.id 127 | output containerRegistryName string = containerRegistry.name 128 | output resourceGroupName string = resourceGroup().name 129 | -------------------------------------------------------------------------------- /src/infra/aks.bicep: -------------------------------------------------------------------------------- 1 | @description('The region where the AKS cluster is deployed in.') 2 | param location string 3 | @description(''' 4 | The suffix of the unique identifier for the resources of the current deployment. 5 | Used to avoid name collisions and to link resources part of the same deployment together. 6 | ''') 7 | param resourceSuffixUID string 8 | 9 | @description('The name of the Virtual Network under which the subnet where the AKS cluster is deployed in.') 10 | param vnetName string 11 | @description('The resource ID of the subnet where the AKS cluster is deployed in.') 12 | param aksSubnetId string 13 | @description('The resource ID of the Log Analytics workspace to which the AKS cluster is connected to.') 14 | param workspaceId string 15 | @description('The resource ID of the public IP address used for load balancer of the AKS cluster.') 16 | param outboundPublicIPId string 17 | 18 | @description('The fixed number of system node pool nodes. This value is fixed as no auto-scaling is configured by default.') 19 | param systemNodeCount int 20 | @description('The minimum number of user node pool nodes.') 21 | param minUserNodeCount int 22 | @description('The maximum number of user node pool nodes.') 23 | param maxUserNodeCount int 24 | @description('The maximum number of PODs per user node') 25 | param maxUserPodsCount int 26 | @description('The VM size (SKU) of the nodes in the AKS cluster.') 27 | param nodeVMSize string 28 | @description('The OS SKU of the nodes in the AKS cluster.') 29 | @allowed(['Ubuntu', 'AKS', 'AKS-Preview']) 30 | param nodeOsSKU string 31 | 32 | 33 | @description('The Kubernetes version that the AKS cluster runs. Check for compatibility with helm and nginx-controller before modifying.') 34 | var kubeVersion = '1.29.4' 35 | 36 | @description(''' 37 | Allows for the management of networks, without access to them. This role does not grant permission to deploy or manage Virtual Machines. 38 | See: https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/networking#network-contributor 39 | ''') 40 | var networkContributorRoleDefinitionId = resourceId( 41 | 'Microsoft.Authorization/roleDefinitions', 42 | '4d97b98b-1d4f-4787-a291-c67834d212e7' 43 | ) 44 | 45 | 46 | // Create the Azure kubernetes service cluster 47 | resource aks 'Microsoft.ContainerService/managedClusters@2024-02-01' = { 48 | name: 'aks-${resourceSuffixUID}' 49 | location: location 50 | sku: { 51 | name: 'Base' 52 | tier: 'Standard' 53 | } 54 | identity: { 55 | type: 'SystemAssigned' 56 | } 57 | properties: { 58 | kubernetesVersion: kubeVersion 59 | enableRBAC: true 60 | dnsPrefix: 'refapp' 61 | disableLocalAccounts: true 62 | aadProfile: { 63 | enableAzureRBAC: true 64 | managed: true 65 | } 66 | agentPoolProfiles: [ 67 | { 68 | name: 'system' 69 | count: systemNodeCount 70 | mode: 'System' 71 | vmSize: nodeVMSize 72 | type: 'VirtualMachineScaleSets' 73 | osType: 'Linux' 74 | osSKU: nodeOsSKU 75 | enableAutoScaling: false 76 | vnetSubnetID: aksSubnetId 77 | availabilityZones: ['1', '2', '3'] 78 | tags: { 79 | azsecpack: 'nonprod' 80 | AzSecPackAutoConfigReady: 'true' 81 | 'platformsettings.host_environment.service.platform_optedin_for_rootcerts': 'true' 82 | } 83 | } 84 | { 85 | name: 'user' 86 | mode: 'User' 87 | vmSize: nodeVMSize 88 | type: 'VirtualMachineScaleSets' 89 | osType: 'Linux' 90 | osSKU: nodeOsSKU 91 | enableAutoScaling: true 92 | count: minUserNodeCount 93 | minCount: minUserNodeCount 94 | maxCount: maxUserNodeCount 95 | maxPods: maxUserPodsCount 96 | vnetSubnetID: aksSubnetId 97 | availabilityZones: ['1', '2', '3'] 98 | tags: { 99 | azsecpack: 'nonprod' 100 | AzSecPackAutoConfigReady: 'true' 101 | 'platformsettings.host_environment.service.platform_optedin_for_rootcerts': 'true' 102 | } 103 | } 104 | ] 105 | oidcIssuerProfile: { 106 | enabled: true 107 | } 108 | // https://learn.microsoft.com/en-us/azure/aks/auto-upgrade-node-os-image 109 | autoUpgradeProfile: { 110 | nodeOSUpgradeChannel: 'NodeImage' 111 | upgradeChannel: 'stable' 112 | } 113 | securityProfile: { 114 | workloadIdentity: { 115 | enabled: true 116 | } 117 | } 118 | apiServerAccessProfile: { 119 | enablePrivateCluster: true 120 | enablePrivateClusterPublicFQDN: false 121 | } 122 | servicePrincipalProfile: { 123 | clientId: 'msi' 124 | } 125 | networkProfile: { 126 | networkPlugin: 'azure' 127 | loadBalancerSku: 'standard' 128 | serviceCidr: '10.1.0.0/16' 129 | dnsServiceIP: '10.1.0.10' 130 | loadBalancerProfile: { 131 | outboundIPs: { 132 | publicIPs: [ 133 | { 134 | id: outboundPublicIPId 135 | } 136 | ] 137 | } 138 | } 139 | } 140 | addonProfiles: { 141 | omsagent: { 142 | config: { 143 | logAnalyticsWorkspaceResourceID: workspaceId 144 | } 145 | enabled: true 146 | } 147 | azureKeyvaultSecretsProvider: { 148 | enabled: true 149 | config: { 150 | enableSecretRotation: 'false' 151 | rotationPollInterval: '2m' 152 | } 153 | } 154 | } 155 | } 156 | } 157 | 158 | // See: https://learn.microsoft.com/en-us/azure/azure-monitor/reference/supported-logs/microsoft-containerservice-managedclusters-logs 159 | resource diagnosticLogs 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { 160 | name: aks.name 161 | scope: aks 162 | properties: { 163 | workspaceId: workspaceId 164 | logAnalyticsDestinationType: 'Dedicated' 165 | logs: [ 166 | { 167 | category: 'kube-apiserver' 168 | enabled: true 169 | } 170 | { 171 | category: 'kube-audit' 172 | enabled: true 173 | } 174 | { 175 | category: 'kube-audit-admin' 176 | enabled: true 177 | } 178 | { 179 | category: 'kube-controller-manager' 180 | enabled: true 181 | } 182 | { 183 | category: 'kube-scheduler' 184 | enabled: true 185 | } 186 | { 187 | category: 'cluster-autoscaler' 188 | enabled: true 189 | } 190 | { 191 | category: 'cloud-controller-manager' 192 | enabled: true 193 | } 194 | { 195 | category: 'guard' 196 | enabled: true 197 | } 198 | { 199 | category: 'csi-azuredisk-controller' 200 | enabled: true 201 | } 202 | { 203 | category: 'csi-azurefile-controller' 204 | enabled: true 205 | } 206 | { 207 | category: 'csi-snapshot-controller' 208 | enabled: true 209 | } 210 | ] 211 | metrics: [ 212 | { 213 | category: 'AllMetrics' 214 | enabled: true 215 | } 216 | ] 217 | } 218 | } 219 | 220 | // Assign the 'Network Contributor' role to the AKS service principal on the VNet 221 | // it's deployed in, so that AKS can create the internal load balancer with a 222 | // static public IP from within the AKS subnet. 223 | // See: https://learn.microsoft.com/en-us/azure/aks/configure-kubenet#prerequisites 224 | resource vnet 'Microsoft.Network/virtualNetworks@2023-11-01' existing = { 225 | name: vnetName 226 | } 227 | 228 | resource networkContributorRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { 229 | name: guid(resourceGroup().id, vnet.id, networkContributorRoleDefinitionId) 230 | scope: vnet 231 | properties: { 232 | description: 'Lets AKS manage the VNet, but not access it' 233 | principalId: aks.identity.principalId 234 | principalType: 'ServicePrincipal' 235 | roleDefinitionId: networkContributorRoleDefinitionId 236 | } 237 | } 238 | 239 | output aksid string = aks.id 240 | output aksnodesrg string = aks.properties.nodeResourceGroup 241 | output aksPrivateFqdn string = aks.properties.privateFQDN 242 | output kubeletIdentityObjectId string = aks.properties.identityProfile.kubeletidentity.objectId 243 | -------------------------------------------------------------------------------- /src/infra/appGateway.bicep: -------------------------------------------------------------------------------- 1 | @description('The region where the AKS cluster is deployed in.') 2 | param location string 3 | 4 | @description(''' 5 | The suffix of the unique identifier for the resources of the current deployment. 6 | Used to avoid name collisions and to link resources part of the same deployment together. 7 | ''') 8 | param resourceSuffixUID string 9 | 10 | @description('The Id of the Subnet where the App Gateway is deployed in.') 11 | param appGatewaySubnetId string 12 | 13 | @description('The private IP address of the Load Balancer to which the App Gateway routes traffic.') 14 | param loadBalancerPrivateIp string 15 | 16 | @description('The resource ID of the Log Analytics workspace to which the App Gateway is connected to.') 17 | param workspaceId string 18 | 19 | @description('The minimum capacity of the App Gateway.') 20 | param minAppGatewayCapacity int = 3 21 | 22 | @description('The maximum capacity of the App Gateway.') 23 | param maxAppGatewayCapacity int = 125 24 | 25 | var appGatewayName = 'appgw-${resourceSuffixUID}' 26 | 27 | 28 | resource publicIPAddress 'Microsoft.Network/publicIPAddresses@2021-05-01' = { 29 | name: 'appgw-pip-${resourceSuffixUID}' 30 | location: location 31 | zones: ['1','2','3'] 32 | sku: { 33 | name: 'Standard' 34 | } 35 | properties: { 36 | publicIPAddressVersion: 'IPv4' 37 | publicIPAllocationMethod: 'Static' 38 | idleTimeoutInMinutes: 4 39 | } 40 | } 41 | 42 | resource applicationGateWay 'Microsoft.Network/applicationGateways@2021-05-01' = { 43 | name: appGatewayName 44 | location: location 45 | zones: ['1','2','3'] 46 | properties: { 47 | sku: { 48 | name: 'Standard_v2' 49 | tier: 'Standard_v2' 50 | } 51 | autoscaleConfiguration: { 52 | maxCapacity: maxAppGatewayCapacity 53 | minCapacity: minAppGatewayCapacity 54 | } 55 | gatewayIPConfigurations: [ 56 | { 57 | name: 'appGatewayIpConfig' 58 | properties: { 59 | subnet: { 60 | id: appGatewaySubnetId 61 | } 62 | } 63 | } 64 | ] 65 | frontendIPConfigurations: [ 66 | { 67 | name: 'appGwPublicFrontendIp' 68 | properties: { 69 | privateIPAllocationMethod: 'Dynamic' 70 | publicIPAddress: { 71 | id: publicIPAddress.id 72 | } 73 | } 74 | } 75 | ] 76 | frontendPorts: [ 77 | { 78 | name: 'port_80' 79 | properties: { 80 | port: 80 81 | } 82 | } 83 | ] 84 | backendAddressPools: [ 85 | { 86 | name: 'myBackendPool' 87 | properties: { 88 | backendAddresses: [ 89 | { 90 | ipAddress: loadBalancerPrivateIp 91 | } 92 | ] 93 | } 94 | } 95 | ] 96 | backendHttpSettingsCollection: [ 97 | { 98 | name: 'webAppSettings' 99 | properties: { 100 | port: 80 101 | protocol: 'Http' 102 | cookieBasedAffinity: 'Disabled' 103 | pickHostNameFromBackendAddress: false 104 | requestTimeout: 20 105 | probe: { 106 | id: resourceId('Microsoft.Network/applicationGateways/probes', appGatewayName, 'app-health') 107 | } 108 | } 109 | } 110 | ] 111 | httpListeners: [ 112 | { 113 | name: 'httpListener' 114 | properties: { 115 | frontendIPConfiguration: { 116 | id: resourceId('Microsoft.Network/applicationGateways/frontendIPConfigurations', appGatewayName, 'appGwPublicFrontendIp') 117 | } 118 | frontendPort: { 119 | id: resourceId('Microsoft.Network/applicationGateways/frontendPorts', appGatewayName, 'port_80') 120 | } 121 | protocol: 'Http' 122 | requireServerNameIndication: false 123 | } 124 | } 125 | ] 126 | urlPathMaps:[ 127 | { 128 | name: 'httpRule' 129 | properties: { 130 | defaultBackendAddressPool :{ 131 | id: resourceId('Microsoft.Network/applicationGateways/backendAddressPools', appGatewayName, 'myBackendPool') 132 | } 133 | defaultBackendHttpSettings: { 134 | id: resourceId('Microsoft.Network/applicationGateways/backendHttpSettingsCollection', appGatewayName, 'webAppSettings') 135 | } 136 | pathRules: [ 137 | { 138 | name: 'cart' 139 | properties: { 140 | paths: [ 141 | '/api/*' 142 | ] 143 | backendAddressPool: { 144 | id: resourceId('Microsoft.Network/applicationGateways/backendAddressPools', appGatewayName, 'myBackendPool') 145 | } 146 | backendHttpSettings: { 147 | id: resourceId('Microsoft.Network/applicationGateways/backendHttpSettingsCollection', appGatewayName, 'webAppSettings') 148 | } 149 | } 150 | } 151 | ] 152 | } 153 | } 154 | ] 155 | requestRoutingRules: [ 156 | { 157 | name: 'httpRule' 158 | properties: { 159 | ruleType: 'PathBasedRouting' 160 | priority: 1 161 | httpListener: { 162 | id: resourceId('Microsoft.Network/applicationGateways/httpListeners', appGatewayName, 'httpListener') 163 | } 164 | urlPathMap: { 165 | id: resourceId('Microsoft.Network/applicationGateways/urlPathMaps', appGatewayName, 'httpRule') 166 | } 167 | } 168 | } 169 | ] 170 | probes: [ 171 | { 172 | name: 'app-health' 173 | properties: { 174 | protocol: 'Http' 175 | host: '127.0.0.1' 176 | path: '/api/live' 177 | interval: 30 178 | timeout: 30 179 | unhealthyThreshold: 3 180 | pickHostNameFromBackendHttpSettings: false 181 | minServers: 0 182 | match: {} 183 | } 184 | } 185 | ] 186 | enableHttp2: false 187 | } 188 | } 189 | 190 | resource diagnosticLogs 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { 191 | name: applicationGateWay.name 192 | scope:applicationGateWay 193 | properties: { 194 | workspaceId: workspaceId 195 | logs: [ 196 | { 197 | categoryGroup: 'allLogs' 198 | enabled: true 199 | } 200 | ] 201 | metrics: [ 202 | { 203 | category: 'AllMetrics' 204 | enabled: true 205 | } 206 | ] 207 | } 208 | } 209 | 210 | resource diagnosticLogsPublicIP 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { 211 | name: publicIPAddress.name 212 | scope:publicIPAddress 213 | properties: { 214 | workspaceId: workspaceId 215 | logs: [ 216 | { 217 | categoryGroup: 'allLogs' 218 | enabled: true 219 | } 220 | ] 221 | metrics: [ 222 | { 223 | category: 'AllMetrics' 224 | enabled: true 225 | } 226 | ] 227 | } 228 | } 229 | 230 | output publicIpAddress string = publicIPAddress.properties.ipAddress 231 | -------------------------------------------------------------------------------- /src/infra/appInsights.bicep: -------------------------------------------------------------------------------- 1 | @description('The region where the App Insights is deployed in.') 2 | param location string 3 | 4 | @description(''' 5 | The suffix of the unique identifier for the resources of the current deployment. 6 | Used to avoid name collisions and to link resources part of the same deployment together. 7 | ''') 8 | param resourceSuffixUID string 9 | 10 | @description('The resource ID of the Log Analytics workspace to which the App Insights is connected to.') 11 | param workspaceId string 12 | 13 | @description('The name of the Key Vault where the App Insights connection string is stored.') 14 | param keyVaultName string 15 | 16 | resource insights 'Microsoft.Insights/components@2020-02-02' = { 17 | name: 'insights-${resourceSuffixUID}' 18 | location: location 19 | kind: 'web' 20 | properties: { 21 | Application_Type: 'web' 22 | DisableLocalAuth: false 23 | ForceCustomerStorageForProfiler: false 24 | IngestionMode: 'LogAnalytics' 25 | publicNetworkAccessForIngestion: 'Enabled' 26 | publicNetworkAccessForQuery: 'Enabled' 27 | RetentionInDays: 30 28 | SamplingPercentage: json('100') 29 | WorkspaceResourceId: workspaceId 30 | } 31 | } 32 | 33 | // populate connection string in key vault 34 | resource keyVault 'Microsoft.KeyVault/vaults@2021-11-01-preview' existing = { 35 | name: keyVaultName 36 | } 37 | 38 | resource appInsightsConnectionStringSecret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = { 39 | parent: keyVault 40 | name: 'app-insights-connection-string' 41 | properties: { 42 | value: insights.properties.ConnectionString 43 | } 44 | } 45 | 46 | output appInsightsName string = insights.name 47 | output appInsightsId string = insights.id 48 | -------------------------------------------------------------------------------- /src/infra/azureSqlDatabase.bicep: -------------------------------------------------------------------------------- 1 | param location string 2 | param resourceSuffixUID string 3 | param vnetId string 4 | param infraSubnetId string 5 | param workspaceId string 6 | param azureSqlZoneRedundant bool 7 | param keyVaultName string 8 | param managedIdentityClientId string 9 | 10 | resource sqlServer 'Microsoft.Sql/servers@2023-05-01-preview' = { 11 | name: 'sql-${resourceSuffixUID}' 12 | location: location 13 | properties: { 14 | // using a workaround to set the admin as the managed identity of the AKS clusters. Do not use this in production. 15 | administrators: { 16 | azureADOnlyAuthentication: true 17 | login: 'ManagedIdentityAdmin' 18 | administratorType: 'ActiveDirectory' 19 | sid: managedIdentityClientId 20 | tenantId: subscription().tenantId 21 | principalType: 'Application' 22 | } 23 | minimalTlsVersion: '1.2' 24 | publicNetworkAccess: 'Disabled' 25 | version: '12.0' 26 | } 27 | } 28 | 29 | var sqlAppDatabaseName = 'az-ref-app' 30 | var sqlCatalogName = sqlAppDatabaseName 31 | var skuTierName = 'Premium' 32 | var dtuCapacity = 125 33 | var requestedBackupStorageRedundancy = 'Local' 34 | var readScale = 'Enabled' 35 | 36 | resource sqlDatabase 'Microsoft.Sql/servers/databases@2023-05-01-preview' = { 37 | parent: sqlServer 38 | name: 'az-ref-app' 39 | location: location 40 | tags: { 41 | displayName: sqlCatalogName 42 | } 43 | sku: { 44 | name: skuTierName 45 | tier: skuTierName 46 | capacity: dtuCapacity 47 | } 48 | properties: { 49 | requestedBackupStorageRedundancy: requestedBackupStorageRedundancy 50 | readScale: readScale 51 | zoneRedundant: azureSqlZoneRedundant 52 | } 53 | } 54 | 55 | /* 56 | // To allow applications hosted inside Azure to connect to your SQL server, Azure connections must be enabled. 57 | // To enable Azure connections, there must be a firewall rule with starting and ending IP addresses set to 0.0.0.0. 58 | // This recommended rule is only applicable to Azure SQL Database. 59 | // Ref: https://learn.microsoft.com/azure/azure-sql/database/firewall-configure?view=azuresql#connections-from-inside-azure 60 | resource allowAllWindowsAzureIps 'Microsoft.Sql/servers/firewallRules@2021-11-01-preview' = { 61 | name: 'AllowAllWindowsAzureIps' 62 | parent: sqlServer 63 | properties: { 64 | endIpAddress: '0.0.0.0' 65 | startIpAddress: '0.0.0.0' 66 | } 67 | } 68 | 69 | */ 70 | 71 | // private DNS zone 72 | resource privateDnsZone 'Microsoft.Network/privateDnsZones@2020-06-01' = { 73 | name: 'privatelink${environment().suffixes.sqlServerHostname}' 74 | location: 'global' 75 | } 76 | 77 | // link to VNET 78 | resource privateDnsZoneVnetLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2020-06-01' = { 79 | parent: privateDnsZone 80 | name: 'vnetLink' 81 | location: 'global' 82 | properties: { 83 | registrationEnabled: false 84 | virtualNetwork: { 85 | id: vnetId 86 | } 87 | } 88 | } 89 | 90 | resource privateEndpoint 'Microsoft.Network/privateEndpoints@2022-01-01' = { 91 | name: 'sql-privatelink-${resourceSuffixUID}' 92 | location: location 93 | properties: { 94 | privateLinkServiceConnections: [ 95 | { 96 | name: 'sql-link' 97 | properties: { 98 | privateLinkServiceId: sqlServer.id 99 | groupIds: ['sqlServer'] 100 | } 101 | } 102 | ] 103 | subnet: { 104 | id: infraSubnetId 105 | } 106 | } 107 | // register in DNS 108 | resource privateDnsZoneGroup 'privateDnsZoneGroups@2022-01-01' = { 109 | name: 'sql-dns-${resourceSuffixUID}' 110 | properties: { 111 | privateDnsZoneConfigs: [ 112 | { 113 | name: 'sql-config' 114 | properties: { 115 | privateDnsZoneId: privateDnsZone.id 116 | } 117 | } 118 | ] 119 | } 120 | } 121 | } 122 | 123 | resource diagnosticLogs 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { 124 | name: sqlDatabase.name 125 | scope: sqlDatabase 126 | properties: { 127 | workspaceId: workspaceId 128 | logs: [ 129 | { 130 | categoryGroup: 'allLogs' 131 | enabled: true 132 | } 133 | ] 134 | metrics: [ 135 | { 136 | category: 'Basic' 137 | enabled: true 138 | } 139 | { 140 | category: 'InstanceAndAppAdvanced' 141 | enabled: true 142 | } 143 | { 144 | category: 'WorkloadManagement' 145 | enabled: true 146 | } 147 | ] 148 | } 149 | } 150 | 151 | // put connection properties into key vault 152 | resource keyVault 'Microsoft.KeyVault/vaults@2021-11-01-preview' existing = { 153 | name: keyVaultName 154 | } 155 | // application database name 156 | resource sqlAppDatabaseNameSecret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = { 157 | parent: keyVault 158 | name: 'sql-app-database-name' 159 | properties: { 160 | value: sqlAppDatabaseName 161 | } 162 | } 163 | // Azure SQL Endpoints (auth is via AAD) 164 | resource AzureSqlEndpointSecret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = { 165 | parent: keyVault 166 | name: 'azure-sql-endpoint' 167 | properties: { 168 | value: sqlServer.properties.fullyQualifiedDomainName 169 | } 170 | } 171 | 172 | output sqlServerFqdn string = sqlServer.properties.fullyQualifiedDomainName 173 | output sqlCatalogName string = sqlCatalogName 174 | 175 | output sqlServerName string = sqlServer.name 176 | output sqlServerId string = sqlServer.id 177 | output sqlDatabaseName string = sqlDatabase.name 178 | -------------------------------------------------------------------------------- /src/infra/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Exit on errors 4 | set -o errexit -o pipefail -o noclobber 5 | 6 | # Parse command line arguments 7 | for ARG in "$@"; do 8 | case $ARG in 9 | -rg=*|--resource-group=*) 10 | RESOURCE_GROUP_NAME="${ARG#*=}" 11 | shift 12 | ;; 13 | -*|--*) 14 | echo "Unknown argument '$ARG'" >&2 15 | exit 1 16 | ;; 17 | *) 18 | ;; 19 | esac 20 | done 21 | 22 | # Validate command line arguments 23 | if [ -z $RESOURCE_GROUP_NAME ]; then 24 | echo "No resource group provided. Please provide a resource group name as command line argument. E.g. '$0 -rg=my-rg-name'" >&2 25 | exit 1 26 | fi 27 | 28 | 29 | # Deploy Az Ref App to the specified resource group 30 | RESOURCES_SUFFIX_UID=${RESOURCE_GROUP_NAME: -6} 31 | DEPLOYMENT_NAME=refapp-deploy 32 | 33 | SCRIPT_PATH=$( cd "$(dirname "${BASH_SOURCE[0]}")" ; pwd -P ) 34 | 35 | az deployment group create \ 36 | --resource-group $RESOURCE_GROUP_NAME \ 37 | --name $DEPLOYMENT_NAME \ 38 | --template-file $SCRIPT_PATH/main.bicep \ 39 | --parameters $SCRIPT_PATH/main.bicepparam \ 40 | --parameters resourceSuffixUID=$RESOURCES_SUFFIX_UID \ 41 | --verbose 42 | -------------------------------------------------------------------------------- /src/infra/externalLoadBalancerIP.bicep: -------------------------------------------------------------------------------- 1 | @description('The region where the Public IP is deployed in.') 2 | param location string 3 | 4 | @description(''' 5 | The suffix of the unique identifier for the resources of the current deployment. 6 | Used to avoid name collisions and to link resources part of the same deployment together. 7 | ''') 8 | param resourceSuffixUID string 9 | 10 | @description('The resource ID of the Log Analytics workspace to which the Public IP is connected to.') 11 | param workspaceId string 12 | 13 | resource publicIP 'Microsoft.Network/publicIPAddresses@2022-01-01' = { 14 | name: 'elb-pip-${resourceSuffixUID}' 15 | location: location 16 | zones: ['1', '2', '3'] 17 | sku: { 18 | name: 'Standard' 19 | } 20 | properties: { 21 | publicIPAllocationMethod: 'Static' 22 | publicIPAddressVersion: 'IPv4' 23 | } 24 | } 25 | 26 | resource diagnosticLogs 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { 27 | name: publicIP.name 28 | scope: publicIP 29 | properties: { 30 | workspaceId: workspaceId 31 | logs: [ 32 | { 33 | categoryGroup: 'audit' 34 | enabled: true 35 | } 36 | ] 37 | metrics: [ 38 | { 39 | category: 'AllMetrics' 40 | enabled: true 41 | } 42 | ] 43 | } 44 | } 45 | 46 | output externalLoadBalancerIP string = publicIP.properties.ipAddress 47 | output externalLoadBalancerIPId string = publicIP.id 48 | -------------------------------------------------------------------------------- /src/infra/frontDoor.bicep: -------------------------------------------------------------------------------- 1 | @description(''' 2 | The suffix of the unique identifier for the resources of the current deployment. 3 | Used to avoid name collisions and to link resources part of the same deployment together. 4 | ''') 5 | param resourceSuffixUID string 6 | 7 | @description('The time in seconds that the origin server has to respond to a request.') 8 | param originResponseTimeSeconds int = 90 9 | 10 | @description('The public IP address of the App Gateway.') 11 | param appGatewayPublicIp string 12 | 13 | @description('The resource ID of the Log Analytics workspace to which the Front Door is connected to.') 14 | param workspaceId string 15 | 16 | 17 | var frontDoorSkuName = 'Premium_AzureFrontDoor' 18 | 19 | resource frontDoorProfile 'Microsoft.Cdn/profiles@2022-11-01-preview' = { 20 | name: 'cdn-profile-${resourceSuffixUID}' 21 | location: 'global' 22 | sku: { 23 | name: frontDoorSkuName 24 | } 25 | properties: { 26 | originResponseTimeoutSeconds: originResponseTimeSeconds 27 | } 28 | } 29 | 30 | resource frontDoorEndpoint 'Microsoft.Cdn/profiles/afdEndpoints@2022-11-01-preview' = { 31 | name: 'afd-endpoint-${resourceSuffixUID}' 32 | parent: frontDoorProfile 33 | location: 'global' 34 | properties: { 35 | enabledState: 'Enabled' 36 | } 37 | } 38 | 39 | resource frontDoorOriginGroup 'Microsoft.Cdn/profiles/originGroups@2022-11-01-preview' = { 40 | name: 'default-origin-group' 41 | parent: frontDoorProfile 42 | properties: { 43 | loadBalancingSettings: { 44 | sampleSize: 4 45 | successfulSamplesRequired: 3 46 | additionalLatencyInMilliseconds: 50 47 | } 48 | healthProbeSettings: { 49 | probePath: '/api/live' 50 | probeRequestType: 'GET' 51 | probeProtocol: 'Http' 52 | probeIntervalInSeconds: 100 53 | } 54 | } 55 | } 56 | 57 | resource frontDoorOrigin 'Microsoft.Cdn/profiles/originGroups/origins@2022-11-01-preview' = { 58 | name: 'default-origin' 59 | parent: frontDoorOriginGroup 60 | properties: { 61 | hostName: appGatewayPublicIp 62 | httpPort: 80 63 | httpsPort: 443 64 | originHostHeader: appGatewayPublicIp 65 | priority: 1 66 | weight: 1000 67 | enabledState: 'Enabled' 68 | enforceCertificateNameCheck: false 69 | } 70 | } 71 | 72 | resource frontDoorRoute 'Microsoft.Cdn/profiles/afdEndpoints/routes@2022-11-01-preview' = { 73 | name: 'default-route' 74 | parent: frontDoorEndpoint 75 | dependsOn: [ 76 | frontDoorOrigin // This explicit dependency is required to ensure that the origin group is not empty when the route is created. 77 | ] 78 | properties: { 79 | originGroup: { 80 | id: frontDoorOriginGroup.id 81 | } 82 | supportedProtocols: [ 83 | 'Http' 84 | 'Https' 85 | ] 86 | patternsToMatch: [ 87 | '/*' 88 | ] 89 | forwardingProtocol: 'HttpOnly' 90 | linkToDefaultDomain: 'Enabled' 91 | httpsRedirect: 'Enabled' 92 | enabledState: 'Enabled' 93 | } 94 | } 95 | 96 | resource diagnosticLogs 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { 97 | name: frontDoorProfile.name 98 | scope: frontDoorProfile 99 | properties: { 100 | workspaceId: workspaceId 101 | logs: [ 102 | { 103 | categoryGroup: 'audit' 104 | enabled: true 105 | } 106 | ] 107 | metrics: [ 108 | { 109 | category: 'AllMetrics' 110 | enabled: true 111 | } 112 | ] 113 | } 114 | } 115 | 116 | var wafPolicyName = replace('waf${resourceSuffixUID}', '-', '') 117 | 118 | resource frontdoorFirewallPolicy 'Microsoft.Network/FrontDoorWebApplicationFirewallPolicies@2022-05-01' = { 119 | name: wafPolicyName 120 | location: 'Global' 121 | sku: { 122 | name: frontDoorSkuName 123 | } 124 | properties: { 125 | managedRules: { 126 | managedRuleSets: [ 127 | { 128 | ruleSetType: 'Microsoft_BotManagerRuleSet' 129 | ruleSetVersion: '1.1' 130 | ruleGroupOverrides: [] 131 | exclusions: [] 132 | } 133 | ] 134 | } 135 | policySettings: { 136 | enabledState: 'Enabled' 137 | mode: 'Prevention' 138 | } 139 | } 140 | } 141 | resource cdn_waf_security_policy 'Microsoft.Cdn/profiles/securitypolicies@2021-06-01' = { 142 | parent: frontDoorProfile 143 | name: wafPolicyName 144 | properties: { 145 | parameters: { 146 | wafPolicy: { 147 | id: frontdoorFirewallPolicy.id 148 | } 149 | associations: [ 150 | { 151 | domains: [ 152 | { 153 | id: frontDoorEndpoint.id 154 | } 155 | ] 156 | patternsToMatch: [ 157 | '/*' 158 | ] 159 | } 160 | ] 161 | type: 'WebApplicationFirewall' 162 | } 163 | } 164 | } 165 | 166 | output frontDoorEndpointHostName string = frontDoorEndpoint.properties.hostName 167 | -------------------------------------------------------------------------------- /src/infra/keyvault.bicep: -------------------------------------------------------------------------------- 1 | @description('The region where the KeyVault is deployed in.') 2 | param location string 3 | 4 | @description(''' 5 | The suffix of the unique identifier for the resources of the current deployment. 6 | Used to avoid name collisions and to link resources part of the same deployment together. 7 | ''') 8 | param resourceSuffixUID string 9 | 10 | @description('The resource ID of the VNET where the KeyVault is connected to.') 11 | param vnetId string 12 | 13 | @description('The resource ID of the subnet where the KeyVault is connected to.') 14 | param infraSubnetId string 15 | 16 | @description('The resource ID of the Log Analytics workspace to which the KeyVault is connected to.') 17 | param workspaceId string 18 | 19 | // Note: no specific configuration for Zone Redundancy 20 | // See: https://learn.microsoft.com/en-us/Azure/key-vault/general/disaster-recovery-guidance 21 | resource keyVault 'Microsoft.KeyVault/vaults@2022-07-01' = { 22 | name: 'keyvault-${resourceSuffixUID}' 23 | location: location 24 | properties: { 25 | sku: { 26 | name: 'standard' 27 | family: 'A' 28 | } 29 | tenantId: tenant().tenantId 30 | enableRbacAuthorization: true 31 | enableSoftDelete: true 32 | publicNetworkAccess: 'Disabled' 33 | networkAcls: { 34 | bypass: 'AzureServices' 35 | defaultAction: 'Deny' 36 | ipRules: [] 37 | virtualNetworkRules: [] 38 | } 39 | } 40 | } 41 | 42 | // private DNS zone 43 | resource privateDnsZone 'Microsoft.Network/privateDnsZones@2020-06-01' = { 44 | name: 'privatelink.vaultcore.azure.net' // See: https://github.com/Azure/bicep/issues/9708 45 | location: 'global' 46 | } 47 | 48 | // link to VNET 49 | resource privateDnsZoneVnetLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2020-06-01' = { 50 | parent: privateDnsZone 51 | name: 'vnetLink' 52 | location: 'global' 53 | properties: { 54 | registrationEnabled: false 55 | virtualNetwork: { 56 | id: vnetId 57 | } 58 | } 59 | } 60 | 61 | resource privateEndpoint 'Microsoft.Network/privateEndpoints@2022-01-01' = { 62 | name: 'keyvault-pl-${resourceSuffixUID}' 63 | location: location 64 | properties: { 65 | privateLinkServiceConnections: [ 66 | { 67 | name: 'keyvault-link' 68 | properties: { 69 | privateLinkServiceId: keyVault.id 70 | groupIds: [ 71 | 'vault' 72 | ] 73 | } 74 | } 75 | ] 76 | subnet: { 77 | id: infraSubnetId 78 | } 79 | } 80 | // register in DNS 81 | resource privateDnsZoneGroup 'privateDnsZoneGroups@2022-01-01' = { 82 | name: 'vault-dns' 83 | properties: { 84 | privateDnsZoneConfigs: [ 85 | { 86 | name: 'vault-config' 87 | properties: { 88 | privateDnsZoneId: privateDnsZone.id 89 | } 90 | } 91 | ] 92 | } 93 | } 94 | } 95 | 96 | resource diagnosticLogs 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { 97 | name: keyVault.name 98 | scope: keyVault 99 | properties: { 100 | workspaceId: workspaceId 101 | logs: [ 102 | { 103 | categoryGroup: 'audit' 104 | enabled: true 105 | } 106 | ] 107 | metrics: [ 108 | { 109 | category: 'AllMetrics' 110 | enabled: true 111 | } 112 | ] 113 | } 114 | } 115 | 116 | output keyvaultId string = keyVault.id 117 | output keyvaultName string = keyVault.name 118 | -------------------------------------------------------------------------------- /src/infra/logAnalytics.bicep: -------------------------------------------------------------------------------- 1 | @description('The region where the Log Analytics is deployed in.') 2 | param location string 3 | 4 | @description(''' 5 | The suffix of the unique identifier for the resources of the current deployment. 6 | Used to avoid name collisions and to link resources part of the same deployment together. 7 | ''') 8 | param resourceSuffixUID string 9 | 10 | resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2021-06-01' = { 11 | name: 'insights-ws-${resourceSuffixUID}' 12 | location: location 13 | properties: { 14 | retentionInDays: 90 15 | sku: { 16 | name: 'PerGB2018' 17 | } 18 | } 19 | } 20 | 21 | output workspaceId string = logAnalyticsWorkspace.id 22 | output workspaceName string = logAnalyticsWorkspace.name 23 | -------------------------------------------------------------------------------- /src/infra/main.bicep: -------------------------------------------------------------------------------- 1 | @description('The AKS node cluster configuration for the service API') 2 | param aksConfig object 3 | 4 | @description('The network configuration of the service API') 5 | param networkConfig object 6 | 7 | @description('The suffix to be used for the name of resources') 8 | param resourceSuffixUID string = '' 9 | 10 | 11 | var serviceLocation = resourceGroup().location 12 | 13 | 14 | resource managedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2018-11-30' = { 15 | name: 'app-identity-${resourceSuffixUID}' 16 | location: serviceLocation 17 | } 18 | 19 | module network './network.bicep' = { 20 | name: 'vnet' 21 | params: { 22 | location: serviceLocation 23 | resourceSuffixUID: resourceSuffixUID 24 | networkConfig: networkConfig 25 | workspaceId: logAnalytics.outputs.workspaceId 26 | } 27 | } 28 | 29 | module logAnalytics 'logAnalytics.bicep' = { 30 | name: 'logAnalytics' 31 | params: { 32 | location: serviceLocation 33 | resourceSuffixUID: resourceSuffixUID 34 | } 35 | } 36 | 37 | module appInsights 'appInsights.bicep' = { 38 | name: 'appInsights' 39 | params: { 40 | location: serviceLocation 41 | resourceSuffixUID: resourceSuffixUID 42 | workspaceId: logAnalytics.outputs.workspaceId 43 | keyVaultName: keyVault.outputs.keyvaultName 44 | } 45 | } 46 | 47 | module keyVault './keyvault.bicep' = { 48 | name: 'keyvault' 49 | params: { 50 | location: serviceLocation 51 | resourceSuffixUID: resourceSuffixUID 52 | vnetId: network.outputs.vnetId 53 | infraSubnetId: network.outputs.infraSubnetId 54 | workspaceId: logAnalytics.outputs.workspaceId 55 | } 56 | } 57 | 58 | module appGateway './appGateway.bicep' = { 59 | name: 'appGateway' 60 | params: { 61 | location: serviceLocation 62 | resourceSuffixUID: resourceSuffixUID 63 | appGatewaySubnetId: network.outputs.appGatewaySubnetId 64 | loadBalancerPrivateIp: networkConfig.loadBalancerPrivateIP 65 | workspaceId: logAnalytics.outputs.workspaceId 66 | } 67 | } 68 | 69 | module redis './redisCache.bicep' = { 70 | name: 'redis' 71 | params: { 72 | infraSubnetId: network.outputs.infraSubnetId 73 | location: serviceLocation 74 | resourceSuffixUID: resourceSuffixUID 75 | vnetId: network.outputs.vnetId 76 | workspaceId: logAnalytics.outputs.workspaceId 77 | appManagedIdentityName: managedIdentity.name 78 | appManagedIdentityPrincipalId: managedIdentity.properties.principalId 79 | keyVaultName: keyVault.outputs.keyvaultName 80 | } 81 | } 82 | 83 | module sqlDatabase './azureSqlDatabase.bicep' = { 84 | name: 'sqlDatabase' 85 | params: { 86 | infraSubnetId: network.outputs.infraSubnetId 87 | location: serviceLocation 88 | resourceSuffixUID: resourceSuffixUID 89 | vnetId: network.outputs.vnetId 90 | workspaceId: logAnalytics.outputs.workspaceId 91 | managedIdentityClientId: managedIdentity.properties.clientId 92 | azureSqlZoneRedundant: true 93 | keyVaultName: keyVault.outputs.keyvaultName 94 | } 95 | } 96 | 97 | 98 | module externalLoadBalancerIP 'externalLoadBalancerIP.bicep' = { 99 | name: 'externalLoadBalancerIP' 100 | params: { 101 | resourceSuffixUID: resourceSuffixUID 102 | location: serviceLocation 103 | workspaceId: logAnalytics.outputs.workspaceId 104 | } 105 | } 106 | 107 | module aks './aks.bicep' = { 108 | name: 'aks' 109 | params: { 110 | resourceSuffixUID: resourceSuffixUID 111 | location: serviceLocation 112 | vnetName: network.outputs.vnetName 113 | aksSubnetId: network.outputs.aksSubnetId 114 | workspaceId: logAnalytics.outputs.workspaceId 115 | outboundPublicIPId: externalLoadBalancerIP.outputs.externalLoadBalancerIPId 116 | systemNodeCount: aksConfig.systemNodeCount 117 | minUserNodeCount: aksConfig.minUserNodeCount 118 | maxUserNodeCount: aksConfig.maxUserNodeCount 119 | nodeVMSize: aksConfig.nodeVMSize 120 | nodeOsSKU: aksConfig.nodeOsSKU 121 | maxUserPodsCount: aksConfig.maxUserPodsCount 122 | } 123 | } 124 | 125 | module acr './acr.bicep' = { 126 | name: 'acr' 127 | params: { 128 | location: serviceLocation 129 | resourceSuffixUID: resourceSuffixUID 130 | vnetId: network.outputs.vnetId 131 | infraSubnetId: network.outputs.infraSubnetId 132 | workspaceId: logAnalytics.outputs.workspaceId 133 | } 134 | } 135 | 136 | module keyVaultAppRbacGrants './rbacGrantKeyVault.bicep' = { 137 | name: 'rbacGrantsKeyVault' 138 | params: { 139 | keyVaultName: keyVault.outputs.keyvaultName 140 | appManagedIdentityPrincipalId: managedIdentity.properties.principalId 141 | } 142 | } 143 | 144 | module frontDoor './frontDoor.bicep' = { 145 | name: 'frontDoor' 146 | params: { 147 | resourceSuffixUID: resourceSuffixUID 148 | appGatewayPublicIp: appGateway.outputs.publicIpAddress 149 | workspaceId: logAnalytics.outputs.workspaceId 150 | } 151 | } 152 | 153 | module rbacGrantToAcr './rbacGrantToAcr.bicep' = { 154 | name: 'rbacGrantToAcr' 155 | params: { 156 | containerRegistryName: acr.outputs.containerRegistryName 157 | kubeletIdentityObjectId: aks.outputs.kubeletIdentityObjectId 158 | } 159 | } 160 | 161 | 162 | output frontDoorEndpointHostName string = frontDoor.outputs.frontDoorEndpointHostName 163 | output logAnalyticsWorkspaceId string = logAnalytics.outputs.workspaceId 164 | -------------------------------------------------------------------------------- /src/infra/main.bicepparam: -------------------------------------------------------------------------------- 1 | using 'main.bicep' 2 | 3 | param aksConfig = { 4 | systemNodeCount: 3 5 | minUserNodeCount: 3 6 | maxUserNodeCount: 6 7 | maxUserPodsCount: 10 8 | nodeVMSize: 'Standard_D2s_v3' 9 | nodeOsSKU: 'Ubuntu' 10 | } 11 | 12 | param networkConfig = { 13 | vnet: { 14 | cidr: '10.0.0.0/22' 15 | } 16 | subnets: { 17 | AppGatewaySubnet: { 18 | cidr: '10.0.1.0/24' 19 | } 20 | InfraSubnet: { 21 | cidr: '10.0.2.0/24' 22 | } 23 | AKSSubnet: { 24 | cidr: '10.0.3.0/24' 25 | } 26 | } 27 | dnsResolverPrivateIP: '10.0.0.40' 28 | loadBalancerPrivateIP: '10.0.3.250' 29 | } 30 | -------------------------------------------------------------------------------- /src/infra/network.bicep: -------------------------------------------------------------------------------- 1 | @description('The region where the Network artefacts are deployed in.') 2 | param location string 3 | 4 | @description(''' 5 | The suffix of the unique identifier for the resources of the current deployment. 6 | Used to avoid name collisions and to link resources part of the same deployment together. 7 | ''') 8 | param resourceSuffixUID string 9 | 10 | @description('''The network configuration for the deployment. 11 | Format of network config object: 12 | networkConfig = { 13 | vnet: { 14 | cidr: '10.0.0.0/22' 15 | } 16 | subnets: { 17 | AppGatewaySubnet: { 18 | cidr: '10.0.1.0/24' 19 | } 20 | InfraSubnet: { 21 | cidr: '10.0.2.0/24' 22 | } 23 | AKSSubnet: { 24 | cidr: '10.0.3.0/24' 25 | } 26 | } 27 | dnsResolverPrivateIP: '10.0.0.40' 28 | loadBalancerPrivateIP: '10.0.3.250' 29 | } 30 | ''') 31 | param networkConfig object 32 | 33 | @description('The resource ID of the Log Analytics workspace to which the Network artefacts are connected to.') 34 | param workspaceId string 35 | 36 | 37 | 38 | var infraSubnetName = 'InfraSubnet' 39 | var infraSubnetCidr = networkConfig.subnets[infraSubnetName].cidr 40 | 41 | 42 | var appGatewaySubnetName = 'AppGatewaySubnet' 43 | var appGatewaySubnetCidr = networkConfig.subnets[appGatewaySubnetName].cidr 44 | 45 | var aksSubnetName = 'AKSSubnet' 46 | var aksSubnetCidr = networkConfig.subnets[aksSubnetName].cidr 47 | 48 | resource vnet 'Microsoft.Network/virtualNetworks@2022-01-01' = { 49 | name: 'vnet-${resourceSuffixUID}' 50 | location: location 51 | properties: { 52 | addressSpace: { 53 | addressPrefixes: [ 54 | networkConfig.vnet.cidr 55 | ] 56 | } 57 | subnets: [ 58 | { 59 | name: infraSubnetName 60 | properties: { 61 | addressPrefix: infraSubnetCidr 62 | networkSecurityGroup: { 63 | id: infraSubnetNsg.id 64 | } 65 | } 66 | } 67 | { 68 | name: appGatewaySubnetName 69 | properties: { 70 | addressPrefix: appGatewaySubnetCidr 71 | networkSecurityGroup: { 72 | id: appGatewaySubnetNsg.id 73 | } 74 | } 75 | } 76 | { 77 | name: aksSubnetName 78 | properties: { 79 | addressPrefix: aksSubnetCidr 80 | networkSecurityGroup: { 81 | id: aksSubnetNsg.id 82 | } 83 | } 84 | } 85 | ] 86 | } 87 | 88 | resource infraSubnet 'subnets' existing = { 89 | name: infraSubnetName 90 | } 91 | resource appGatewaySubnet 'subnets' existing = { 92 | name: appGatewaySubnetName 93 | } 94 | resource aksSubnet 'subnets' existing = { 95 | name: aksSubnetName 96 | } 97 | } 98 | 99 | // Define NSGs 100 | resource infraSubnetNsg 'Microsoft.Network/networkSecurityGroups@2022-01-01' = { 101 | name: 'vnet-nsg-${resourceSuffixUID}' 102 | location: location 103 | properties: {} 104 | } 105 | 106 | resource appGatewaySubnetNsg 'Microsoft.Network/networkSecurityGroups@2022-01-01' = { 107 | name: 'appgateway-subnet-nsg-${resourceSuffixUID}' 108 | location: location 109 | properties: { 110 | securityRules: [ 111 | { 112 | name: 'OFP-rule-300' 113 | properties: { 114 | description: 'OFP-rule-300 - RequiredPortsForAppGateway' 115 | protocol: '*' 116 | sourcePortRange: '*' 117 | destinationPortRange: '65200-65535' 118 | sourceAddressPrefix: 'GatewayManager' 119 | destinationAddressPrefix: '*' 120 | access: 'Allow' 121 | priority: 300 122 | direction: 'Inbound' 123 | } 124 | } 125 | { 126 | name: 'OFP-rule-301' 127 | properties: { 128 | description: 'OFP-rule-301 - Allow FrontDoor to talk to AppGateway' 129 | protocol: 'TCP' 130 | sourcePortRange: '*' 131 | sourceAddressPrefix: 'AzureFrontDoor.Backend' 132 | destinationAddressPrefix: 'VirtualNetwork' 133 | access: 'Allow' 134 | priority: 301 135 | direction: 'Inbound' 136 | sourcePortRanges: [] 137 | destinationPortRanges: [ 138 | '443' 139 | '80' 140 | ] 141 | sourceAddressPrefixes: [] 142 | destinationAddressPrefixes: [] 143 | } 144 | } 145 | ] 146 | } 147 | } 148 | 149 | 150 | 151 | resource aksSubnetNsg 'Microsoft.Network/networkSecurityGroups@2022-01-01' = { 152 | name: 'aks-subnet-nsg-${resourceSuffixUID}' 153 | location: location 154 | properties: { 155 | securityRules: [ 156 | { 157 | name: 'OFP-rule-302' 158 | properties: { 159 | description: 'OFP-rule-302 - Allow port 80 to the subnet from the virtual network' 160 | protocol: 'Tcp' 161 | sourcePortRange: '*' 162 | destinationPortRange: '80' 163 | sourceAddressPrefix: 'VirtualNetwork' 164 | destinationAddressPrefix: '*' 165 | access: 'Allow' 166 | priority: 302 167 | direction: 'Inbound' 168 | } 169 | } 170 | ] 171 | } 172 | } 173 | 174 | resource diagnosticLogs 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { 175 | name: vnet.name 176 | scope: vnet 177 | properties: { 178 | workspaceId: workspaceId 179 | logs: [ 180 | { 181 | categoryGroup: 'allLogs' 182 | enabled: true 183 | } 184 | ] 185 | metrics: [ 186 | { 187 | category: 'AllMetrics' 188 | enabled: true 189 | } 190 | ] 191 | } 192 | } 193 | 194 | output vnetName string = vnet.name 195 | output vnetId string = vnet.id 196 | output infraSubnetId string = vnet::infraSubnet.id 197 | output appGatewaySubnetId string = vnet::appGatewaySubnet.id 198 | output aksSubnetId string = vnet::aksSubnet.id 199 | -------------------------------------------------------------------------------- /src/infra/rbacGrantKeyVault.bicep: -------------------------------------------------------------------------------- 1 | //Helper module to grant a managed identity access to a key vault 2 | @description('KeyVault Name') 3 | param keyVaultName string 4 | 5 | @description('The principal ID of the App managed identity to grant access to the key vault') 6 | param appManagedIdentityPrincipalId string 7 | 8 | // Get the target key vault 9 | resource keyVault 'Microsoft.KeyVault/vaults@2021-11-01-preview' existing = { 10 | name: keyVaultName 11 | } 12 | 13 | // See: https://learn.microsoft.com/en-us/azure/key-vault/general/rbac-guide?tabs=azure-cli#azure-built-in-roles-for-key-vault-data-plane-operations 14 | var keyVaultSecretsUserRole = '4633458b-17de-408a-b874-0445c86b69e6' 15 | 16 | var secretUserRoleAssignmentName= guid(appManagedIdentityPrincipalId, keyVaultSecretsUserRole, resourceGroup().id) 17 | resource secretReaderRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { 18 | name: secretUserRoleAssignmentName 19 | scope: keyVault 20 | properties: { 21 | roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', keyVaultSecretsUserRole) 22 | principalId: appManagedIdentityPrincipalId 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/infra/rbacGrantToAcr.bicep: -------------------------------------------------------------------------------- 1 | param containerRegistryName string 2 | param kubeletIdentityObjectId string 3 | 4 | var acrPullRoleId = '7f951dda-4ed3-4680-a7ca-43fe172d538d' 5 | 6 | resource acrPullRole 'Microsoft.Authorization/roleDefinitions@2018-01-01-preview' existing = { 7 | scope: subscription() 8 | name: acrPullRoleId 9 | } 10 | 11 | resource containerRegistry 'Microsoft.ContainerRegistry/registries@2021-09-01' existing = { 12 | name: containerRegistryName 13 | } 14 | 15 | resource assignAcrPullToAks 'Microsoft.Authorization/roleAssignments@2020-04-01-preview' = { 16 | name: guid(subscription().id, resourceGroup().id, containerRegistryName, 'AssignAcrPullToAks',kubeletIdentityObjectId) 17 | scope: containerRegistry 18 | properties: { 19 | description: 'Assign AcrPull role to AKS' 20 | principalId: kubeletIdentityObjectId 21 | principalType: 'ServicePrincipal' 22 | roleDefinitionId: acrPullRole.id 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/infra/redisCache.bicep: -------------------------------------------------------------------------------- 1 | @description('The region where the Redis Cache is deployed in.') 2 | param location string 3 | 4 | @description(''' 5 | The suffix of the unique identifier for the resources of the current deployment. 6 | Used to avoid name collisions and to link resources part of the same deployment together. 7 | ''') 8 | param resourceSuffixUID string 9 | 10 | @description('The resource ID of the VNET to which the Redis Cache is connected to.') 11 | param vnetId string 12 | 13 | @description('The resource ID of the subnet to which the Redis Cache is connected to.') 14 | param infraSubnetId string 15 | 16 | @description('The resource ID of the Log Analytics workspace to which the Redis Cache is connected to.') 17 | param workspaceId string 18 | 19 | @description('The name of the App Managed Identity to which the Data Contributor role is assigned to.') 20 | param appManagedIdentityName string 21 | 22 | @description('The principal ID of the App Managed Identity to which the Data Contributor role is assigned to.') 23 | param appManagedIdentityPrincipalId string 24 | 25 | @description('The zones where the Redis Cache is deployed in. To be used for Zone Resiliency.') 26 | param redisZones array = ['1', '2', '3'] 27 | 28 | @description('The name of the Key Vault where the Redis Cache properties will be stored.') 29 | param keyVaultName string 30 | 31 | var redisCacheSkuName = 'Premium' 32 | var redisCacheFamilyName = 'P' 33 | var redisCacheCapacity = 2 34 | 35 | resource redisCache 'Microsoft.Cache/redis@2023-08-01' = { 36 | name: 'redis-${resourceSuffixUID}' 37 | location: location 38 | properties: { 39 | redisVersion: '6.0' 40 | minimumTlsVersion: '1.2' 41 | sku: { 42 | name: redisCacheSkuName 43 | family: redisCacheFamilyName 44 | capacity: redisCacheCapacity 45 | } 46 | enableNonSslPort: false 47 | 48 | publicNetworkAccess: 'Disabled' 49 | redisConfiguration: { 50 | 'maxmemory-reserved': '30' 51 | 'maxfragmentationmemory-reserved': '30' 52 | 'maxmemory-delta': '30' 53 | 'aad-enabled': 'True' 54 | } 55 | } 56 | zones: redisZones 57 | } 58 | 59 | // Assign Data Contributor to our App Managed Identity 60 | resource rbacAssignment 'Microsoft.Cache/redis/accessPolicyAssignments@2023-08-01' = { 61 | parent: redisCache 62 | name: appManagedIdentityName 63 | properties: { 64 | accessPolicyName: 'Data Contributor' 65 | objectId: appManagedIdentityPrincipalId 66 | objectIdAlias: appManagedIdentityName 67 | } 68 | } 69 | 70 | // private DNS zone 71 | resource privateDnsZone 'Microsoft.Network/privateDnsZones@2020-06-01' = { 72 | name: 'privatelink.redis.cache.windows.net' 73 | location: 'global' 74 | } 75 | 76 | // link to VNET 77 | resource privateDnsZoneVnetLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2020-06-01' = { 78 | parent: privateDnsZone 79 | name: 'vnetLink' 80 | location: 'global' 81 | properties: { 82 | registrationEnabled: false 83 | virtualNetwork: { 84 | id: vnetId 85 | } 86 | } 87 | } 88 | 89 | resource privateEndpoint 'Microsoft.Network/privateEndpoints@2022-01-01' = { 90 | name: 'redis-pl-${resourceSuffixUID}' 91 | location: location 92 | properties: { 93 | privateLinkServiceConnections: [ 94 | { 95 | name: 'redis-link' 96 | properties: { 97 | privateLinkServiceId: redisCache.id 98 | groupIds: [ 99 | 'redisCache' 100 | ] 101 | } 102 | } 103 | ] 104 | subnet: { 105 | id: infraSubnetId 106 | } 107 | } 108 | // register in DNS 109 | resource privateDnsZoneGroup 'privateDnsZoneGroups@2022-01-01' = { 110 | name: 'reds-dns' 111 | properties: { 112 | privateDnsZoneConfigs: [ 113 | { 114 | name: 'redis-config' 115 | properties: { 116 | privateDnsZoneId: privateDnsZone.id 117 | } 118 | } 119 | ] 120 | } 121 | } 122 | } 123 | 124 | resource diagnosticLogs 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { 125 | name: redisCache.name 126 | scope: redisCache 127 | properties: { 128 | workspaceId: workspaceId 129 | logs: [ 130 | { 131 | categoryGroup: 'audit' 132 | enabled: true 133 | } 134 | { 135 | categoryGroup: 'allLogs' 136 | enabled: true 137 | } 138 | ] 139 | metrics: [ 140 | { 141 | category: 'AllMetrics' 142 | enabled: true 143 | } 144 | ] 145 | } 146 | } 147 | 148 | // put redis properties into key vault 149 | resource keyVault 'Microsoft.KeyVault/vaults@2021-11-01-preview' existing = { 150 | name: keyVaultName 151 | } 152 | 153 | resource redisEndpointSecret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = { 154 | parent: keyVault 155 | name: 'redis-endpoint' 156 | properties: { 157 | value: redisCache.properties.hostName 158 | } 159 | } 160 | 161 | output redisCacheId string = redisCache.id 162 | output redisCacheName string = redisCache.name 163 | -------------------------------------------------------------------------------- /tests/run-health-checks.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Stop execution on first error encountered 4 | set -o errexit -o pipefail -o noclobber 5 | 6 | # Parse command line arguments 7 | for ARG in "$@"; do 8 | case $ARG in 9 | -h=*|--host=*) 10 | HOST="${ARG#*=}" 11 | shift 12 | ;; 13 | -*|--*) 14 | echo "Unknown argument '$ARG'" >&2 15 | exit 1 16 | ;; 17 | *) 18 | ;; 19 | esac 20 | done 21 | 22 | # Validate command line arguments and set defaults 23 | if [ -z $HOST ]; then 24 | echo "No host provided. Please provide a host to run health checks against. Sample usage '$0 -h=example.com'" >&2 25 | exit 1 26 | fi 27 | 28 | # Global scope variables 29 | RESPONSE_BODY="{}" # The last response body received 30 | RED='\033[0;31m' 31 | GREEN='\033[0;32m' 32 | YELLOW='\033[0;33m' 33 | 34 | # Function to send curl request, validate response status, and parse and return response body 35 | send_and_validate() { 36 | local verb=$1 37 | local url_path=$2 38 | local body=$3 39 | 40 | local response=$(curl -s -w "%{http_code}" \ 41 | -H "X-Forwarded-For: 127.0.0.1" \ 42 | -H 'Content-Type: application/json' \ 43 | --retry 5 \ 44 | --retry-connrefused \ 45 | --location \ 46 | -X "$verb" "http://$HOST$url_path" \ 47 | -d "$body") 48 | 49 | local http_code=${response: -3} 50 | RESPONSE_BODY=${response:: -3} 51 | 52 | if ! [[ "$http_code" =~ ^2 ]]; then 53 | echo -e " ... ${RED}FAILED" 54 | echo -e "Got response '$http_code: $RESPONSE_BODY'" 55 | 56 | exit 1 57 | else 58 | echo -e " ... ${GREEN}OK" 59 | fi 60 | } 61 | 62 | 63 | 64 | # Start test execution 65 | echo -ne "${YELLOW}Test #1 --- Health check" 66 | send_and_validate "GET" "/api/live" 67 | 68 | 69 | echo -ne "${YELLOW}Test #2 --- Get upcoming concerts" 70 | send_and_validate "GET" "/api/concerts/?take=10" 71 | CONCERT_ID=$(echo $RESPONSE_BODY | jq -r ".items[0].id") 72 | 73 | 74 | echo -ne "${YELLOW}Test #3 --- Get concert by ID" 75 | send_and_validate "GET" "/api/concerts/$CONCERT_ID" 76 | 77 | 78 | echo -ne "${YELLOW}Test #4 --- Create new user" 79 | CREATE_USER_REQUEST='{ 80 | "email": "healthcheck@example.com", 81 | "phone": "0123456789", 82 | "displayName": "Health Check" 83 | }' 84 | send_and_validate "POST" "/api/users" "$CREATE_USER_REQUEST" 85 | USER_ID=$(echo $RESPONSE_BODY | jq -r ".id") 86 | 87 | 88 | echo -ne "${YELLOW}Test #5 --- Get user by ID" 89 | send_and_validate "GET" "/api/users/$USER_ID" 90 | 91 | 92 | echo -ne "${YELLOW}Test #6 --- Put item in cart" 93 | PUT_ITEM_REQUEST="{ 94 | \"concertId\": \"$CONCERT_ID\", 95 | \"quantity\": 3 96 | }" 97 | send_and_validate "PUT" "/api/users/$USER_ID/carts" "$PUT_ITEM_REQUEST" 98 | 99 | 100 | echo -ne "${YELLOW}Test #7 --- Create order (checkout cart)" 101 | CREATE_ORDER_REQUEST='{ 102 | "cardholder": "Example User", 103 | "cardNumber": "378282246310005", 104 | "securityCode": "123", 105 | "expirationMonthYear": "1029" 106 | }' 107 | send_and_validate "POST" "/api/users/$USER_ID/orders" "$CREATE_ORDER_REQUEST" 108 | 109 | 110 | echo -ne "${YELLOW}Test #8 --- Get orders" 111 | send_and_validate "GET" "/api/users/$USER_ID/orders/?take=3&skip=0" 112 | 113 | 114 | echo -e "${GREEN}All tests executed successfully!" 115 | --------------------------------------------------------------------------------