diff --git a/.github/workflows/_deploy-container.yml b/.github/workflows/_deploy-container.yml index 145e793c4..0c4d44d20 100644 --- a/.github/workflows/_deploy-container.yml +++ b/.github/workflows/_deploy-container.yml @@ -1,4 +1,4 @@ -name: "Deploy Container" +name: Deploy Container on: workflow_call: @@ -23,10 +23,19 @@ on: type: string jobs: - staging-west-europe-deploy: + stage: name: Staging - if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest + # environment: "staging" # Manual approval disabled + if: ${{ vars.STAGING_CLUSTER_ENABLED == 'true' && github.ref == 'refs/heads/main' }} + env: + UNIQUE_PREFIX: ${{ vars.UNIQUE_PREFIX }} + ENVIRONMENT: "stage" + CLUSTER_LOCATION_ACRONYM: ${{ vars.STAGING_CLUSTER_LOCATION_ACRONYM }} + SERVICE_PRINCIPAL_ID: ${{ vars.STAGING_SERVICE_PRINCIPAL_ID }} + TENANT_ID: ${{ vars.TENANT_ID }} + SUBSCRIPTION_ID: ${{ vars.STAGING_SUBSCRIPTION_ID }} + steps: - name: Checkout code uses: actions/checkout@v4 @@ -40,12 +49,12 @@ jobs: - name: Login to Azure uses: azure/login@v2 with: - tenant-id: ${{ secrets.AZURE_TENANT_ID }} - client-id: ${{ secrets.AZURE_SERVICE_PRINCIPAL_ID }} - subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + client-id: ${{ env.SERVICE_PRINCIPAL_ID }} + tenant-id: ${{ env.TENANT_ID }} + subscription-id: ${{ env.SUBSCRIPTION_ID }} - name: Login to ACR - run: az acr login --name ${{ vars.UNIQUE_PREFIX }}stage + run: az acr login --name ${{ env.UNIQUE_PREFIX }}${{ env.ENVIRONMENT }} - name: Setup Docker Buildx uses: docker/setup-buildx-action@v3 @@ -57,32 +66,40 @@ jobs: docker buildx build \ --platform linux/amd64,linux/arm64 \ --build-arg VERSION=${{ inputs.version }} \ - -t ${{ vars.UNIQUE_PREFIX }}stage.azurecr.io/${{ inputs.image_name }}:${{ inputs.version }} \ - -t ${{ vars.UNIQUE_PREFIX }}stage.azurecr.io/${{ inputs.image_name }}:latest \ + -t ${{ env.UNIQUE_PREFIX }}${{ env.ENVIRONMENT }}.azurecr.io/${{ inputs.image_name }}:${{ inputs.version }} \ + -t ${{ env.UNIQUE_PREFIX }}${{ env.ENVIRONMENT }}.azurecr.io/${{ inputs.image_name }}:latest \ -f ${{ inputs.docker_file }} \ --push . docker buildx rm - - name: Deploy Staging West Europe cluster + - name: Deploy Container run: | SURFIX=$(echo "${{ inputs.version }}" | sed 's/\./-/g') - az containerapp update --name ${{ inputs.image_name }} --resource-group "${{ vars.UNIQUE_PREFIX }}-stage-weu" --image "${{ vars.UNIQUE_PREFIX }}stage.azurecr.io/${{ inputs.image_name }}:${{ inputs.version }}" --revision-suffix $SURFIX + az containerapp update --name ${{ inputs.image_name }} --resource-group "${{ env.UNIQUE_PREFIX }}-${{ env.ENVIRONMENT }}-${{ env.CLUSTER_LOCATION_ACRONYM }}" --image "${{ env.UNIQUE_PREFIX }}${{ env.ENVIRONMENT }}.azurecr.io/${{ inputs.image_name }}:${{ inputs.version }}" --revision-suffix $SURFIX - production-west-europe-deploy: + prod1: name: Production - if: false && github.ref == 'refs/heads/main' ## Disable production for now - needs: staging-west-europe-deploy + needs: stage + environment: "production" # Force a manual approval runs-on: ubuntu-latest - environment: "production" ## Force a manual approval + if: ${{ vars.PRODUCTION_CLUSTER1_ENABLED == 'true' && github.ref == 'refs/heads/main' }} + env: + UNIQUE_PREFIX: ${{ vars.UNIQUE_PREFIX }} + ENVIRONMENT: "prod" + CLUSTER_LOCATION_ACRONYM: ${{ vars.PRODUCTION_CLUSTER1_LOCATION_ACRONYM }} + SERVICE_PRINCIPAL_ID: ${{ vars.PRODUCTION_SERVICE_PRINCIPAL_ID }} + TENANT_ID: ${{ vars.TENANT_ID }} + SUBSCRIPTION_ID: ${{ vars.PRODUCTION_SUBSCRIPTION_ID }} + steps: - name: Login to Azure uses: azure/login@v2 with: - tenant-id: ${{ secrets.AZURE_TENANT_ID }} - client-id: ${{ secrets.AZURE_SERVICE_PRINCIPAL_ID }} - subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + client-id: ${{ env.SERVICE_PRINCIPAL_ID }} + tenant-id: ${{ env.TENANT_ID }} + subscription-id: ${{ env.SUBSCRIPTION_ID }} - - name: Deploy Production West Europe cluster + - name: Deploy Container run: | SURFIX=$(echo "${{ inputs.version }}" | sed 's/\./-/g') - az containerapp update --name ${{ inputs.image_name }} --resource-group "${{ vars.UNIQUE_PREFIX }}-prod-weu" --image "${{ vars.UNIQUE_PREFIX }}prod.azurecr.io/${{ inputs.image_name }}:${{ inputs.version }}" --revision-suffix $SURFIX + az containerapp update --name ${{ inputs.image_name }} --resource-group "${{ env.UNIQUE_PREFIX }}-${{ env.ENVIRONMENT }}-${{ env.CLUSTER_LOCATION_ACRONYM }}" --image "${{ env.UNIQUE_PREFIX }}${{ env.ENVIRONMENT }}.azurecr.io/${{ inputs.image_name }}:${{ inputs.version }}" --revision-suffix $SURFIX diff --git a/.github/workflows/_deploy-infrastructure.yml b/.github/workflows/_deploy-infrastructure.yml new file mode 100644 index 000000000..a9581f3cf --- /dev/null +++ b/.github/workflows/_deploy-infrastructure.yml @@ -0,0 +1,126 @@ +name: Plan and Deploy Infrastructure + +on: + workflow_call: + inputs: + github_environment: + required: true + type: string + include_shared_environment_resources: + required: true + type: boolean + unique_prefix: + required: true + type: string + azure_environment: + required: true + type: string + shared_location: + required: true + type: string + cluster_location: + required: true + type: string + cluster_location_acronym: + required: true + type: string + sql_admin_object_id: + required: true + type: string + domain_name: + required: true + type: string + service_principal_id: + required: true + type: string + tenant_id: + required: true + type: string + subscription_id: + required: true + type: string + deployment_enabled: + required: true + type: string + +jobs: + plan: + name: "Planning" + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Install Bicep CLI + run: | + curl -Lo bicep https://github.com/Azure/bicep/releases/latest/download/bicep-linux-x64 && + chmod +x ./bicep && + sudo mv ./bicep /usr/local/bin/bicep && + bicep --version + + - name: Login to Azure + uses: azure/login@v2 + with: + client-id: ${{ inputs.service_principal_id }} + tenant-id: ${{ inputs.tenant_id }} + subscription-id: ${{ inputs.subscription_id }} + + - name: Plan Shared Environment Resources + if: ${{ inputs.include_shared_environment_resources == true }} + run: bash ./cloud-infrastructure/environment/deploy-environment.sh ${{ inputs.unique_prefix }} ${{ inputs.azure_environment }} ${{ inputs.shared_location }} --plan + + - name: Plan Cluster Resources + id: deploy_cluster + run: bash ./cloud-infrastructure/cluster/deploy-cluster.sh ${{ inputs.unique_prefix }} ${{ inputs.azure_environment }} ${{ inputs.cluster_location }} ${{ inputs.cluster_location_acronym }} ${{ inputs.sql_admin_object_id }} ${{ inputs.domain_name }} --plan + + deploy: + name: "Deploying" + if: ${{ inputs.deployment_enabled == 'true' && github.ref == 'refs/heads/main' }} + needs: plan + environment: "${{ inputs.github_environment }}" + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Install Bicep CLI + run: | + curl -Lo bicep https://github.com/Azure/bicep/releases/latest/download/bicep-linux-x64 && + chmod +x ./bicep && + sudo mv ./bicep /usr/local/bin/bicep && + bicep --version + + - name: Login to Azure + uses: azure/login@v2 + with: + client-id: ${{ inputs.service_principal_id }} + tenant-id: ${{ inputs.tenant_id }} + subscription-id: ${{ inputs.subscription_id }} + + - name: Deploy Shared Environment Resources + if: ${{ inputs.include_shared_environment_resources == true }} + run: bash ./cloud-infrastructure/environment/deploy-environment.sh ${{ inputs.unique_prefix }} ${{ inputs.azure_environment }} ${{ inputs.shared_location }} --apply + + - name: Deploy Cluster Resources + id: deploy_cluster + run: bash ./cloud-infrastructure/cluster/deploy-cluster.sh ${{ inputs.unique_prefix }} ${{ inputs.azure_environment }} ${{ inputs.cluster_location }} ${{ inputs.cluster_location_acronym }} ${{ inputs.sql_admin_object_id }} ${{ inputs.domain_name }} --apply + + - name: Refresh Azure Tokens # The previous step may take a while, so we refresh the token to avoid timeouts + uses: azure/login@v2 + with: + client-id: ${{ inputs.service_principal_id }} + tenant-id: ${{ inputs.tenant_id }} + subscription-id: ${{ inputs.subscription_id }} + + - name: Replace Classic sqlcmd (ODBC) with sqlcmd (GO) + run: | + sudo apt-get remove -y mssql-tools && + curl https://packages.microsoft.com/keys/microsoft.asc | sudo tee /etc/apt/trusted.gpg.d/microsoft.asc && + sudo add-apt-repository "$(wget -qO- https://packages.microsoft.com/config/ubuntu/22.04/prod.list)" && + sudo apt-get update && + sudo apt-get install -y sqlcmd + + - name: Grant Database Permissions + run: | + bash ./cloud-infrastructure/cluster/grant-database-permissions.sh ${{ inputs.unique_prefix }} ${{ inputs.azure_environment }} ${{ inputs.cluster_location_acronym }} 'account-management' ${{ steps.deploy_cluster.outputs.ACCOUNT_MANAGEMENT_IDENTITY_CLIENT_ID }} + bash ./cloud-infrastructure/cluster/grant-database-permissions.sh ${{ inputs.unique_prefix }} ${{ inputs.azure_environment }} ${{ inputs.cluster_location_acronym }} 'back-office' ${{ steps.deploy_cluster.outputs.BACK_OFFICE_IDENTITY_CLIENT_ID }} diff --git a/.github/workflows/account-management.yml b/.github/workflows/account-management.yml index f9e5fd19a..d05ad7ff9 100644 --- a/.github/workflows/account-management.yml +++ b/.github/workflows/account-management.yml @@ -29,6 +29,7 @@ jobs: runs-on: ubuntu-latest outputs: version: ${{ steps.generate_version.outputs.version }} + steps: - name: Checkout code uses: actions/checkout@v4 @@ -124,6 +125,7 @@ jobs: name: Code Style and Linting if: github.ref != 'refs/heads/main' runs-on: ubuntu-latest + steps: - name: Checkout code uses: actions/checkout@v4 diff --git a/.github/workflows/app-gateway.yml b/.github/workflows/app-gateway.yml index 13698a371..1182d4fc0 100644 --- a/.github/workflows/app-gateway.yml +++ b/.github/workflows/app-gateway.yml @@ -29,6 +29,7 @@ jobs: runs-on: ubuntu-latest outputs: version: ${{ steps.generate_version.outputs.version }} + steps: - name: Checkout code uses: actions/checkout@v4 @@ -88,6 +89,7 @@ jobs: name: Code Style and Linting if: github.ref != 'refs/heads/main' runs-on: ubuntu-latest + steps: - name: Checkout code uses: actions/checkout@v4 diff --git a/.github/workflows/back-office.yml b/.github/workflows/back-office.yml index 4a39f9a44..8de5946ae 100644 --- a/.github/workflows/back-office.yml +++ b/.github/workflows/back-office.yml @@ -29,6 +29,7 @@ jobs: runs-on: ubuntu-latest outputs: version: ${{ steps.generate_version.outputs.version }} + steps: - name: Checkout code uses: actions/checkout@v4 @@ -124,6 +125,7 @@ jobs: name: Code Style and Linting if: github.ref != 'refs/heads/main' runs-on: ubuntu-latest + steps: - name: Checkout code uses: actions/checkout@v4 diff --git a/.github/workflows/cloud-infrastructure.yml b/.github/workflows/cloud-infrastructure.yml index 83bc4220c..47f6c02f1 100644 --- a/.github/workflows/cloud-infrastructure.yml +++ b/.github/workflows/cloud-infrastructure.yml @@ -20,173 +20,43 @@ permissions: contents: read jobs: - plan: - name: Plan Changes - runs-on: ubuntu-latest - steps: - - name: Checkout Code - uses: actions/checkout@v4 - - - name: Install Bicep CLI - run: | - curl -Lo bicep https://github.com/Azure/bicep/releases/latest/download/bicep-linux-x64 && - chmod +x ./bicep && - sudo mv ./bicep /usr/local/bin/bicep && - bicep --version - - - name: Login to Azure subscription - uses: azure/login@v2 - with: - client-id: ${{ secrets.AZURE_SERVICE_PRINCIPAL_ID }} - tenant-id: ${{ secrets.AZURE_TENANT_ID }} - subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - - - name: Plan Changes to Shared Staging Environment Resources - env: - UNIQUE_PREFIX: ${{ vars.UNIQUE_PREFIX }} - run: bash ./cloud-infrastructure/environment/config/staging.sh --plan - - - name: Plan Changes to Staging West Europe Cluster - env: - ACTIVE_DIRECTORY_SQL_ADMIN_OBJECT_ID: ${{ secrets.ACTIVE_DIRECTORY_SQL_ADMIN_OBJECT_ID }} - UNIQUE_PREFIX: ${{ vars.UNIQUE_PREFIX }} - DOMAIN_NAME: ${{ vars.DOMAIN_NAME_STAGING }} - run: bash ./cloud-infrastructure/cluster/config/staging-west-europe.sh --plan - - - name: Plan Changes to Shared Production Environment Resources - if: false ## Disable production for now - run: bash ./cloud-infrastructure/environment/config/production.sh --plan - - - name: Plan Changes to Production West Europe Cluster - if: false ## Disable production for now - env: - ACTIVE_DIRECTORY_SQL_ADMIN_OBJECT_ID: ${{ secrets.ACTIVE_DIRECTORY_SQL_ADMIN_OBJECT_ID }} - UNIQUE_PREFIX: ${{ vars.UNIQUE_PREFIX }} - DOMAIN_NAME: ${{ vars.DOMAIN_NAME_PRODUCTION }} - run: bash ./cloud-infrastructure/cluster/config/production-west-europe.sh --plan - - staging: + stage: name: Staging - if: github.ref == 'refs/heads/main' - needs: plan - runs-on: ubuntu-latest - environment: "staging" ## Force a manual approval - steps: - - name: Checkout Code - uses: actions/checkout@v4 - - - name: Install Bicep CLI - run: | - curl -Lo bicep https://github.com/Azure/bicep/releases/latest/download/bicep-linux-x64 && - chmod +x ./bicep && - sudo mv ./bicep /usr/local/bin/bicep && - bicep --version - - - name: Replace Classic sqlcmd (ODBC) with sqlcmd (GO) - run: | - sudo apt-get remove -y mssql-tools && - curl https://packages.microsoft.com/keys/microsoft.asc | sudo tee /etc/apt/trusted.gpg.d/microsoft.asc && - sudo add-apt-repository "$(wget -qO- https://packages.microsoft.com/config/ubuntu/22.04/prod.list)" && - sudo apt-get update && - sudo apt-get install -y sqlcmd - - - name: Login to Azure Subscription - uses: azure/login@v2 - with: - client-id: ${{ secrets.AZURE_SERVICE_PRINCIPAL_ID }} - tenant-id: ${{ secrets.AZURE_TENANT_ID }} - subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - - - name: Deploy Shared Staging Environment Resources - env: - UNIQUE_PREFIX: ${{ vars.UNIQUE_PREFIX }} - run: bash ./cloud-infrastructure/environment/config/staging.sh --apply - - - name: Deploy Staging West Europe Cluster - id: deploy_cluster - env: - ACTIVE_DIRECTORY_SQL_ADMIN_OBJECT_ID: ${{ secrets.ACTIVE_DIRECTORY_SQL_ADMIN_OBJECT_ID }} - UNIQUE_PREFIX: ${{ vars.UNIQUE_PREFIX }} - DOMAIN_NAME: ${{ vars.DOMAIN_NAME_STAGING }} - run: bash ./cloud-infrastructure/cluster/config/staging-west-europe.sh --apply - - - name: Refresh Azure Tokens ## The previous step may take a while, so we refresh the token to avoid timeouts - uses: azure/login@v2 - with: - client-id: ${{ secrets.AZURE_SERVICE_PRINCIPAL_ID }} - tenant-id: ${{ secrets.AZURE_TENANT_ID }} - subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - - - name: Grant Database Permissions - env: - UNIQUE_PREFIX: ${{ vars.UNIQUE_PREFIX }} - ENVIRONMENT: "stage" - LOCATION_ACRONYM: "weu" - run: | - ACCOUNT_MANAGEMENT_IDENTITY_CLIENT_ID=${{ steps.deploy_cluster.outputs.ACCOUNT_MANAGEMENT_IDENTITY_CLIENT_ID }} - bash ./cloud-infrastructure/cluster/grant-database-permissions.sh 'account-management' $ACCOUNT_MANAGEMENT_IDENTITY_CLIENT_ID - BACK_OFFICE_IDENTITY_CLIENT_ID=${{ steps.deploy_cluster.outputs.BACK_OFFICE_IDENTITY_CLIENT_ID }} - bash ./cloud-infrastructure/cluster/grant-database-permissions.sh 'back-office' $BACK_OFFICE_IDENTITY_CLIENT_ID - - production: + if: ${{ vars.STAGING_CLUSTER_ENABLED == 'true' }} + uses: ./.github/workflows/_deploy-infrastructure.yml + secrets: inherit + with: + github_environment: "staging" + include_shared_environment_resources: true + unique_prefix: ${{ vars.UNIQUE_PREFIX }} + azure_environment: "stage" + shared_location: ${{ vars.STAGING_SHARED_LOCATION }} + cluster_location: ${{ vars.STAGING_CLUSTER_LOCATION }} + cluster_location_acronym: ${{ vars.STAGING_CLUSTER_LOCATION_ACRONYM }} + sql_admin_object_id: ${{ vars.STAGING_SQL_ADMIN_OBJECT_ID }} + domain_name: ${{ vars.STAGING_DOMAIN_NAME }} + service_principal_id: ${{ vars.STAGING_SERVICE_PRINCIPAL_ID }} + tenant_id: ${{ vars.TENANT_ID }} + subscription_id: ${{ vars.STAGING_SUBSCRIPTION_ID }} + deployment_enabled: ${{ vars.STAGING_CLUSTER_ENABLED }} + + prod1: name: Production - if: false && github.ref == 'refs/heads/main' ## Disable production for now - needs: staging - runs-on: ubuntu-latest - environment: "production" ## Force a manual approval - steps: - - name: Checkout Code - uses: actions/checkout@v4 - - - name: Install Bicep CLI - run: | - curl -Lo bicep https://github.com/Azure/bicep/releases/latest/download/bicep-linux-x64 && - chmod +x ./bicep && - sudo mv ./bicep /usr/local/bin/bicep && - bicep --version - - - name: Replace Classic sqlcmd (ODBC) with sqlcmd (GO) - run: | - sudo apt-get remove -y mssql-tools && - curl https://packages.microsoft.com/keys/microsoft.asc | sudo tee /etc/apt/trusted.gpg.d/microsoft.asc && - sudo add-apt-repository "$(wget -qO- https://packages.microsoft.com/config/ubuntu/22.04/prod.list)" && - sudo apt-get update && - sudo apt-get install -y sqlcmd - - - name: Login to Azure Subscription - uses: azure/login@v2 - with: - client-id: ${{ secrets.AZURE_SERVICE_PRINCIPAL_ID }} - tenant-id: ${{ secrets.AZURE_TENANT_ID }} - subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - - - name: Deploy Shared Production Environment Resources - env: - UNIQUE_PREFIX: ${{ vars.UNIQUE_PREFIX }} - run: bash ./cloud-infrastructure/environment/config/production.sh --apply - - - name: Deploy Production West Europe Cluster - id: deploy_cluster - env: - ACTIVE_DIRECTORY_SQL_ADMIN_OBJECT_ID: ${{ secrets.ACTIVE_DIRECTORY_SQL_ADMIN_OBJECT_ID }} - UNIQUE_PREFIX: ${{ vars.UNIQUE_PREFIX }} - DOMAIN_NAME: ${{ vars.DOMAIN_NAME_PRODUCTION }} - run: bash ./cloud-infrastructure/cluster/config/production-west-europe.sh --apply - - - name: Refresh Azure Tokens ## The previous step may take a while, so we refresh the token to avoid timeouts - uses: azure/login@v2 - with: - client-id: ${{ secrets.AZURE_SERVICE_PRINCIPAL_ID }} - tenant-id: ${{ secrets.AZURE_TENANT_ID }} - subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - - - name: Grant Database Permissions - env: - UNIQUE_PREFIX: ${{ vars.UNIQUE_PREFIX }} - ENVIRONMENT: "prod" - LOCATION_ACRONYM: "weu" - run: | - ACCOUNT_MANAGEMENT_IDENTITY_CLIENT_ID=${{ steps.deploy_cluster.outputs.ACCOUNT_MANAGEMENT_IDENTITY_CLIENT_ID }} - bash ./cloud-infrastructure/cluster/grant-database-permissions.sh 'account-management' $ACCOUNT_MANAGEMENT_IDENTITY_CLIENT_ID - BACK_OFFICE_IDENTITY_CLIENT_ID=${{ steps.deploy_cluster.outputs.BACK_OFFICE_IDENTITY_CLIENT_ID }} - bash ./cloud-infrastructure/cluster/grant-database-permissions.sh 'back-office' $BACK_OFFICE_IDENTITY_CLIENT_ID + needs: stage + if: ${{ vars.PRODUCTION_CLUSTER1_ENABLED == 'true' && github.ref == 'refs/heads/main' }} + uses: ./.github/workflows/_deploy-infrastructure.yml + secrets: inherit + with: + github_environment: "production" + include_shared_environment_resources: true + unique_prefix: ${{ vars.UNIQUE_PREFIX }} + azure_environment: "prod" + shared_location: ${{ vars.PRODUCTION_SHARED_LOCATION }} + cluster_location: ${{ vars.PRODUCTION_CLUSTER1_LOCATION }} + cluster_location_acronym: ${{ vars.PRODUCTION_CLUSTER1_LOCATION_ACRONYM }} + sql_admin_object_id: ${{ vars.PRODUCTION_SQL_ADMIN_OBJECT_ID }} + domain_name: ${{ vars.PRODUCTION_DOMAIN_NAME }} + service_principal_id: ${{ vars.PRODUCTION_SERVICE_PRINCIPAL_ID }} + tenant_id: ${{ vars.TENANT_ID }} + subscription_id: ${{ vars.PRODUCTION_SUBSCRIPTION_ID }} + deployment_enabled: ${{ vars.PRODUCTION_CLUSTER1_ENABLED }} diff --git a/cloud-infrastructure/cluster/config/development-west-europe.sh b/cloud-infrastructure/cluster/config/development-west-europe.sh deleted file mode 100755 index dcb0206d6..000000000 --- a/cloud-infrastructure/cluster/config/development-west-europe.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash - -ENVIRONMENT="dev" -LOCATION="WestEurope" -LOCATION_ACRONYM="weu" - -cd "$(dirname "${BASH_SOURCE[0]}")" -. ../deploy-cluster.sh diff --git a/cloud-infrastructure/cluster/config/production-west-europe.sh b/cloud-infrastructure/cluster/config/production-west-europe.sh deleted file mode 100755 index 123fa83ba..000000000 --- a/cloud-infrastructure/cluster/config/production-west-europe.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash - -ENVIRONMENT="prod" -LOCATION="WestEurope" -LOCATION_ACRONYM="weu" - -cd "$(dirname "${BASH_SOURCE[0]}")" -. ../deploy-cluster.sh diff --git a/cloud-infrastructure/cluster/config/staging-west-europe.sh b/cloud-infrastructure/cluster/config/staging-west-europe.sh deleted file mode 100755 index b59773cc3..000000000 --- a/cloud-infrastructure/cluster/config/staging-west-europe.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash - -ENVIRONMENT="stage" -LOCATION="WestEurope" -LOCATION_ACRONYM="weu" - -cd "$(dirname "${BASH_SOURCE[0]}")" -. ../deploy-cluster.sh diff --git a/cloud-infrastructure/cluster/deploy-cluster.sh b/cloud-infrastructure/cluster/deploy-cluster.sh index 56658779b..11935da98 100755 --- a/cloud-infrastructure/cluster/deploy-cluster.sh +++ b/cloud-infrastructure/cluster/deploy-cluster.sh @@ -1,3 +1,12 @@ +#!/bin/bash + +UNIQUE_PREFIX=$1 +ENVIRONMENT=$2 +CLUSTER_LOCATION=$3 +CLUSTER_LOCATION_ACRONYM=$4 +SQL_ADMIN_OBJECT_ID=$5 +DOMAIN_NAME=$6 + get_active_version() { local image=$(az containerapp revision list --name "$1" --resource-group "$2" --query "[0].properties.template.containers[0].image" --output tsv 2>/dev/null) @@ -21,11 +30,6 @@ function is_domain_configured() { fi } -if [[ -z "$ACTIVE_DIRECTORY_SQL_ADMIN_OBJECT_ID" ]]; then - echo "ACTIVE_DIRECTORY_SQL_ADMIN_OBJECT_ID is not set." - exit 1 -fi - if [[ "$DOMAIN_NAME" == "-" ]]; then # "-" is used to indicate that the domain is not configured DOMAIN_NAME="" @@ -33,7 +37,7 @@ fi CONTAINER_REGISTRY_NAME=$UNIQUE_PREFIX$ENVIRONMENT ENVIRONMENT_RESOURCE_GROUP_NAME="$UNIQUE_PREFIX-$ENVIRONMENT" -RESOURCE_GROUP_NAME="$ENVIRONMENT_RESOURCE_GROUP_NAME-$LOCATION_ACRONYM" +RESOURCE_GROUP_NAME="$ENVIRONMENT_RESOURCE_GROUP_NAME-$CLUSTER_LOCATION_ACRONYM" IS_DOMAIN_CONFIGURED=$(is_domain_configured "app-gateway" "$RESOURCE_GROUP_NAME") APP_GATEWAY_VERSION=$(get_active_version "app-gateway" $RESOURCE_GROUP_NAME) @@ -45,7 +49,7 @@ APPLICATIONINSIGHTS_CONNECTION_STRING=$(az monitor app-insights component show - CURRENT_DATE=$(date +'%Y-%m-%dT%H-%M') DEPLOYMENT_COMMAND="az deployment sub create" -DEPLOYMENT_PARAMETERS="-l $LOCATION -n $CURRENT_DATE-$RESOURCE_GROUP_NAME --output json -f ./main-cluster.bicep -p resourceGroupName=$RESOURCE_GROUP_NAME environmentResourceGroupName=$ENVIRONMENT_RESOURCE_GROUP_NAME environment=$ENVIRONMENT containerRegistryName=$CONTAINER_REGISTRY_NAME domainName=$DOMAIN_NAME isDomainConfigured=$IS_DOMAIN_CONFIGURED sqlAdminObjectId=$ACTIVE_DIRECTORY_SQL_ADMIN_OBJECT_ID appGatewayVersion=$APP_GATEWAY_VERSION accountManagementVersion=$ACTIVE_ACCOUNT_MANAGEMENT_VERSION backOfficeVersion=$ACTIVE_BACK_OFFICE_VERSION applicationInsightsConnectionString=$APPLICATIONINSIGHTS_CONNECTION_STRING" +DEPLOYMENT_PARAMETERS="-l $CLUSTER_LOCATION -n $CURRENT_DATE-$RESOURCE_GROUP_NAME --output json -f ./main-cluster.bicep -p resourceGroupName=$RESOURCE_GROUP_NAME environmentResourceGroupName=$ENVIRONMENT_RESOURCE_GROUP_NAME environment=$ENVIRONMENT containerRegistryName=$CONTAINER_REGISTRY_NAME domainName=$DOMAIN_NAME isDomainConfigured=$IS_DOMAIN_CONFIGURED sqlAdminObjectId=$SQL_ADMIN_OBJECT_ID appGatewayVersion=$APP_GATEWAY_VERSION accountManagementVersion=$ACTIVE_ACCOUNT_MANAGEMENT_VERSION backOfficeVersion=$ACTIVE_BACK_OFFICE_VERSION applicationInsightsConnectionString=$APPLICATIONINSIGHTS_CONNECTION_STRING" cd "$(dirname "${BASH_SOURCE[0]}")" . ../deploy.sh @@ -63,7 +67,7 @@ then # Check for the specific error message indicating that DNS Records are missing if [[ $cleaned_output == *"InvalidCustomHostNameValidation"* ]] || [[ $cleaned_output == *"FailedCnameValidation"* ]] || [[ $cleaned_output == *"-certificate' under resource group '$RESOURCE_GROUP_NAME' was not found"* ]]; then # Get details about the container apps environment. Although the creation of the container app fails, the verification ID on the container apps environment is consistent across all container apps. - env_details=$(az containerapp env show --name "$RESOURCE_GROUP_NAME" --resource-group "$RESOURCE_GROUP_NAME") + env_details=$(az containerapp env show --name $RESOURCE_GROUP_NAME --resource-group $RESOURCE_GROUP_NAME) # Extract the customDomainVerificationId and defaultDomain from the container apps environment custom_domain_verification_id=$(echo "$env_details" | jq -r '.properties.customDomainConfiguration.customDomainVerificationId') @@ -82,8 +86,8 @@ then # If the domain was not configured during the first run and we didn't receive any warnings about missing DNS entries, we trigger the deployment again to complete the binding of the SSL Certificate to the domain. if [[ "$IS_DOMAIN_CONFIGURED" == "false" ]] && [[ "$DOMAIN_NAME" != "" ]]; then echo "Running deployment again to finalize setting up SSL certificate for $DOMAIN_NAME" - IS_DOMAIN_CONFIGURED=$(is_domain_configured "app-gateway" "$RESOURCE_GROUP_NAME") - DEPLOYMENT_PARAMETERS="-l $LOCATION -n $CURRENT_DATE-$RESOURCE_GROUP_NAME --output json -f ./main-cluster.bicep -p resourceGroupName=$RESOURCE_GROUP_NAME environmentResourceGroupName=$ENVIRONMENT_RESOURCE_GROUP_NAME environment=$ENVIRONMENT containerRegistryName=$CONTAINER_REGISTRY_NAME domainName=$DOMAIN_NAME isDomainConfigured=$IS_DOMAIN_CONFIGURED sqlAdminObjectId=$ACTIVE_DIRECTORY_SQL_ADMIN_OBJECT_ID appGatewayVersion=$APP_GATEWAY_VERSION accountManagementVersion=$ACTIVE_ACCOUNT_MANAGEMENT_VERSION backOfficeVersion=$ACTIVE_BACK_OFFICE_VERSION applicationInsightsConnectionString=$APPLICATIONINSIGHTS_CONNECTION_STRING" + IS_DOMAIN_CONFIGURED=$(is_domain_configured "app-gateway" $RESOURCE_GROUP_NAME) + DEPLOYMENT_PARAMETERS="-l $CLUSTER_LOCATION -n $CURRENT_DATE-$RESOURCE_GROUP_NAME --output json -f ./main-cluster.bicep -p resourceGroupName=$RESOURCE_GROUP_NAME environmentResourceGroupName=$ENVIRONMENT_RESOURCE_GROUP_NAME environment=$ENVIRONMENT containerRegistryName=$CONTAINER_REGISTRY_NAME domainName=$DOMAIN_NAME isDomainConfigured=$IS_DOMAIN_CONFIGURED sqlAdminObjectId=$SQL_ADMIN_OBJECT_ID appGatewayVersion=$APP_GATEWAY_VERSION accountManagementVersion=$ACTIVE_ACCOUNT_MANAGEMENT_VERSION backOfficeVersion=$ACTIVE_BACK_OFFICE_VERSION applicationInsightsConnectionString=$APPLICATIONINSIGHTS_CONNECTION_STRING" . ../deploy.sh cleaned_output=$(echo "$output" | sed '/^WARNING/d') diff --git a/cloud-infrastructure/cluster/firewall.sh b/cloud-infrastructure/cluster/firewall.sh index b3c0a51af..8b7d1e467 100644 --- a/cloud-infrastructure/cluster/firewall.sh +++ b/cloud-infrastructure/cluster/firewall.sh @@ -1,5 +1,5 @@ IP_ADDRESS=$(curl -s https://api.ipify.org) -FIREWALL_RULE_NAME="GitHub Action Workflows - On active when deploying" +FIREWALL_RULE_NAME="GitHub Action Workflows - Only active when deploying" if [[ "$1" == "open" ]] then diff --git a/cloud-infrastructure/cluster/grant-database-permissions.sh b/cloud-infrastructure/cluster/grant-database-permissions.sh index 2da0ea9e1..73be093db 100755 --- a/cloud-infrastructure/cluster/grant-database-permissions.sh +++ b/cloud-infrastructure/cluster/grant-database-permissions.sh @@ -1,9 +1,13 @@ -RESOURCE_GROUP_NAME="$UNIQUE_PREFIX-$ENVIRONMENT-$LOCATION_ACRONYM" -MANAGED_IDENTITY="$1" -MANAGEMENT_IDENTITY_CLIENT_ID="$2" -SQL_DATABASE=$1 -SQL_SERVER_NAME="$RESOURCE_GROUP_NAME" -SQL_SERVER="$SQL_SERVER_NAME.database.windows.net" +UNIQUE_PREFIX=$1 +ENVIRONMENT=$2 +CLUSTER_LOCATION_ACRONYM=$3 +SQL_DATABASE_NAME=$4 +MANAGEMENT_IDENTITY_CLIENT_ID=$5 + +RESOURCE_GROUP_NAME=$UNIQUE_PREFIX-$ENVIRONMENT-$CLUSTER_LOCATION_ACRONYM +MANAGED_IDENTITY_NAME=$RESOURCE_GROUP_NAME-$4 +SQL_SERVER_NAME=$RESOURCE_GROUP_NAME +SQL_SERVER=$SQL_SERVER_NAME.database.windows.net cd "$(dirname "${BASH_SOURCE[0]}")" trap '. ./firewall.sh close' EXIT # Ensure that the firewall is closed no matter if other commands fail @@ -20,16 +24,16 @@ SID=$(awk -v id="$SID" 'BEGIN { substr(id,17) }') # Reverse the byte order for the first three sections of the GUID and concatenate -echo "$(date +"%Y-%m-%dT%H:%M:%S") Granting $MANAGED_IDENTITY (ID: $SID) in Recource group $RESOURCE_GROUP_NAME permissions on $SQL_SERVER/$SQL_DATABASE database" +echo "$(date +"%Y-%m-%dT%H:%M:%S") Granting $MANAGED_IDENTITY_NAME (ID: $SID) in Recource group $RESOURCE_GROUP_NAME permissions on $SQL_SERVER/$SQL_DATABASE_NAME database" # Execute the SQL script using mssql-scripter. Pass the script as a heredoc to sqlcmd to allow for complex SQL. -sqlcmd -S $SQL_SERVER -d $SQL_DATABASE --authentication-method=ActiveDirectoryDefault --exit-on-error << EOF -IF NOT EXISTS (SELECT [name] FROM [sys].[database_principals] WHERE [name] = '$MANAGED_IDENTITY' AND [type] = 'E') +sqlcmd -S $SQL_SERVER -d $SQL_DATABASE_NAME --authentication-method=ActiveDirectoryDefault --exit-on-error << EOF +IF NOT EXISTS (SELECT [name] FROM [sys].[database_principals] WHERE [name] = '$MANAGED_IDENTITY_NAME' AND [type] = 'E') BEGIN - CREATE USER [$MANAGED_IDENTITY] WITH SID = $SID, TYPE = E; - ALTER ROLE db_datareader ADD MEMBER [$MANAGED_IDENTITY]; - ALTER ROLE db_datawriter ADD MEMBER [$MANAGED_IDENTITY]; - ALTER ROLE db_ddladmin ADD MEMBER [$MANAGED_IDENTITY]; + CREATE USER [$MANAGED_IDENTITY_NAME] WITH SID = $SID, TYPE = E; + ALTER ROLE db_datareader ADD MEMBER [$MANAGED_IDENTITY_NAME]; + ALTER ROLE db_datawriter ADD MEMBER [$MANAGED_IDENTITY_NAME]; + ALTER ROLE db_ddladmin ADD MEMBER [$MANAGED_IDENTITY_NAME]; END GO EOF diff --git a/cloud-infrastructure/environment/config/development.sh b/cloud-infrastructure/environment/config/development.sh deleted file mode 100755 index 01f6fe400..000000000 --- a/cloud-infrastructure/environment/config/development.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash - -ENVIRONMENT="dev" -LOCATION="WestEurope" - -cd "$(dirname "${BASH_SOURCE[0]}")" -. ../deploy-environment.sh diff --git a/cloud-infrastructure/environment/config/production.sh b/cloud-infrastructure/environment/config/production.sh deleted file mode 100644 index d1ac5489e..000000000 --- a/cloud-infrastructure/environment/config/production.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash - -ENVIRONMENT="prod" -LOCATION="WestEurope" - -cd "$(dirname "${BASH_SOURCE[0]}")" -. ../deploy-environment.sh diff --git a/cloud-infrastructure/environment/config/staging.sh b/cloud-infrastructure/environment/config/staging.sh deleted file mode 100755 index 69b63e538..000000000 --- a/cloud-infrastructure/environment/config/staging.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash - -ENVIRONMENT="stage" -LOCATION="WestEurope" - -cd "$(dirname "${BASH_SOURCE[0]}")" -. ../deploy-environment.sh diff --git a/cloud-infrastructure/environment/deploy-environment.sh b/cloud-infrastructure/environment/deploy-environment.sh index a83d61ccc..651ee340c 100755 --- a/cloud-infrastructure/environment/deploy-environment.sh +++ b/cloud-infrastructure/environment/deploy-environment.sh @@ -1,8 +1,14 @@ -RESOURCE_GROUP_NAME="$UNIQUE_PREFIX-$ENVIRONMENT" +#!/bin/bash + +UNIQUE_PREFIX=$1 +ENVIRONMENT=$2 +LOCATION_SHARED=$3 + +RESOURCE_GROUP_NAME=$UNIQUE_PREFIX-$ENVIRONMENT CONTAINER_REGISTRY_NAME=$UNIQUE_PREFIX$ENVIRONMENT CURRENT_DATE=$(date +'%Y-%m-%dT%H-%M') DEPLOYMENT_COMMAND="az deployment sub create" -DEPLOYMENT_PARAMETERS="-l $LOCATION -n "$CURRENT_DATE-$UNIQUE_PREFIX-$ENVIRONMENT" --output table -f ./main-environment.bicep -p resourceGroupName=$RESOURCE_GROUP_NAME environment=$ENVIRONMENT containerRegistryName=$CONTAINER_REGISTRY_NAME" +DEPLOYMENT_PARAMETERS="-l $LOCATION_SHARED -n $CURRENT_DATE-$UNIQUE_PREFIX-$ENVIRONMENT --output table -f ./main-environment.bicep -p resourceGroupName=$RESOURCE_GROUP_NAME environment=$ENVIRONMENT containerRegistryName=$CONTAINER_REGISTRY_NAME" cd "$(dirname "${BASH_SOURCE[0]}")" . ../deploy.sh diff --git a/developer-cli/Commands/ConfigureContinuousDeploymentsCommand.cs b/developer-cli/Commands/ConfigureContinuousDeploymentsCommand.cs index 73b4d828e..1243cfee2 100644 --- a/developer-cli/Commands/ConfigureContinuousDeploymentsCommand.cs +++ b/developer-cli/Commands/ConfigureContinuousDeploymentsCommand.cs @@ -15,76 +15,79 @@ public class ConfigureContinuousDeploymentsCommand : Command { private static readonly JsonSerializerOptions? JsonSerializerOptions = new() { PropertyNameCaseInsensitive = true }; + private static readonly Config Config = new(); + + private static readonly Dictionary AzureLocations = GetAzureLocations(); + public ConfigureContinuousDeploymentsCommand() : base( "configure-continuous-deployments", - "Set up trust between Azure and GitHub for passwordless deployments using Azure App Registration with OpenID (aka Federated Credentials)." + "Set up trust between Azure and GitHub for passwordless deployments using OpenID." ) { - AddOption(new Option(["--skip-azure-login"], "Skip Azure login")); - AddOption(new Option(["--verbose-logging"], "Print Azure and Github CLI commands and output")); + AddOption(new Option(["--verbose-logging"], "Print Azure and GitHub CLI commands and output")); - Handler = CommandHandler.Create(Execute); + Handler = CommandHandler.Create(Execute); } - private int Execute(bool skipAzureLogin = false, bool verboseLogging = false) + private int Execute(bool verboseLogging = false) { PrerequisitesChecker.Check("dotnet", "az", "gh"); - AzureInfo azureInfo = new(); - Configuration.VerboseLogging = verboseLogging; - var githubInfo = new GithubInfo(); - PrintHeader("Introduction"); - ShowIntroPrompt(githubInfo, skipAzureLogin); + ShowIntroPrompt(); PrintHeader("Collecting data"); - SetGithubInfo(githubInfo); + SetGithubInfo(); - LoginToGithub(githubInfo); + LoginToGithub(); - PublishGithubVariables(githubInfo); + PublishExistingGithubVariables(); - CollectAzureSubscriptionInfo(azureInfo, skipAzureLogin, githubInfo); + ShowWarningIfGithubRepositoryIsAlreadyInitialized(); - CollectUniquePrefix(githubInfo, azureInfo); + SelectAzureSubscriptions(); - CollectExistingAppRegistration(azureInfo); + CollectLocations(); - CollectExistingSqlAdminSecurityGroup(azureInfo, githubInfo); + CollectUniquePrefix(); - CollectDomainNames(githubInfo, azureInfo); + ConfirmReuseIfAppRegistrationsExists(); + + ConfirmReuseIfSqlAdminSecurityGroupsExists(); PrintHeader("Confirm changes"); - ConfirmChangesPrompt(githubInfo, azureInfo); + ConfirmChangesPrompt(); + + var startNew = Stopwatch.StartNew(); PrintHeader("Configuring Azure and GitHub"); - PrepareSubscriptionForContainerAppsEnvironment(azureInfo.Subscription.Id); + PrepareSubscriptionsForContainerAppsEnvironment(); - CreateAppRegistrationIfNotExists(azureInfo); + CreateAppRegistrationsIfNotExists(); - CreateAppRegistrationCredentials(azureInfo, githubInfo); + CreateAppRegistrationCredentials(); - GrantSubscriptionPermissionsToServicePrincipal(azureInfo); + GrantSubscriptionPermissionsToServicePrincipals(); - CreateAzureSqlServerSecurityGroup(azureInfo, githubInfo); + CreateAzureSqlServerSecurityGroups(); - CreateGithubSecretsAndVariables(githubInfo, azureInfo); + CreateGithubEnvironments(); - CreateGithubEnvironments(githubInfo); + CreateGithubSecretsAndVariables(); DisableReusableWorkflows(); TriggerAndMonitorWorkflows(); - PrintHeader("Configuration of GitHub and Azure completed 🎉"); + PrintHeader($"Configuration of GitHub and Azure completed in {startNew.Elapsed:g} 🎉"); - ShowSuccessMessage(githubInfo); + ShowSuccessMessage(); return 0; } @@ -95,16 +98,15 @@ private void PrintHeader(string heading) AnsiConsole.MarkupLine($"\n[bold][green]{heading}[/] {separator}[/]\n"); } - private void ShowIntroPrompt(GithubInfo githubInfo, bool skipAzureLogin) + private void ShowIntroPrompt() { - var loginToAzure = skipAzureLogin ? "" : "\n * Prompt you to log in to Azure and select a subscription"; - var loginToGitHub = githubInfo.IsLoggedIn() ? "" : "\n * Prompt you to log in to GitHub"; + var loginToGitHub = Config.IsLoggedIn() ? "" : " * Prompt you to log in to GitHub\n"; var setupIntroPrompt = $""" This command will configure passwordless deployments from GitHub to Azure. If you continue, this command will do the following: - - {loginToAzure}{loginToGitHub} + + {loginToGitHub} * Prompt you to log in to Azure and select a subscription * Collect information about your Azure subscription and other settings for setting up continuous deployments * Confirm before you continue @@ -115,8 +117,9 @@ [bold]Would you like to continue?[/] AnsiConsole.WriteLine(); } - private void SetGithubInfo(GithubInfo githubInfo) + private void SetGithubInfo() { + // Get all Git remotes var output = ProcessHelper.StartProcess("git remote -v", Configuration.GetSourceCodeFolder(), true); // Sort the output lines so that the "origin" is at the top @@ -146,21 +149,21 @@ private void SetGithubInfo(GithubInfo githubInfo) break; } - githubInfo.InitializeFromUri(githubUri); + Config.InitializeFromUri(githubUri); } - private void LoginToGithub(GithubInfo githubInfo) + private void LoginToGithub() { - if (!githubInfo.IsLoggedIn()) + if (!Config.IsLoggedIn()) { ProcessHelper.StartProcess("gh auth login --git-protocol https --web"); - if (!githubInfo.IsLoggedIn()) Environment.Exit(0); + if (!Config.IsLoggedIn()) Environment.Exit(0); AnsiConsole.WriteLine(); } - var githubApiJson = ProcessHelper.StartProcess($"gh api repos/{githubInfo.Path}", redirectOutput: true); + var githubApiJson = ProcessHelper.StartProcess($"gh api repos/{Config.GithubInfo?.Path}", redirectOutput: true); using var githubApi = JsonDocument.Parse(githubApiJson); @@ -172,32 +175,53 @@ private void LoginToGithub(GithubInfo githubInfo) } } - private static void PublishGithubVariables(GithubInfo githubInfo) + private static void PublishExistingGithubVariables() { - var githubVariablesJson = ProcessHelper.StartProcess($"gh api repos/{githubInfo.Path}/actions/variables", redirectOutput: true); + var githubVariablesJson = ProcessHelper.StartProcess( + $"gh api repos/{Config.GithubInfo?.Path}/actions/variables --paginate", + redirectOutput: true + ); - var githubVariables = JsonDocument.Parse(githubVariablesJson); - foreach (var variable in githubVariables.RootElement.GetProperty("variables").EnumerateArray()) + var configGithubVariables = JsonDocument.Parse(githubVariablesJson).RootElement.GetProperty("variables").EnumerateArray(); + foreach (var variable in configGithubVariables) { var variableName = variable.GetProperty("name").GetString()!; var variableValue = variable.GetProperty("value").GetString()!; - githubInfo.Variables.Add(variableName, variableValue); + + Config.GithubVariables.Add(variableName, variableValue); } } - private void CollectAzureSubscriptionInfo(AzureInfo azureInfo, bool skipAzureLogin, GithubInfo githubInfo) + private static void ShowWarningIfGithubRepositoryIsAlreadyInitialized() { - // Both `az login` and `az account list` will return a JSON array of subscriptions - var subscriptionListJson = RunAzureCliCommand($"{(skipAzureLogin ? "account list --output json" : "login")}"); + if (Config.GithubVariables.Count(variable => Enum.GetNames(typeof(VariableNames)).Contains(variable.Key)) == 0) + { + return; + } + + AnsiConsole.MarkupLine("[yellow]This Github Repository has already been initialized. If you continue existing GitHub variables will be overridden.[/]"); + if (AnsiConsole.Confirm("Do you want to continue, and override existing GitHub variables?")) + { + AnsiConsole.WriteLine(); + return; + } + + Environment.Exit(0); + } + + private void SelectAzureSubscriptions() + { + // `az login` returns a JSON array of subscriptions + var subscriptionListJson = RunAzureCliCommand("login"); // Regular expression to match JSON part var jsonRegex = new Regex(@"\[.*\]", RegexOptions.Singleline); var match = jsonRegex.Match(subscriptionListJson); - List? azureSubscriptions = null; + List? azureSubscriptions = null; if (match.Success) { - azureSubscriptions = JsonSerializer.Deserialize>(match.Value, JsonSerializerOptions); + azureSubscriptions = JsonSerializer.Deserialize>(match.Value, JsonSerializerOptions); } if (azureSubscriptions == null) @@ -206,205 +230,276 @@ private void CollectAzureSubscriptionInfo(AzureInfo azureInfo, bool skipAzureLog Environment.Exit(1); } - var activeSubscriptions = azureSubscriptions.Where(s => s.State == "Enabled").ToList(); + Config.StagingSubscription = SelectSubscription("Staging"); + Config.ProductionSubscription = SelectSubscription("Production"); - var title = "[bold]Please select an Azure subscription[/]"; - var selectedDisplayName = AnsiConsole.Prompt(new SelectionPrompt() - .Title($"{title}") - .AddChoices(activeSubscriptions.Select(s => s.Name)) - ); + return; - var selectedSubscriptions = activeSubscriptions.Where(s => s.Name == selectedDisplayName).ToArray(); - if (selectedSubscriptions.Length > 1) + Subscription SelectSubscription(string environmentName) { - AnsiConsole.MarkupLine($"[red]ERROR:[/] Found two subscriptions with the name {selectedDisplayName}."); - Environment.Exit(1); + var activeSubscriptions = azureSubscriptions.Where(s => s.State == "Enabled").ToList(); + + var selectedDisplayName = AnsiConsole.Prompt(new SelectionPrompt() + .Title($"[bold]Please select an Azure subscription for [yellow]{environmentName}[/][/]") + .AddChoices(activeSubscriptions.Select(s => s.Name)) + ); + + var selectedSubscriptions = activeSubscriptions.Where(s => s.Name == selectedDisplayName).ToArray(); + if (selectedSubscriptions.Length > 1) + { + AnsiConsole.MarkupLine($"[red]ERROR:[/] Found two subscriptions with the name {selectedDisplayName}."); + Environment.Exit(1); + } + + var azureSubscription = selectedSubscriptions.Single(); + + return new Subscription( + azureSubscription.Id, + azureSubscription.Name, + azureSubscription.TenantId, + Config.GithubInfo!, + environmentName + ); } + } - var subscription = selectedSubscriptions.Single(); - RunAzureCliCommand($"account set --subscription {subscription.Id}", false); + private void CollectLocations() + { + var location = CollectLocation(); - AnsiConsole.MarkupLine($"{title}: {subscription.Name}\n"); + Config.StagingLocation = location; + Config.ProductionLocation = location; - azureInfo.AppRegistrationName = $"GitHub - {githubInfo.OrganizationName}/{githubInfo.RepositoryName}"; + Location CollectLocation() + { + var locationDisplayName = AnsiConsole.Prompt(new SelectionPrompt() + .Title("[bold]Please select a location where Azure Resource can be deployed [/]") + .AddChoices(AzureLocations.Keys) + ); - azureInfo.Subscription = subscription; + var locationAcronym = AzureLocations[locationDisplayName]; + var locationCode = locationDisplayName.Replace(" ", "").ToLower(); + return new Location(locationCode, locationCode, locationAcronym); + } } - private void CollectUniquePrefix(GithubInfo githubInfo, AzureInfo azureInfo) + private void CollectUniquePrefix() { - githubInfo.Variables.TryGetValue("UNIQUE_PREFIX", out var uniquePrefix); + var uniquePrefix = Config.GithubVariables.GetValueOrDefault(nameof(VariableNames.UNIQUE_PREFIX)); AnsiConsole.MarkupLine( "When creating Azure resources like Azure Container Registry, SQL Server, Blob storage, Service Bus, Key Vaults, etc., a global unique name is required. To do this we use a prefix of 2-6 characters, which allows for flexibility for the rest of the name. E.g. if you select 'acme' the production SQL Server in West Europe will be named 'acme-prod-euw'." ); - var defaultValue = uniquePrefix - ?? githubInfo.OrganizationName!.ToLower().Substring(0, Math.Min(6, githubInfo.OrganizationName.Length)); + if (uniquePrefix is not null) + { + AnsiConsole.MarkupLine($"[yellow]The unique prefix '{uniquePrefix}' already specified. Changing this will recreate all Azure resources![/]"); + } + else + { + uniquePrefix = Config.GithubInfo!.OrganizationName.ToLower().Substring(0, Math.Min(6, Config.GithubInfo.OrganizationName.Length)); + } while (true) { uniquePrefix = AnsiConsole.Prompt( new TextPrompt("[bold]Please enter a unique prefix between 2-6 characters (e.g. an acronym for your product or company).[/]") - .DefaultValue(defaultValue) + .DefaultValue(uniquePrefix) .Validate(input => Regex.IsMatch(input, "^[a-z0-9]{2,6}$") ? ValidationResult.Success() - : ValidationResult.Error("[red]ERROR:[/]The unique prefix must be 2-6 characters and contain only lowercase characters a-z or 0-9.") + : ValidationResult.Error("[red]ERROR:[/]The unique prefix must be 2-6 characters and contain only lowercase letters a-z or numbers 0-9.") ) ); - if (IsContainerRegistryInUseOnAnotherSubscription($"{uniquePrefix}stage") || - IsContainerRegistryInUseOnAnotherSubscription($"{uniquePrefix}prod")) + if (IsContainerRegistryConflicting(Config.StagingSubscription.Id, Config.StagingLocation.SharedLocation, $"{uniquePrefix}-stage", $"{uniquePrefix}stage") || + IsContainerRegistryConflicting(Config.ProductionSubscription.Id, Config.ProductionLocation.SharedLocation, $"{uniquePrefix}-prod", $"{uniquePrefix}prod")) { AnsiConsole.MarkupLine( - "[red]ERROR:[/]Azure resources conflicting with this prefix is already in use, possibly in another subscription. Please enter a unique name." + "[red]ERROR:[/]Azure resources conflicting with this prefix is already in use, possibly in [bold]another subscription[/] or in [bold]another location[/]. Please enter a unique name." ); continue; } AnsiConsole.WriteLine(); - azureInfo.UniquePrefix = uniquePrefix; + Config.UniquePrefix = uniquePrefix; return; } - bool IsContainerRegistryInUseOnAnotherSubscription(string azureContainerRegistryName) + bool IsContainerRegistryConflicting(string subscriptionId, string location, string resourceGroup, string azureContainerRegistryName) { var checkAvailability = RunAzureCliCommand($"acr check-name --name {azureContainerRegistryName} --query \"nameAvailable\" -o tsv"); if (bool.Parse(checkAvailability)) return false; - var showExistingRegistry = RunAzureCliCommand($"acr show --name {azureContainerRegistryName} --subscription {azureInfo.Subscription.Id} --output json"); + var showExistingRegistry = RunAzureCliCommand($"acr show --name {azureContainerRegistryName} --subscription {subscriptionId} --output json"); var jsonRegex = new Regex(@"\{.*\}", RegexOptions.Singleline); var match = jsonRegex.Match(showExistingRegistry); if (!match.Success) return true; var jsonDocument = JsonDocument.Parse(match.Value); - return jsonDocument.RootElement.GetProperty("id").GetString()?.Contains(azureInfo.Subscription.Id) == false; + var sameSubscription = jsonDocument.RootElement.GetProperty("id").GetString()?.Contains(subscriptionId) == true; + var sameResourceGroup = jsonDocument.RootElement.GetProperty("resourceGroup").GetString() == resourceGroup; + var sameLocation = jsonDocument.RootElement.GetProperty("location").GetString() == location; + + return !(sameSubscription && sameResourceGroup && sameLocation); } } - private void CollectExistingAppRegistration(AzureInfo azureInfo) + private void ConfirmReuseIfAppRegistrationsExists() { - var appRegistrationId = RunAzureCliCommand( - $"""ad app list --display-name "{azureInfo.AppRegistrationName}" --query "[].appId" -o tsv""" - ).Trim(); + ConfirmReuseIfAppRegistrationExist(Config.StagingSubscription.AppRegistration); + ConfirmReuseIfAppRegistrationExist(Config.ProductionSubscription.AppRegistration); + return; - var servicePrincipalId = RunAzureCliCommand( - $"""ad sp list --display-name "{azureInfo.AppRegistrationName}" --query "[].appId" -o tsv""" - ).Trim(); + void ConfirmReuseIfAppRegistrationExist(AppRegistration appRegistration) + { + appRegistration.AppRegistrationId = RunAzureCliCommand( + $"""ad app list --display-name "{appRegistration.Name}" --query "[].appId" -o tsv""" + ).Trim(); - var servicePrincipalObjectId = RunAzureCliCommand( - $"""ad sp list --filter "appId eq '{appRegistrationId}'" --query "[].id" -o tsv""" - ).Trim(); + appRegistration.ServicePrincipalId = RunAzureCliCommand( + $"""ad sp list --display-name "{appRegistration.Name}" --query "[].appId" -o tsv""" + ).Trim(); - if (appRegistrationId != string.Empty && servicePrincipalId != string.Empty) - { - azureInfo.AppRegistrationExists = true; - AnsiConsole.MarkupLine( - $"[yellow]The App Registration '{azureInfo.AppRegistrationName}' already exists with App ID: {servicePrincipalId}[/]" - ); + appRegistration.ServicePrincipalObjectId = RunAzureCliCommand( + $"""ad sp list --filter "appId eq '{appRegistration.AppRegistrationId}'" --query "[].id" -o tsv""" + ).Trim(); - if (AnsiConsole.Confirm("The existing App Registration will be reused. Do you want to continue?")) + if (appRegistration.AppRegistrationId != string.Empty && appRegistration.ServicePrincipalId != string.Empty) { - azureInfo.AppRegistrationId = appRegistrationId; - azureInfo.ServicePrincipalId = servicePrincipalId; - azureInfo.ServicePrincipalObjectId = servicePrincipalObjectId; - AnsiConsole.WriteLine(); - return; - } + AnsiConsole.MarkupLine( + $"[yellow]The App Registration '{appRegistration.Name}' already exists with App ID: {appRegistration.ServicePrincipalId}[/]" + ); - AnsiConsole.MarkupLine("[red]Please delete the existing App Registration and try again.[/]"); - Environment.Exit(1); - } + if (AnsiConsole.Confirm("The existing App Registration will be reused. Do you want to continue?")) + { + AnsiConsole.WriteLine(); + return; + } - if (appRegistrationId != string.Empty || servicePrincipalId != string.Empty) - { - AnsiConsole.MarkupLine( - $"[red]The App Registration or Service Principal '{azureInfo.AppRegistrationName}' exists but not both. Please manually delete and retry.[/]" - ); - Environment.Exit(1); + AnsiConsole.MarkupLine("[red]Please delete the existing App Registration and try again.[/]"); + Environment.Exit(1); + } + + if (appRegistration.AppRegistrationId != string.Empty || appRegistration.ServicePrincipalId != string.Empty) + { + AnsiConsole.MarkupLine($"[red]The App Registration or Service Principal '{appRegistration}' exists, but not both. Please manually delete and retry.[/]"); + Environment.Exit(1); + } } } - private void CollectExistingSqlAdminSecurityGroup(AzureInfo azureInfo, GithubInfo githubInfo) + private void ConfirmReuseIfSqlAdminSecurityGroupsExists() { - var sqlAdminsSecurityGroupName = azureInfo.GetSqlAdminsSecurityGroupName(githubInfo); + Config.StagingSubscription.SqlAdminsGroup.ObjectId = ConfirmReuseIfSqlAdminSecurityGroupExist(Config.StagingSubscription.SqlAdminsGroup.Name); + Config.ProductionSubscription.SqlAdminsGroup.ObjectId = ConfirmReuseIfSqlAdminSecurityGroupExist(Config.ProductionSubscription.SqlAdminsGroup.Name); - azureInfo.SqlAdminsSecurityGroupId = RunAzureCliCommand( - $"""ad group list --display-name "{sqlAdminsSecurityGroupName}" --query "[].id" -o tsv""" - ).Trim(); - - if (azureInfo.SqlAdminsSecurityGroupId == string.Empty) + string? ConfirmReuseIfSqlAdminSecurityGroupExist(string sqlAdminsSecurityGroupName) { - azureInfo.SqlAdminsSecurityGroupId = null; - return; - } + var sqlAdminsObjectId = RunAzureCliCommand( + $"""ad group list --display-name "{sqlAdminsSecurityGroupName}" --query "[].id" -o tsv""" + ).Trim(); - AnsiConsole.MarkupLine( - $"[yellow]The AD Security Group '{sqlAdminsSecurityGroupName}' already exists with ID: {azureInfo.SqlAdminsSecurityGroupId}[/]" - ); + if (sqlAdminsObjectId == string.Empty) + { + return null; + } - if (AnsiConsole.Confirm("The existing AD Security Group will be reused. Do you want to continue?")) - { - AnsiConsole.WriteLine(); - azureInfo.SqlAdminsSecurityGroupExists = true; - return; - } + AnsiConsole.MarkupLine( + $"[yellow]The AD Security Group '{sqlAdminsSecurityGroupName}' already exists with ID: {sqlAdminsObjectId}[/]" + ); - AnsiConsole.MarkupLine("[red]Please delete the existing AD Security Group and try again.[/]"); - Environment.Exit(1); - } + if (AnsiConsole.Confirm("The existing AD Security Group will be reused. Do you want to continue?") == false) + { + AnsiConsole.MarkupLine("[red]Please delete the existing AD Security Group and try again.[/]"); + Environment.Exit(0); + } - private void CollectDomainNames(GithubInfo githubInfo, AzureInfo azureInfo) - { - githubInfo.Variables.TryGetValue("DOMAIN_NAME_PRODUCTION", out var domainNameProduction); - azureInfo.ProductionDomainName = domainNameProduction ?? "-"; + AnsiConsole.WriteLine(); - githubInfo.Variables.TryGetValue("DOMAIN_NAME_STAGING", out var domainNameStaging); - azureInfo.StagingDomainName = domainNameStaging ?? "-"; + return sqlAdminsObjectId; + } } - private void ConfirmChangesPrompt(GithubInfo githubInfo, AzureInfo azureInfo) + private void ConfirmChangesPrompt() { - var appRegistrationAction = azureInfo.AppRegistrationExists ? "updated" : "created"; - var reuseSqlAdminsSecurityGroupAction = azureInfo.SqlAdminsSecurityGroupExists ? "updated" : "created"; - var sqlAdminsSecurityGroupName = azureInfo.GetSqlAdminsSecurityGroupName(githubInfo); + var stagingServicePrincipal = Config.StagingSubscription.AppRegistration.Exists + ? Config.StagingSubscription.AppRegistration.ServicePrincipalId + : "Will be generated"; + var productionServicePrincipal = Config.ProductionSubscription.AppRegistration.Exists + ? Config.ProductionSubscription.AppRegistration.ServicePrincipalId + : "Will be generated"; + var stagingSqlAdminObject = Config.StagingSubscription.SqlAdminsGroup.Exists + ? Config.StagingSubscription.SqlAdminsGroup.ObjectId + : "Will be generated"; + var productionSqlAdminObject = Config.ProductionSubscription.SqlAdminsGroup.Exists + ? Config.ProductionSubscription.SqlAdminsGroup.ObjectId + : "Will be generated"; var setupConfirmPrompt = $""" - [bold]If you continue the following will happen:[/] + [bold]Please review planned changes before continuing.[/] - 1. The App Registration named [blue]{azureInfo.AppRegistrationName}[/] will be {appRegistrationAction} allowing GitHub to do passwordless deployments to Azure. - - 2. The App Registration will be granted the 'Contributor' and 'User Access Administrator' roles in the Azure Subscription. - - 3. The AD Security Group [blue]{sqlAdminsSecurityGroupName}[/] will be {reuseSqlAdminsSecurityGroupAction}, with the App Registration set as the owner. - - 4. The GitHub Repository [blue]{githubInfo.GithubUrl}[/] will be configured with the following secrets and variables: + 1. The following will be created or updated in Azure: - GitHub Secrets (soft secrets): - * AZURE_TENANT_ID: [blue]{azureInfo.Subscription.TenantId}[/] - * AZURE_SUBSCRIPTION_ID: [blue]{azureInfo.Subscription.Id}[/] - * AZURE_SERVICE_PRINCIPAL_ID: [blue]{azureInfo.AppRegistrationId ?? "will be generated"}[/] - * ACTIVE_DIRECTORY_SQL_ADMIN_OBJECT_ID: [blue]{azureInfo.SqlAdminsSecurityGroupId ?? "will be generated"}[/] + [bold]Active Directory App Registrations/Service Principals:[/] + * [blue]{Config.StagingSubscription.AppRegistration.Name}[/] with access to the [blue]{Config.StagingSubscription.Name}[/] subscription. + * [blue]{Config.ProductionSubscription.AppRegistration.Name}[/] with access to the [blue]{Config.ProductionSubscription.Name}[/] subscription. + + [yellow]** The Service Principals will get 'Contributor' and 'User Access Administrator' role on the Azure Subscriptions.[/] + + [bold]Active Directory Security Groups:[/] + * [blue]{Config.StagingSubscription.SqlAdminsGroup.Name}[/] + * [blue]{Config.ProductionSubscription.SqlAdminsGroup.Name}[/] + + [yellow]** The SQL Admins Security Groups are used to grant Managed Identities and CI/CD permissions to SQL Databases.[/] + + 2. The following GitHub environments will be created if not exists: + * [blue]staging[/] + * [blue]production[/] - GitHub Variables: - * UNIQUE_PREFIX: [blue]{azureInfo.UniquePrefix}[/] - * DOMAIN_NAME_PRODUCTION: [blue]{azureInfo.ProductionDomainName}[/] ([yellow]set this manually in GitHub to add the production domain[/]) - * DOMAIN_NAME_STAGING: [blue]{azureInfo.StagingDomainName}[/] ([yellow]set this manually in GitHub to add the staging domain[/]) - - 5. The following environments will be created in the GitHub repository [blue]staging[/] and [blue]production[/] if they do not exist. + [yellow]** Environments are used to require approval when infrastructure is deployed. In private GitHub repositories, this requires a paid plan.[/] - 6. The [blue]Cloud Infrastructure - Deployment[/] GitHub Action will be triggered to deploy Azure Infrastructure. This will take [yellow]between 30-45 minutes[/]. + 3. The following GitHub repository variables will be created: + + [bold]Shared Variables:[/] + * TENANT_ID: [blue]{Config.TenantId}[/] + * UNIQUE_PREFIX: [blue]{Config.UniquePrefix}[/] + + [bold]Staging Shared Variables:[/] + * STAGING_SUBSCRIPTION_ID: [blue]{Config.StagingSubscription.Id}[/] + * STAGING_SHARED_LOCATION: [blue]{Config.StagingLocation.SharedLocation}[/] + * STAGING_SERVICE_PRINCIPAL_ID: [blue]{stagingServicePrincipal}[/] + * STAGING_SQL_ADMIN_OBJECT_ID: [blue]{stagingSqlAdminObject}[/] + * STAGING_DOMAIN_NAME: [blue]-[/] ([yellow]Manually changed this and triggered deployment to set up the domain[/]) + + [bold]Staging Cluster Variables:[/] + * STAGING_CLUSTER_ENABLED: [blue]true[/] + * STAGING_CLUSTER_LOCATION: [blue]{Config.StagingLocation.ClusterLocation}[/] + * STAGING_CLUSTER_LOCATION_ACRONYM: [blue]{Config.StagingLocation.ClusterLocationAcronym}[/] + + [bold]Production Shared Variables:[/] + * PRODUCTION_SUBSCRIPTION_ID: [blue]{Config.ProductionSubscription.Id}[/] + * PRODUCTION_SHARED_LOCATION: [blue]{Config.ProductionLocation.SharedLocation}[/] + * PRODUCTION_SERVICE_PRINCIPAL_ID: [blue]{productionServicePrincipal}[/] + * PRODUCTION_SQL_ADMIN_OBJECT_ID: [blue]{productionSqlAdminObject}[/] + * PRODUCTION_DOMAIN_NAME: [blue]-[/] ([yellow]Manually changed this and triggered deployment to set up the domain[/]) + + [bold]Production Cluster 1 Variables:[/] + * PRODUCTION_CLUSTER1_ENABLED: [blue]false[/] ([yellow]Change this to 'true' when ready to deploy to production[/]) + * PRODUCTION_CLUSTER1_LOCATION: [blue]{Config.ProductionLocation.ClusterLocation}[/] + * PRODUCTION_CLUSTER1_LOCATION_ACRONYM: [blue]{Config.ProductionLocation.ClusterLocationAcronym}[/] + + [yellow]** All variables can be changed on the GitHub Settings page. For example, if you want to deploy production or staging to different locations.[/] - 7. Disable the reusable workflow [blue]Deploy Container[/]. + 4. Disable the reusable GitHub workflows [blue]Deploy Container[/] and [blue]Plan and Deploy Infrastructure[/]. - 8. The [blue]Application - Build and Deploy[/] GitHub Action will be triggered to deploy the Application Code. This will take [yellow]less than 5 minutes[/]. + 5. The [blue]Cloud Infrastructure - Deployment[/] GitHub Actions will be triggered deployment of Azure Infrastructure. This will take [yellow]between 15 and 45 minutes[/]. - 9. You will receive recommendations on how to further secure and optimize your setup. + 6. The [blue]Build and Deploy[/] GitHub Action will be triggered to deploy the Application Code. This will take [yellow]between 5 and 10 minutes[/]. - Please note that this command can be run again update the configuration. Use the [yellow]--skip-azure-login[/] flag to avoid logging in to Azure again. + 7. You will receive recommendations on how to further secure and optimize your setup. [bold]Would you like to continue?[/] """; @@ -412,55 +507,79 @@ [bold]Would you like to continue?[/] if (!AnsiConsole.Confirm($"{setupConfirmPrompt}", false)) Environment.Exit(0); } - private void PrepareSubscriptionForContainerAppsEnvironment(string subscriptionId) + private void PrepareSubscriptionsForContainerAppsEnvironment() { - RunAzureCliCommand( - $"provider register --namespace Microsoft.ContainerService --subscription {subscriptionId}", - !Configuration.VerboseLogging - ); + PrepareSubscription(Config.StagingSubscription.Id); + PrepareSubscription(Config.ProductionSubscription.Id); - AnsiConsole.MarkupLine("[green]Successfully ensured deployment of Azure Container Apps Environment is enabled on Azure Subscription.[/]"); + AnsiConsole.MarkupLine("[green]Successfully ensured deployment of Azure Container Apps Environment is enabled on Azure Subscriptions.[/]"); + return; + + void PrepareSubscription(string subscriptionId) + { + RunAzureCliCommand( + $"provider register --namespace Microsoft.ContainerService --subscription {subscriptionId}", + !Configuration.VerboseLogging + ); + } } - private void CreateAppRegistrationIfNotExists(AzureInfo azureInfo) + private void CreateAppRegistrationsIfNotExists() { - if (azureInfo.AppRegistrationExists) return; + if (!Config.StagingSubscription.AppRegistration.Exists) + { + CreateAppRegistration(Config.StagingSubscription.AppRegistration); + } + + if (!Config.ProductionSubscription.AppRegistration.Exists) + { + CreateAppRegistration(Config.ProductionSubscription.AppRegistration); + } - azureInfo.AppRegistrationId = RunAzureCliCommand( - $"""ad app create --display-name "{azureInfo.AppRegistrationName}" --query appId -o tsv""" - ).Trim(); + return; - azureInfo.ServicePrincipalId = RunAzureCliCommand( - $"ad sp create --id {azureInfo.AppRegistrationId} --query appId -o tsv" - ).Trim(); + void CreateAppRegistration(AppRegistration appRegistration) + { + appRegistration.AppRegistrationId = RunAzureCliCommand( + $"""ad app create --display-name "{appRegistration.Name}" --query appId -o tsv""" + ).Trim(); - azureInfo.ServicePrincipalObjectId = RunAzureCliCommand( - $"""ad sp list --filter "appId eq '{azureInfo.AppRegistrationId}'" --query "[].id" -o tsv""" - ).Trim(); + appRegistration.ServicePrincipalId = RunAzureCliCommand( + $"ad sp create --id {appRegistration.AppRegistrationId} --query appId -o tsv" + ).Trim(); - AnsiConsole.MarkupLine( - $"[green]Successfully created an App Registration {azureInfo.AppRegistrationName} ({azureInfo.AppRegistrationId}).[/]" - ); + appRegistration.ServicePrincipalObjectId = RunAzureCliCommand( + $"""ad sp list --filter "appId eq '{appRegistration.AppRegistrationId}'" --query "[].id" -o tsv""" + ).Trim(); + + AnsiConsole.MarkupLine( + $"[green]Successfully created an App Registration {appRegistration} ({appRegistration.AppRegistrationId}).[/]" + ); + } } - private void CreateAppRegistrationCredentials(AzureInfo azureInfo, GithubInfo githubInfo) + private void CreateAppRegistrationCredentials() { - CreateFederatedCredential("MainBranch", "ref:refs/heads/main"); - CreateFederatedCredential("PullRequests", "pull_request"); - CreateFederatedCredential("StagingEnvironment", "environment:staging"); - CreateFederatedCredential("ProductionEnvironment", "environment:production"); + // Staging + CreateFederatedCredential(Config.StagingSubscription.AppRegistration.AppRegistrationId!, "MainBranch", "ref:refs/heads/main"); + CreateFederatedCredential(Config.StagingSubscription.AppRegistration.AppRegistrationId!, "StagingEnvironment", "environment:staging"); + CreateFederatedCredential(Config.StagingSubscription.AppRegistration.AppRegistrationId!, "PullRequests", "pull_request"); + + // Production + CreateFederatedCredential(Config.ProductionSubscription.AppRegistration.AppRegistrationId!, "MainBranch", "ref:refs/heads/main"); + CreateFederatedCredential(Config.ProductionSubscription.AppRegistration.AppRegistrationId!, "ProductionEnvironment", "environment:production"); AnsiConsole.MarkupLine( - $"[green]Successfully created App Registration with Federated Credentials allowing passwordless deployments from {githubInfo.GithubUrl}.[/]" + $"[green]Successfully created App Registration with Federated Credentials allowing passwordless deployments from {Config.GithubInfo?.Url}.[/]" ); - void CreateFederatedCredential(string displayName, string refRefsHeadsMain) + void CreateFederatedCredential(string appRegistrationId, string displayName, string refRefsHeadsMain) { var parameters = JsonSerializer.Serialize(new { name = displayName, issuer = "https://token.actions.githubusercontent.com", - subject = $"""repo:{githubInfo.Path}:{refRefsHeadsMain}""", + subject = $"""repo:{Config.GithubInfo?.Path}:{refRefsHeadsMain}""", audiences = new[] { "api://AzureADTokenExchange" } } ); @@ -469,7 +588,7 @@ void CreateFederatedCredential(string displayName, string refRefsHeadsMain) { FileName = Configuration.IsWindows ? "cmd.exe" : "az", Arguments = - $"{(Configuration.IsWindows ? "/C az" : string.Empty)} ad app federated-credential create --id {azureInfo.AppRegistrationId} --parameters @-", + $"{(Configuration.IsWindows ? "/C az" : string.Empty)} ad app federated-credential create --id {appRegistrationId} --parameters @-", RedirectStandardInput = true, RedirectStandardOutput = !Configuration.VerboseLogging, RedirectStandardError = !Configuration.VerboseLogging @@ -478,80 +597,64 @@ void CreateFederatedCredential(string displayName, string refRefsHeadsMain) } } - private void GrantSubscriptionPermissionsToServicePrincipal(AzureInfo azureInfo) + private void GrantSubscriptionPermissionsToServicePrincipals() { - GrantAccess("Contributor"); - GrantAccess("User Access Administrator"); + GrantAccess(Config.StagingSubscription, Config.StagingSubscription.AppRegistration.Name); + GrantAccess(Config.ProductionSubscription, Config.ProductionSubscription.AppRegistration.Name); - AnsiConsole.MarkupLine( - $"[green]Successfully granted Service Principal ({azureInfo.ServicePrincipalId}) 'Contributor' and `User Access Administrator` rights to Azure Subscription.[/]" - ); - - void GrantAccess(string role) + void GrantAccess(Subscription subscription, string appRegistrationName) { + var servicePrincipalId = subscription.AppRegistration.ServicePrincipalId!; + RunAzureCliCommand( - $"role assignment create --assignee {azureInfo.ServicePrincipalId} --role \"{role}\" --scope /subscriptions/{azureInfo.Subscription.Id}", + $"role assignment create --assignee {servicePrincipalId} --role \"Contributor\" --scope /subscriptions/{subscription.Id}", !Configuration.VerboseLogging ); + RunAzureCliCommand( + $"role assignment create --assignee {servicePrincipalId} --role \"User Access Administrator\" --scope /subscriptions/{subscription.Id}", + !Configuration.VerboseLogging + ); + + AnsiConsole.MarkupLine( + $"[green]Successfully granted Service Principal ({appRegistrationName}) 'Contributor' and `User Access Administrator` rights to Azure Subscription {subscription.Name}.[/]" + ); } } - private void CreateAzureSqlServerSecurityGroup(AzureInfo azureInfo, GithubInfo githubInfo) + private void CreateAzureSqlServerSecurityGroups() { - var sqlAdminsSecurityGroupName = azureInfo.GetSqlAdminsSecurityGroupName(githubInfo); - if (!azureInfo.SqlAdminsSecurityGroupExists) - { - azureInfo.SqlAdminsSecurityGroupId = RunAzureCliCommand( - $"""ad group create --display-name "{sqlAdminsSecurityGroupName}" --mail-nickname "{azureInfo.SqlAdminsSecurityGroupNickName}" --query "id" -o tsv""" - ).Trim(); - } + CreateAzureSqlServerSecurityGroup(Config.StagingSubscription.SqlAdminsGroup, Config.StagingSubscription.AppRegistration); + CreateAzureSqlServerSecurityGroup(Config.ProductionSubscription.SqlAdminsGroup, Config.ProductionSubscription.AppRegistration); - RunAzureCliCommand( - $"ad group member add --group {azureInfo.SqlAdminsSecurityGroupId} --member-id {azureInfo.ServicePrincipalObjectId}", - !Configuration.VerboseLogging - ); - - AnsiConsole.MarkupLine( - $"[green]Successfully created AD Security Group '{sqlAdminsSecurityGroupName}' and granted the App Registration {azureInfo.AppRegistrationName} owner.[/]" - ); - } + void CreateAzureSqlServerSecurityGroup(SqlAdminsGroup sqlAdminGroup, AppRegistration appRegistration) + { + if (!sqlAdminGroup.Exists) + { + sqlAdminGroup.ObjectId = RunAzureCliCommand( + $"""ad group create --display-name "{sqlAdminGroup.Name}" --mail-nickname "{sqlAdminGroup.NickName}" --query "id" -o tsv""" + ).Trim(); + } - private void CreateGithubSecretsAndVariables(GithubInfo githubInfo, AzureInfo azureInfo) - { - ProcessHelper.StartProcess( - $"gh secret set AZURE_TENANT_ID -b\"{azureInfo.Subscription.TenantId}\" --repo={githubInfo.Path}" - ); - ProcessHelper.StartProcess( - $"gh secret set AZURE_SUBSCRIPTION_ID -b\"{azureInfo.Subscription.Id}\" --repo={githubInfo.Path}" - ); - ProcessHelper.StartProcess( - $"gh secret set AZURE_SERVICE_PRINCIPAL_ID -b\"{azureInfo.AppRegistrationId}\" --repo={githubInfo.Path}" - ); - ProcessHelper.StartProcess( - $"gh secret set ACTIVE_DIRECTORY_SQL_ADMIN_OBJECT_ID -b\"{azureInfo.SqlAdminsSecurityGroupId}\" --repo={githubInfo.Path}" - ); - ProcessHelper.StartProcess( - $"gh variable set UNIQUE_PREFIX -b\"{azureInfo.UniquePrefix}\" --repo={githubInfo.Path}" - ); - ProcessHelper.StartProcess( - $"gh variable set DOMAIN_NAME_PRODUCTION -b\"{azureInfo.ProductionDomainName}\" --repo={githubInfo.Path}" - ); - ProcessHelper.StartProcess( - $"gh variable set DOMAIN_NAME_STAGING -b\"{azureInfo.StagingDomainName}\" --repo={githubInfo.Path}" - ); + RunAzureCliCommand( + $"ad group member add --group {sqlAdminGroup.ObjectId} --member-id {appRegistration.ServicePrincipalObjectId}", + !Configuration.VerboseLogging + ); - AnsiConsole.MarkupLine("[green]Successfully created secrets in GitHub.[/]"); + AnsiConsole.MarkupLine( + $"[green]Successfully created AD Security Group '{sqlAdminGroup.Name}' and granted the App Registration {appRegistration.Name} owner.[/]" + ); + } } - private void CreateGithubEnvironments(GithubInfo githubInfo) + private void CreateGithubEnvironments() { ProcessHelper.StartProcess( - $"""gh api --method PUT -H "Accept: application/vnd.github+json" repos/{githubInfo.Path}/environments/staging""", + $"""gh api --method PUT -H "Accept: application/vnd.github+json" repos/{Config.GithubInfo?.Path}/environments/staging""", redirectOutput: true ); ProcessHelper.StartProcess( - $"""gh api --method PUT -H "Accept: application/vnd.github+json" repos/{githubInfo.Path}/environments/production""", + $"""gh api --method PUT -H "Accept: application/vnd.github+json" repos/{Config.GithubInfo?.Path}/environments/production""", redirectOutput: true ); @@ -560,10 +663,45 @@ private void CreateGithubEnvironments(GithubInfo githubInfo) ); } + private void CreateGithubSecretsAndVariables() + { + SetGithubVariable(VariableNames.TENANT_ID, Config.TenantId); + SetGithubVariable(VariableNames.UNIQUE_PREFIX, Config.UniquePrefix); + + SetGithubVariable(VariableNames.STAGING_SUBSCRIPTION_ID, Config.StagingSubscription.Id); + SetGithubVariable(VariableNames.STAGING_SERVICE_PRINCIPAL_ID, Config.StagingSubscription.AppRegistration.ServicePrincipalId!); + SetGithubVariable(VariableNames.STAGING_SHARED_LOCATION, Config.StagingLocation.SharedLocation); + SetGithubVariable(VariableNames.STAGING_SQL_ADMIN_OBJECT_ID, Config.StagingSubscription.SqlAdminsGroup.ObjectId!); + SetGithubVariable(VariableNames.STAGING_DOMAIN_NAME, "-"); + + SetGithubVariable(VariableNames.STAGING_CLUSTER_ENABLED, "true"); + SetGithubVariable(VariableNames.STAGING_CLUSTER_LOCATION, Config.StagingLocation.ClusterLocation); + SetGithubVariable(VariableNames.STAGING_CLUSTER_LOCATION_ACRONYM, Config.StagingLocation.ClusterLocationAcronym); + + SetGithubVariable(VariableNames.PRODUCTION_SUBSCRIPTION_ID, Config.ProductionSubscription.Id); + SetGithubVariable(VariableNames.PRODUCTION_SERVICE_PRINCIPAL_ID, Config.ProductionSubscription.AppRegistration.ServicePrincipalId!); + SetGithubVariable(VariableNames.PRODUCTION_SHARED_LOCATION, Config.ProductionLocation.SharedLocation); + SetGithubVariable(VariableNames.PRODUCTION_SQL_ADMIN_OBJECT_ID, Config.ProductionSubscription.SqlAdminsGroup.ObjectId!); + SetGithubVariable(VariableNames.PRODUCTION_DOMAIN_NAME, "-"); + + SetGithubVariable(VariableNames.PRODUCTION_CLUSTER1_ENABLED, "false"); + SetGithubVariable(VariableNames.PRODUCTION_CLUSTER1_LOCATION, Config.ProductionLocation.ClusterLocation); + SetGithubVariable(VariableNames.PRODUCTION_CLUSTER1_LOCATION_ACRONYM, Config.ProductionLocation.ClusterLocationAcronym); + + AnsiConsole.MarkupLine("[green]Successfully created secrets in GitHub.[/]"); + return; + + void SetGithubVariable(VariableNames name, string value) + { + ProcessHelper.StartProcess($"gh variable set {Enum.GetName(name)} -b\"{value}\" --repo={Config.GithubInfo?.Path}"); + } + } + private void DisableReusableWorkflows() { // Disable reusable workflows DisableActiveWorkflow("Deploy Container"); + DisableActiveWorkflow("Plan and Deploy Infrastructure"); return; void DisableActiveWorkflow(string workflowName) @@ -595,7 +733,7 @@ void DisableActiveWorkflow(string workflowName) private void TriggerAndMonitorWorkflows() { - AnsiConsole.Status().Start("Begin d eployment.", ctx => + AnsiConsole.Status().Start("Begin deployment.", ctx => { for (var i = 60; i >= 0; i--) { @@ -604,7 +742,7 @@ private void TriggerAndMonitorWorkflows() break; } - ctx.Status($"Deployment of Cloud Infrastructure and Application code will automatically start in {i} seconds. Press 'ctrl+c` to exit or Enter to continue."); + ctx.Status($"Deployment of Cloud Infrastructure and Application code will automatically start in {i} seconds. Press 'Ctrl+C' to exit or 'Enter' to continue."); Thread.Sleep(TimeSpan.FromSeconds(1)); } } @@ -617,65 +755,79 @@ private void TriggerAndMonitorWorkflows() void StartGithubWorkflow(string workflowName, string workflowFileName) { - AnsiConsole.MarkupLine($"[green]Starting {workflowName} GitHub workflow...[/]"); + try + { + AnsiConsole.MarkupLine($"[green]Starting {workflowName} GitHub workflow...[/]"); - var runWorkflowCommand = $"gh workflow run {workflowFileName} --ref main"; - ProcessHelper.StartProcess(runWorkflowCommand, Configuration.GetSourceCodeFolder(), true); + var runWorkflowCommand = $"gh workflow run {workflowFileName} --ref main"; + ProcessHelper.StartProcess(runWorkflowCommand, Configuration.GetSourceCodeFolder(), true); - // Wait briefly to ensure the run has started - Thread.Sleep(TimeSpan.FromSeconds(15)); + // Wait briefly to ensure the run has started + Thread.Sleep(TimeSpan.FromSeconds(15)); - // Fetch and filter the workflows to find a "running" one - var listWorkflowRunsCommand = $"gh run list --workflow={workflowFileName} --json databaseId,status"; - var workflowsJson = ProcessHelper.StartProcess(listWorkflowRunsCommand, Configuration.GetSourceCodeFolder(), true); + // Fetch and filter the workflows to find a "running" one + var listWorkflowRunsCommand = $"gh run list --workflow={workflowFileName} --json databaseId,status"; + var workflowsJson = ProcessHelper.StartProcess(listWorkflowRunsCommand, Configuration.GetSourceCodeFolder(), true); - long? workflowId = null; - using (var jsonDocument = JsonDocument.Parse(workflowsJson)) - { - foreach (var element in jsonDocument.RootElement.EnumerateArray()) + long? workflowId = null; + using (var jsonDocument = JsonDocument.Parse(workflowsJson)) { - var status = element.GetProperty("status").GetString()!; - workflowId = element.GetProperty("databaseId").GetInt64(); - - if (status.Equals("in_progress", StringComparison.OrdinalIgnoreCase)) + foreach (var element in jsonDocument.RootElement.EnumerateArray()) { - break; + var status = element.GetProperty("status").GetString()!; + + if (status.Equals("in_progress", StringComparison.OrdinalIgnoreCase)) + { + workflowId = element.GetProperty("databaseId").GetInt64(); + break; + } } } - } - if (workflowId is null) + if (workflowId is null) + { + AnsiConsole.MarkupLine("[red]Failed to retrieve a running workflow ID. Please check the GitHub Actions page for more info.[/]"); + Environment.Exit(1); + } + + var watchWorkflowRunCommand = $"gh run watch {workflowId.Value}"; + ProcessHelper.StartProcessWithSystemShell(watchWorkflowRunCommand, Configuration.GetSourceCodeFolder()); + + // Run the command one more time to get the result + var runResult = ProcessHelper.StartProcess(watchWorkflowRunCommand, Configuration.GetSourceCodeFolder(), true); + if (runResult.Contains("completed") && runResult.Contains("success")) return; + + AnsiConsole.MarkupLine($"[red]Error: Failed to run the {workflowName} GitHub workflow.[/]"); + AnsiConsole.MarkupLine($"[red]{runResult}[/]"); + Environment.Exit(1); + } + catch (Exception) { - AnsiConsole.MarkupLine("[red]Failed to retrieve a running workflow ID.[/]"); + AnsiConsole.MarkupLine($"[red]Error: Failed to run the {workflowName} GitHub workflow.[/]"); Environment.Exit(1); } - - var watchWorkflowRunCommand = $"gh run watch {workflowId.Value}"; - ProcessHelper.StartProcessWithSystemShell(watchWorkflowRunCommand, Configuration.GetSourceCodeFolder()); } } - private void ShowSuccessMessage(GithubInfo githubInfo) + private void ShowSuccessMessage() { var setupIntroPrompt = $""" So far so good. The configuration of GitHub and Azure is now complete. Here are some recommendations to further secure and optimize your setup: - - For protecting the [blue]main[/] branch, configure branch protection rules to necessitate pull request reviews before merging can occur. Visit [blue]{githubInfo.GithubUrl}/settings/branches[/], click ""Add Branch protection rule"", and set it up for the [bold]main[/] branch. + - For protecting the [blue]main[/] branch, configure branch protection rules to necessitate pull request reviews before merging can occur. Visit [blue]{Config.GithubInfo?.Url}/settings/branches[/], click ""Add Branch protection rule"", and set it up for the [bold]main[/] branch. Requires a paid GitHub plan for private repositories. - - To add a step for manual approval during infrastructure deployment to the Staging and Production environments, set up required reviewers on GitHub environments. This requires a GitHub Teams or Enterprise Cloud subscription for private repositories. Visit [blue]{githubInfo.GithubUrl}/settings/environments[/] and enable [blue]Required reviewers[/] for the [bold]staging[/] and [bold]production[/] environments. + - To add a step for manual approval during infrastructure deployment to the staging and production environments, set up required reviewers on GitHub environments. Visit [blue]{Config.GithubInfo?.Url}/settings/environments[/] and enable [blue]Required reviewers[/] for the [bold]staging[/] and [bold]production[/] environments. Requires a paid GitHub plan for private repositories. - - Configure the Domain Name for the Staging and Production environments. This involves two steps: + - Configure the Domain Name for the staging and production environments. This involves two steps: - a. Go to [blue]{githubInfo.GithubUrl}/settings/variables/actions[/] to set the [blue]DOMAIN_NAME_STAGING[/] and [blue]DOMAIN_NAME_PRODUCTION[/] variables. E.g. [blue]staging.your-saas-company.com[/] and [blue]your-saas-company.com[/]. + a. Go to [blue]{Config.GithubInfo?.Url}/settings/variables/actions[/] to set the [blue]DOMAIN_NAME_STAGING[/] and [blue]DOMAIN_NAME_PRODUCTION[/] variables. E.g. [blue]staging.your-saas-company.com[/] and [blue]your-saas-company.com[/]. - b. Run the [blue]Cloud Infrastructure - Deployment[/] workflow again. Note that it might fail with an error message to set up a DNS TXT and CNAME record. Once done, rerun the failed jobs. + b. Run the [blue]Cloud Infrastructure - Deployment[/] workflow again. Note that it might fail with an error message to set up a DNS TXT and CNAME record. Once done, re-run the failed jobs. - Set up SonarCloud for code quality and security analysis. This service is free for public repositories. Visit [blue]https://sonarcloud.io[/] to connect your GitHub account. Add the [blue]SONAR_TOKEN[/] secret, and the [blue]SONAR_ORGANIZATION[/] and [blue]SONAR_PROJECT_KEY[/] variables to the GitHub repository. The workflows are already configured for SonarCloud analysis. - - Enable Microsoft Defender for Cloud (also known as Azure Security Center) once the system evolves for added security recommendations. This costs about $10-15 per month per cluster. - - You can rerun this command to update the configuration. Use the [yellow]--skip-azure-login[/] flag to bypass Azure login. + - Enable Microsoft Defender for Cloud (also known as Azure Security Center) once the system evolves for added security recommendations. """; AnsiConsole.MarkupLine($"{setupIntroPrompt}"); @@ -688,19 +840,80 @@ private string RunAzureCliCommand(string arguments, bool redirectOutput = true) return ProcessHelper.StartProcess($"{azureCliCommand} {arguments}", redirectOutput: redirectOutput); } + + private static Dictionary GetAzureLocations() + { + // List of global available regions extracted by running: + // "az account list-locations --query "[?metadata.regionType == 'Physical'].{DisplayName:displayName}" --output table + // Location Acronyms are taken from here https://learn.microsoft.com/en-us/azure/backup/scripts/geo-code-list + return new Dictionary + { + { "Australia Central", "acl" }, + { "Australia Central 2", "acl2" }, + { "Australia East", "ae" }, + { "Australia Southeast", "ase" }, + { "Brazil South", "brs" }, + { "Brazil Southeast", "bse" }, + { "Canada Central", "cnc" }, + { "Canada East", "cne" }, + { "Central India", "inc" }, + { "Central US", "cus" }, + { "East Asia", "ea" }, + { "East US", "eus" }, + { "East US 2", "eus2" }, + { "France Central", "frc" }, + { "France South", "frs" }, + { "Germany North", "gn" }, + { "Germany West Central", "gwc" }, + { "Japan East", "jpe" }, + { "Japan West", "jpw" }, + { "Jio India Central", "jic" }, + { "Jio India West", "jiw" }, + { "Korea Central", "krc" }, + { "Korea South", "krs" }, + { "North Central US", "ncus" }, + { "North Europe", "ne" }, + { "Norway East", "nwe" }, + { "Norway West", "nww" }, + { "South Africa North", "san" }, + { "South Africa West", "saw" }, + { "South Central US", "scus" }, + { "South India", "ins" }, + { "Southeast Asia", "sea" }, + { "Sweden Central", "sdc" }, + { "Switzerland North", "szn" }, + { "Switzerland West", "szw" }, + { "UAE Central", "uac" }, + { "UAE North", "uan" }, + { "UK South", "uks" }, + { "UK West", "ukw" }, + { "West Central US", "wcus" }, + { "West Europe", "we" }, + { "West India", "inw" }, + { "West US", "wus" }, + { "West US 2", "wus2" }, + { "West US 3", "wus3" } + }; + } } -public class GithubInfo +public class Config { - public string? OrganizationName { get; private set; } + public string TenantId => StagingSubscription.TenantId; + + public string UniquePrefix { get; set; } = default!; - public string? RepositoryName { get; private set; } + public GithubInfo? GithubInfo { get; private set; } - public string? Path { get; private set; } + public Subscription StagingSubscription { get; set; } = default!; - public string GithubUrl => $"https://github.com/{OrganizationName}/{RepositoryName}"; + public Location StagingLocation { get; set; } = default!; - public Dictionary Variables { get; } = new(); + public Subscription ProductionSubscription { get; set; } = default!; + + public Location ProductionLocation { get; set; } = default!; + + public Dictionary GithubVariables { get; set; } = new(); public void InitializeFromUri(string gitUri) { @@ -719,10 +932,7 @@ public void InitializeFromUri(string gitUri) } var parts = remote.Split("/"); - OrganizationName = parts[0]; - RepositoryName = parts[1]; - - Path = $"{OrganizationName}/{RepositoryName}"; + GithubInfo = new GithubInfo(parts[0], parts[1]); } public bool IsLoggedIn() @@ -733,37 +943,83 @@ public bool IsLoggedIn() } } -public class AzureInfo +public class GithubInfo(string organizationName, string repositoryName) { - public Subscription Subscription { get; set; } = default!; + public string OrganizationName { get; } = organizationName; - public string AppRegistrationName { get; set; } = default!; + public string RepositoryName { get; } = repositoryName; - public string? AppRegistrationId { get; set; } + public string Path => $"{OrganizationName}/{RepositoryName}"; - public string ServicePrincipalId { get; set; } = default!; + public string Url => $"https://github.com/{Path}"; +} - public string ServicePrincipalObjectId { get; set; } = default!; +public record AzureSubscription(string Id, string Name, string TenantId, string State); - public bool AppRegistrationExists { get; set; } +public class Subscription(string id, string name, string tenantId, GithubInfo githubInfo, string environmentName) +{ + public string Id { get; } = id; - public string SqlAdminsSecurityGroupNickName => $"AzureSQLServerAdmins{UniquePrefix}"; + public string Name { get; } = name; - public string? SqlAdminsSecurityGroupId { get; set; } + public string TenantId { get; } = tenantId; - public bool SqlAdminsSecurityGroupExists { get; set; } + public AppRegistration AppRegistration { get; } = new(githubInfo, environmentName); - public string ProductionDomainName { get; set; } = "-"; + public SqlAdminsGroup SqlAdminsGroup { get; } = new(githubInfo, environmentName); +} - public string StagingDomainName { get; set; } = "-"; +public class AppRegistration(GithubInfo githubInfo, string environmentName) +{ + public string Name => $"GitHub - {githubInfo.OrganizationName}/{githubInfo.RepositoryName} - {environmentName}"; - public string UniquePrefix { get; set; } = default!; + public bool Exists => !string.IsNullOrEmpty(AppRegistrationId); - public string GetSqlAdminsSecurityGroupName(GithubInfo githubInfo) - { - return $"Azure SQL Server Admins - {githubInfo.RepositoryName}"; - } + public string? AppRegistrationId { get; set; } + + public string? ServicePrincipalId { get; set; } + + public string? ServicePrincipalObjectId { get; set; } } -[UsedImplicitly] -public record Subscription(string Id, string Name, string TenantId, string State); +public class SqlAdminsGroup(GithubInfo githubInfo, string enviromentName) +{ + public string Name => $"SQL Admins - {githubInfo.OrganizationName}/{githubInfo.RepositoryName} - {enviromentName}"; + + public string NickName => $"SQLServerAdmins{githubInfo.OrganizationName}{githubInfo.RepositoryName}{enviromentName}"; + + public bool Exists => !string.IsNullOrEmpty(ObjectId); + + public string? ObjectId { get; set; } +} + +public record Location(string SharedLocation, string ClusterLocation, string ClusterLocationAcronym); + +public enum VariableNames +{ + // ReSharper disable InconsistentNaming + TENANT_ID, + UNIQUE_PREFIX, + + STAGING_SUBSCRIPTION_ID, + STAGING_SERVICE_PRINCIPAL_ID, + STAGING_SHARED_LOCATION, + STAGING_SQL_ADMIN_OBJECT_ID, + STAGING_DOMAIN_NAME, + + STAGING_CLUSTER_ENABLED, + STAGING_CLUSTER_LOCATION, + STAGING_CLUSTER_LOCATION_ACRONYM, + + PRODUCTION_SUBSCRIPTION_ID, + PRODUCTION_SERVICE_PRINCIPAL_ID, + PRODUCTION_SHARED_LOCATION, + PRODUCTION_SQL_ADMIN_OBJECT_ID, + PRODUCTION_DOMAIN_NAME, + + PRODUCTION_CLUSTER1_ENABLED, + PRODUCTION_CLUSTER1_LOCATION, + + PRODUCTION_CLUSTER1_LOCATION_ACRONYM + // ReSharper restore InconsistentNaming +}