diff --git a/.azuredevops/README.md b/.azuredevops/README.md new file mode 100644 index 0000000..4110f00 --- /dev/null +++ b/.azuredevops/README.md @@ -0,0 +1,158 @@ +# Bicep deploy + +A [pipeline](ms.azure.deploy.yml) to plan and deploy Azure infrastructure. + +## Overview + +![Flow overview](../images/deploy-flow.azdo.drawio.png) + +1. The user creates a new branch, then commits and push the code. +1. The user creates a pull request. +1. The pipeline is automatically triggered and starts the [plan job](#plan-job). +1. If the plan job was successful, the pipeline will wait for a [required reviewer](#get-started) to approve the [deploy job](#deploy-job). +1. When a reviewer has approved, the pipeline starts the [deploy job](#deploy-job) to deploy the code. + +## Get started + +To use the pipeline, several prerequisite steps are required: + +1. Install the [PSRule](https://marketplace.visualstudio.com/items?itemName=bewhite.ps-rule) Azure DevOps extension. + +1. Create an [environment](https://learn.microsoft.com/en-us/azure/devops/pipelines/process/environments?view=azure-devops). + +1. To prevent unapproved deployments, add the [**"Approvals"**](https://learn.microsoft.com/en-us/azure/devops/pipelines/process/approvals?view=azure-devops&tabs=check-pass#approvals) check to the environment. + +1. Create a [Azure Resource Manager workload identity service connection](https://learn.microsoft.com/en-us/azure/devops/pipelines/release/configure-workload-identity?view=azure-devops). + +1. Assign [Azure roles](https://learn.microsoft.com/en-us/azure/role-based-access-control/role-assignments-steps) to the application so it can deploy Azure infrastructure. For example, give it the **"Owner"** role on the target Azure subscription. + +1. [If needed, create a repo](https://learn.microsoft.com/en-us/azure/devops/repos/git/create-new-repo?view=azure-devops#create-a-repo-using-the-web-portal). + +1. In the [repo settings](https://learn.microsoft.com/en-us/azure/devops/repos/git/set-git-repository-permissions?view=azure-devops#open-security-for-a-repository), ensure the [build service](https://learn.microsoft.com/en-us/azure/devops/pipelines/process/access-tokens?view=azure-devops&tabs=yaml#manage-build-service-account-permissions) has **"Contribute to pull requests"** permission. + +1. Add the [ms.azure.deploy.yml](./ms.azure.deploy.yml) to a repo folder, e.g. **".pipelines"**. + +1. Customize the variable values in the **"ms.azure.deploy.yml"** file and commit the changes. + +1. If needed, add [bicep](https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/) code to the repo. + +1. Add a [ps-rule.yaml](./../ps-rule.yaml) file to the same folder as the main bicep/template file or in the repository root. + +1. Go to the Azure DevOps **Pipelines** page. Then choose the action to create a **New pipeline**. + +1. Select **Azure Repos Git** as the location of the source code. + +1. When the list of repositories appears, select the repository. + +1. Select **Existing Azure Pipelines YAML file** and choose the YAML file: **"/.pipelines/ms.azure.deploy.yml"**. + +1. Save the pipeline without running it. + +1. Configure [branch policies](https://learn.microsoft.com/en-us/azure/devops/repos/git/branch-policies?view=azure-devops&tabs=browser#configure-branch-policies) for the default/main branch. + +1. Add a [build validation branch policy](https://learn.microsoft.com/en-us/azure/devops/repos/git/branch-policies?view=azure-devops&tabs=browser#build-validation). + +## Pipeline + +The pipeline is designed to run when a pull request is created or updated. + +The jobs in this pipeline has been tested on a [standard Microsoft-hosted agent](https://learn.microsoft.com/en-us/azure/devops/pipelines/agents/hosted?view=azure-devops&tabs=yaml#software) with YAML VM Image Label **"ubuntu-22.04"**. + +The following tools are used: + +- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/) + - [az bicep build](https://learn.microsoft.com/en-us/cli/azure/bicep?view=azure-cli-latest#az-bicep-build) + - [az bicep build-params](https://learn.microsoft.com/en-us/cli/azure/bicep?view=azure-cli-latest#az-bicep-build-params) + - [az provider register](https://learn.microsoft.com/en-us/cli/azure/provider?view=azure-cli-latest#az-provider-register) + - [az deployment {SCOPE} create](https://learn.microsoft.com/en-us/cli/azure/deployment/sub?view=azure-cli-latest#az-deployment-sub-create) + - [az deployment {SCOPE} validate](https://learn.microsoft.com/en-us/cli/azure/deployment/sub?view=azure-cli-latest#az-deployment-sub-validate) + - [az deployment {SCOPE} what-if](https://learn.microsoft.com/en-us/cli/azure/deployment/sub?view=azure-cli-latest#az-deployment-sub-what-if) +- [Azure Cost Estimator](https://github.com/TheCloudTheory/arm-estimator) +- [curl](https://curl.se/) +- Azure Pipelines task: + - [AzureCLI@2](https://learn.microsoft.com/en-us/azure/devops/pipelines/tasks/reference/azure-cli-v2?view=azure-pipelines) + - [checkout](https://learn.microsoft.com/en-us/azure/devops/pipelines/yaml-schema/steps-checkout?view=azure-pipelines) + - [ps-rule-assert](https://github.com/microsoft/PSRule-pipelines/blob/main/docs/tasks.md#ps-rule-assert) + - [publish](https://learn.microsoft.com/en-us/azure/devops/pipelines/yaml-schema/steps-publish?view=azure-pipelines) +- [GNU bash](https://www.gnu.org/software/bash/) +- [GNU bc](https://www.gnu.org/software/bc/) +- [GNU core utilities](https://www.gnu.org/software/coreutils/coreutils.html) +- [GNU find utilities](https://www.gnu.org/software/findutils/) +- [jq](https://jqlang.github.io/jq/) +- [sed](https://www.gnu.org/software/sed/) +- [unzip](https://infozip.sourceforge.net/) + +### Plan job + +The plan job will build and test the code. If no issues are found in the code, a [what-if](https://docs.microsoft.com/cli/azure/deployment/sub#az-deployment-sub-what-if) report is generated. + +The PSRule steps will only run if **"rule_option"** is specified and points to a file that exist. + +For more information about PSRule configuration, see: + +- [Sample ps-rule.yaml](../ps-rule.yaml) +- [Configuring options](https://azure.github.io/PSRule.Rules.Azure/setup/configuring-options/) +- [Configuring rule defaults](https://azure.github.io/PSRule.Rules.Azure/setup/configuring-rules/) +- [Available Options](https://microsoft.github.io/PSRule/v2/concepts/PSRule/en-US/about_PSRule_Options/) +- [Available Rules by resource type](https://azure.github.io/PSRule.Rules.Azure/en/rules/resource/) + +### Deploy job + +The deploy job will only run when the plan job was successful. + +It targets a specific [environment](#get-started). + +If the environment is configured with **Approvers**, the job will require manual approval. + +### Variables + +- **AZURE_PROVIDERS**: A comma separated list of Azure resource providers. + + The pipeline create job will try to register the specified providers in addition to the providers that is detected in code by deployment validate. + +- **AZURE_PROVIDER_WAIT_SECONDS**: Seconds to wait between each provider status check. + +- **AZURE_PROVIDER_WAIT_COUNT**: Times to check provider status before giving up. + +- **AZURE_SUBSCRIPTION_ID**: The subscription ID in which to deploy the resources. + +- **COST_THRESHOLD**: Max acceptable estimated cost. Exceeding threshold causes plan to fail. + +- **CURRENCY**: Currency code to use for estimations. See allowed values at + +- **ENVIRONMENT**: Name of the [environment](#get-started) to use for the [deploy job](#deploy-job). + +- **LOCATION**: The Azure location to store the deployment metadata. + +- **LOG_SEVERITY**: The log verbosity. Can be one of: + + - ERROR - Only show errors, suppressing warnings. + - INFO - Standard log level. + - VERBOSE - Increase logging verbosity. + - DEBUG - Show all debug logs. + +- **MANAGEMENT_GROUP**: Management group to create deployment at for mg scope. + +- **RESOURCE_GROUP**: Resource group to create deployment at for group scope. + +- **RULE_BASELINE**: The name of a PSRule baseline to use. For a list of baseline names for module PSRule.Rules.Azure see + +- **RULE_MODULES**: A comma separated list of modules to use for analysis. For a list of modules see + +- **RULE_OPTION**: The path to an options file. + +- **SCOPE**: The deployment scope. Accepted: tenant, mg, sub, group. + +- **SERVICE_CONNECTION**: The Azure Resource Manager service connection name. + +- **TEMPLATE**: The template address. A path or URI to a file or a template spec resource id. + +- **TEMPLATE_PARAMETERS**: Deployment parameter values. Either a path, URI, JSON string, or `` pairs. + +- **VERSION_ACE_TOOL**: Azure Cost Estimator version. The version to use for cost estimation. See versions at + +- **WORKFLOW_VERSION**: The version of the bicep-action scripts to use. See . + +## License + +The code and documentation in this project are released under the [BSD 3-Clause License](../LICENSE). diff --git a/.azuredevops/ms.azure.deploy.yml b/.azuredevops/ms.azure.deploy.yml new file mode 100644 index 0000000..34cba45 --- /dev/null +++ b/.azuredevops/ms.azure.deploy.yml @@ -0,0 +1,549 @@ +trigger: none +pr: + autoCancel: true + drafts: false + +name: Azure Deploy + +variables: + AZURE_PROVIDERS: Microsoft.Advisor,Microsoft.AlertsManagement,Microsoft.Authorization,Microsoft.Consumption,Microsoft.EventGrid,microsoft.insights,Microsoft.ManagedIdentity,Microsoft.Management,Microsoft.Network,Microsoft.PolicyInsights,Microsoft.ResourceHealth,Microsoft.Resources,Microsoft.Security + AZURE_PROVIDER_WAIT_SECONDS: 10 + AZURE_PROVIDER_WAIT_COUNT: 30 + AZURE_SUBSCRIPTION_ID: d0d0d0d0-ed29-4694-ac26-2e358c364506 + COST_THRESHOLD: -1 + CURRENCY: EUR + ENVIRONMENT: production + LOCATION: westeurope + LOG_SEVERITY: INFO + MANAGEMENT_GROUP: + RESOURCE_GROUP: + RULE_BASELINE: Azure.Default + RULE_MODULES: Az.Resources,PSRule.Rules.Azure + RULE_OPTION: ps-rule.yaml + SCOPE: sub + SERVICE_CONNECTION: ifsandboxvdc01_arm_connection + TEMPLATE: main.bicep + TEMPLATE_PARAMETERS: main.bicepparam + VERSION_ACE_TOOL: 1.4 + WORKFLOW_VERSION: v1 + +pool: + vmImage: ubuntu-22.04 + +stages: + - stage: Plan + jobs: + - job: plan + displayName: Plan deployment + steps: + - checkout: self + displayName: Checkout + fetchDepth: 1 + persistCredentials: true + + - task: AzureCLI@2 + displayName: Install tools + env: + IN_SEVERITY: ${{ variables.LOG_SEVERITY }} + IN_TEMPLATE: ${{ variables.TEMPLATE }} + LOG_NAME: install_tools + LOG_ORDER: b1 + LOG_PATH: $(Pipeline.Workspace)/logs + SCRIPT: install-tools + VERSION: ${{ variables.WORKFLOW_VERSION }} + inputs: + azureSubscription: ${{ variables.SERVICE_CONNECTION }} + scriptLocation: inlineScript + scriptType: bash + inlineScript: | + uri="https://github.com/innofactororg/bicep-action/raw/${VERSION}/scripts/${SCRIPT}.sh" + HTTP_CODE=$(curl -sSL --remote-name --retry 4 \ + --write-out "%{response_code}" \ + --header 'Accept: application/vnd.github.raw' "${uri}" + ) + if [[ "${HTTP_CODE}" -lt 200 || "${HTTP_CODE}" -gt 299 ]]; then + echo "##[error]Unable to get ${uri}! Response code: ${HTTP_CODE}" + exit 1 + fi + chmod +x ${SCRIPT}.sh + ./${SCRIPT}.sh + + - task: AzureCLI@2 + condition: and(succeeded(), not(startsWith(variables['TEMPLATE'], '/subscriptions/'))) + displayName: Bicep build + env: + IN_SEVERITY: ${{ variables.LOG_SEVERITY }} + IN_TEMPLATE: ${{ variables.TEMPLATE }} + LOG_NAME: bicep_build + LOG_ORDER: b3 + LOG_PATH: $(Pipeline.Workspace)/logs + SCRIPT: az-bicep + SOURCE_PATH: $(Build.SourcesDirectory) + VERSION: ${{ variables.WORKFLOW_VERSION }} + name: bicep_build + inputs: + azureSubscription: ${{ variables.SERVICE_CONNECTION }} + scriptLocation: inlineScript + scriptType: bash + inlineScript: | + uri="https://github.com/innofactororg/bicep-action/raw/${VERSION}/scripts/${SCRIPT}.sh" + HTTP_CODE=$(curl -sSL --remote-name --retry 4 \ + --write-out "%{response_code}" \ + --header 'Accept: application/vnd.github.raw' "${uri}" + ) + if [[ "${HTTP_CODE}" -lt 200 || "${HTTP_CODE}" -gt 299 ]]; then + echo "##[error]Unable to get ${uri}! Response code: ${HTTP_CODE}" + exit 1 + fi + chmod +x ${SCRIPT}.sh + ./${SCRIPT}.sh build + + - task: AzureCLI@2 + condition: and(succeeded(), not(eq(variables['TEMPLATE_PARAMETERS'], ''))) + displayName: Bicep build params + env: + IN_SEVERITY: ${{ variables.LOG_SEVERITY }} + IN_TEMPLATE: ${{ variables.TEMPLATE_PARAMETERS }} + LOG_NAME: bicep_build_params + LOG_ORDER: b4 + LOG_PATH: $(Pipeline.Workspace)/logs + SCRIPT: az-bicep + SOURCE_PATH: $(Build.SourcesDirectory) + VERSION: ${{ variables.WORKFLOW_VERSION }} + name: bicep_build_params + inputs: + azureSubscription: ${{ variables.SERVICE_CONNECTION }} + scriptType: bash + scriptLocation: inlineScript + inlineScript: | + uri="https://github.com/innofactororg/bicep-action/raw/${VERSION}/scripts/${SCRIPT}.sh" + HTTP_CODE=$(curl -sSL --remote-name --retry 4 \ + --write-out "%{response_code}" \ + --header 'Accept: application/vnd.github.raw' "${uri}" + ) + if [[ "${HTTP_CODE}" -lt 200 || "${HTTP_CODE}" -gt 299 ]]; then + echo "##[error]Unable to get ${uri}! Response code: ${HTTP_CODE}" + exit 1 + fi + chmod +x ${SCRIPT}.sh + ./${SCRIPT}.sh build-params + + - task: AzureCLI@2 + displayName: Validate + env: + IN_LOCATION: ${{ variables.LOCATION }} + IN_MANAGEMENT_GROUP: ${{ variables.MANAGEMENT_GROUP }} + IN_PROVIDERS: ${{ variables.AZURE_PROVIDERS }} + IN_RESOURCE_GROUP: ${{ variables.RESOURCE_GROUP }} + IN_SCOPE: ${{ variables.SCOPE }} + IN_SEVERITY: ${{ variables.LOG_SEVERITY }} + IN_TEMPLATE: ${{ variables.TEMPLATE }} + IN_TEMPLATE_PARAMS: ${{ variables.TEMPLATE_PARAMETERS }} + LOG_NAME: validate + LOG_ORDER: b5 + LOG_PATH: $(Pipeline.Workspace)/logs + RUN_ID: $(Build.BuildId) + SCRIPT: az-deploy + SOURCE_PATH: $(Build.SourcesDirectory) + SUBSCRIPTION_ID: ${{ variables.AZURE_SUBSCRIPTION_ID }} + VERSION: ${{ variables.WORKFLOW_VERSION }} + name: validate + inputs: + azureSubscription: ${{ variables.SERVICE_CONNECTION }} + scriptType: bash + scriptLocation: inlineScript + inlineScript: | + uri="https://github.com/innofactororg/bicep-action/raw/${VERSION}/scripts/${SCRIPT}.sh" + HTTP_CODE=$(curl -sSL --remote-name --retry 4 \ + --write-out "%{response_code}" \ + --header 'Accept: application/vnd.github.raw' "${uri}" + ) + if [[ "${HTTP_CODE}" -lt 200 || "${HTTP_CODE}" -gt 299 ]]; then + echo "##[error]Unable to get ${uri}! Response code: ${HTTP_CODE}" + exit 1 + fi + chmod +x ${SCRIPT}.sh + ./${SCRIPT}.sh validate + + - task: AzureCLI@2 + displayName: What-if + env: + IN_LOCATION: ${{ variables.LOCATION }} + IN_MANAGEMENT_GROUP: ${{ variables.MANAGEMENT_GROUP }} + IN_RESOURCE_GROUP: ${{ variables.RESOURCE_GROUP }} + IN_SCOPE: ${{ variables.SCOPE }} + IN_SEVERITY: ${{ variables.LOG_SEVERITY }} + IN_TEMPLATE: ${{ variables.TEMPLATE }} + IN_TEMPLATE_PARAMS: ${{ variables.TEMPLATE_PARAMETERS }} + LOG_NAME: what-if + LOG_ORDER: a1 + LOG_PATH: $(Pipeline.Workspace)/logs + RUN_ID: $(Build.BuildId) + SCRIPT: az-deploy + SOURCE_PATH: $(Build.SourcesDirectory) + SUBSCRIPTION_ID: ${{ variables.AZURE_SUBSCRIPTION_ID }} + VERSION: ${{ variables.WORKFLOW_VERSION }} + inputs: + azureSubscription: ${{ variables.SERVICE_CONNECTION }} + scriptType: bash + scriptLocation: inlineScript + inlineScript: | + uri="https://github.com/innofactororg/bicep-action/raw/${VERSION}/scripts/${SCRIPT}.sh" + HTTP_CODE=$(curl -sSL --remote-name --retry 4 \ + --write-out "%{response_code}" \ + --header 'Accept: application/vnd.github.raw' "${uri}" + ) + if [[ "${HTTP_CODE}" -lt 200 || "${HTTP_CODE}" -gt 299 ]]; then + echo "##[error]Unable to get ${uri}! Response code: ${HTTP_CODE}" + exit 1 + fi + chmod +x ${SCRIPT}.sh + ./${SCRIPT}.sh what-if + + - task: Bash@3 + condition: and(succeeded(), not(eq(variables['RULE_OPTION'], ''))) + displayName: PSRule config + env: + LOG_NAME: psrule_config + LOG_PATH: $(Pipeline.Workspace)/logs + OPTION: ${{ variables.RULE_OPTION }} + SCRIPT: psrule-config + TEMPLATE_FILE: $(bicep_build.file) + TEMPLATE_PARAMS_FILE: $(bicep_build_params.file) + VERSION: ${{ variables.WORKFLOW_VERSION }} + inputs: + targetType: inline + script: | + if [[ "${TEMPLATE_FILE}" == *bicep_build.file* ]]; then + echo 'The bicep_build.file output has not been set!' + export TEMPLATE_FILE='' + fi + if [[ "${TEMPLATE_PARAMS_FILE}" == *bicep_build_params.file* ]]; then + echo 'The bicep_build_params.file output has not been set!' + export TEMPLATE_PARAMS_FILE='' + fi + uri="https://github.com/innofactororg/bicep-action/raw/${VERSION}/scripts/${SCRIPT}.sh" + HTTP_CODE=$(curl -sSL --remote-name --retry 4 \ + --write-out "%{response_code}" \ + --header 'Accept: application/vnd.github.raw' "${uri}" + ) + if [[ "${HTTP_CODE}" -lt 200 || "${HTTP_CODE}" -gt 299 ]]; then + echo "##[error]Unable to get ${uri}! Response code: ${HTTP_CODE}" + exit 1 + fi + chmod +x ${SCRIPT}.sh + ./${SCRIPT}.sh + + - task: ps-rule-assert@2 + condition: and(succeeded(), not(eq(variables['RULE_OPTION'], '')), eq(variables['psrule_config.error'], '')) + displayName: PSRule analysis + inputs: + baseline: ${{ variables.RULE_BASELINE }} + inputPath: $(Build.SourcesDirectory) + inputType: repository + modules: ${{ variables.RULE_MODULES }} + option: ${{ variables.RULE_OPTION }} + outputFormat: Markdown + outputPath: $(Pipeline.Workspace)/logs/psrule_analysis.md + summary: false + + - task: Bash@3 + condition: and(not(eq(variables['RULE_OPTION'], '')), or(succeeded(), failed())) + displayName: PSRule report + env: + CONFIG_ERROR: $(psrule_config.error) + LOG_NAME: psrule_report + LOG_ORDER: b6 + LOG_PATH: $(Pipeline.Workspace)/logs + SCRIPT: psrule-report + VERSION: ${{ variables.WORKFLOW_VERSION }} + inputs: + targetType: inline + script: | + if [[ "${CONFIG_ERROR}" == *psrule_config.error* ]]; then + echo 'The psrule_config.error output has not been set!' + export CONFIG_ERROR='' + fi + uri="https://github.com/innofactororg/bicep-action/raw/${VERSION}/scripts/${SCRIPT}.sh" + HTTP_CODE=$(curl -sSL --remote-name --retry 4 \ + --write-out "%{response_code}" \ + --header 'Accept: application/vnd.github.raw' "${uri}" + ) + if [[ "${HTTP_CODE}" -lt 200 || "${HTTP_CODE}" -gt 299 ]]; then + echo "##[error]Unable to get ${uri}! Response code: ${HTTP_CODE}" + exit 1 + fi + chmod +x ${SCRIPT}.sh + ./${SCRIPT}.sh + + - task: AzureCLI@2 + condition: and(succeeded(), not(eq(variables['bicep_build.file'], ''))) + displayName: Cost estimate + env: + IN_CURRENCY: ${{ variables.CURRENCY }} + IN_LOCATION: ${{ variables.LOCATION }} + IN_MANAGEMENT_GROUP: ${{ variables.MANAGEMENT_GROUP }} + IN_RESOURCE_GROUP: ${{ variables.RESOURCE_GROUP }} + IN_SCOPE: ${{ variables.SCOPE }} + IN_TEMPLATE_PARAMS: ${{ variables.TEMPLATE_PARAMETERS }} + LOG_NAME: cost_estimate + LOG_ORDER: a2 + LOG_PATH: $(Pipeline.Workspace)/logs + SCRIPT: azure-cost + SUBSCRIPTION_ID: ${{ variables.AZURE_SUBSCRIPTION_ID }} + TEMPLATE_FILE: $(bicep_build.file) + TEMPLATE_PARAMS_FILE: $(bicep_build_params.file) + THRESHOLD: ${{ variables.COST_THRESHOLD }} + VERSION: ${{ variables.WORKFLOW_VERSION }} + VERSION_ACE: ${{ variables.VERSION_ACE_TOOL }} + inputs: + azureSubscription: ${{ variables.SERVICE_CONNECTION }} + scriptLocation: inlineScript + scriptType: bash + inlineScript: | + if [[ "${TEMPLATE_FILE}" == *bicep_build.file* ]]; then + echo '##[error]The bicep_build.file output has not been set!' + exit 1 + fi + if [[ "${TEMPLATE_PARAMS_FILE}" == *bicep_build_params.file* ]]; then + echo 'The bicep_build_params.file output has not been set!' + export TEMPLATE_PARAMS_FILE='' + fi + uri="https://github.com/innofactororg/bicep-action/raw/${VERSION}/scripts/${SCRIPT}.sh" + HTTP_CODE=$(curl -sSL --remote-name --retry 4 \ + --write-out "%{response_code}" \ + --header 'Accept: application/vnd.github.raw' "${uri}" + ) + if [[ "${HTTP_CODE}" -lt 200 || "${HTTP_CODE}" -gt 299 ]]; then + echo "##[error]Unable to get ${uri}! Response code: ${HTTP_CODE}" + exit 1 + fi + chmod +x ${SCRIPT}.sh + ./${SCRIPT}.sh + + - task: Bash@3 + condition: and(always(), eq(variables['Build.Reason'], 'PullRequest')) + displayName: Add comment + env: + COMMENTS_URL: $(System.CollectionUri)$(System.TeamProject)/_apis/git/repositories/$(Build.Repository.Name)/pullRequests/$(System.PullRequest.PullRequestId)/threads?api-version=7.1-preview.1 + COMMIT_SHA: $(System.PullRequest.SourceCommitId) + COMMIT_URL: $(System.CollectionUri)$(System.TeamProject)/_git/$(Build.Repository.Name)/commit/$(System.PullRequest.SourceCommitId) + EVENT_ACTION: $(Build.Reason) + EVENT_ACTOR: $(Build.SourceVersionAuthor) + EVENT_NO: "[$(System.PullRequest.PullRequestId)]($(Build.BuildUri))" + JOB_NAME: $(Build.BuildNumber) + JOB_STATUS: $(Agent.JobStatus) + JOB_URL: $(System.CollectionUri)$(System.TeamProject)/_build/results?buildId=$(Build.BuildId) + LOG_NAME: plan_comment + LOG_PATH: $(Pipeline.Workspace)/logs + RUN_NUMBER: $(Build.BuildId) + SCRIPT: pr-comment + TOKEN: $(System.AccessToken) + VERSION: ${{ variables.WORKFLOW_VERSION }} + inputs: + targetType: inline + script: | + export COMMIT_SHA="[${COMMIT_SHA:0:8}](${COMMIT_URL})" + uri="https://github.com/innofactororg/bicep-action/raw/${VERSION}/scripts/${SCRIPT}.sh" + HTTP_CODE=$(curl -sSL --remote-name --retry 4 \ + --write-out "%{response_code}" \ + --header 'Accept: application/vnd.github.raw' "${uri}" + ) + if [[ "${HTTP_CODE}" -lt 200 || "${HTTP_CODE}" -gt 299 ]]; then + echo "##[error]Unable to get ${uri}! Response code: ${HTTP_CODE}" + exit 1 + fi + chmod +x ${SCRIPT}.sh + ./${SCRIPT}.sh + + - task: Bash@3 + condition: or(failed(), and(succeeded(), not(eq(variables['LOG_SEVERITY'], 'ERROR')))) + displayName: Show debug info + inputs: + targetType: inline + script: | + set -e + echo '##[group]environment variables' + for var in $(compgen -e); do + echo "${var} ${!var}" + done + echo '##[endgroup]' + + - task: PublishPipelineArtifact@1 + condition: always() + displayName: Upload logs + inputs: + artifact: plan_logs + publishLocation: pipeline + targetPath: $(Pipeline.Workspace)/logs + + - stage: Deploy + dependsOn: Plan + jobs: + - deployment: create + displayName: Create deployment + environment: ${{ variables.ENVIRONMENT }} + variables: + plan_providers: $[ stageDependencies.Plan.plan.outputs['validate.providers'] ] + strategy: + runOnce: + deploy: + steps: + - download: none + - checkout: self + displayName: Checkout + fetchDepth: 1 + persistCredentials: true + + - task: AzureCLI@2 + displayName: Install tools + env: + IN_SEVERITY: ${{ variables.LOG_SEVERITY }} + IN_TEMPLATE: ${{ variables.TEMPLATE }} + LOG_NAME: install_tools + LOG_ORDER: b1 + LOG_PATH: $(Pipeline.Workspace)/logs + SCRIPT: install-tools + VERSION: ${{ variables.WORKFLOW_VERSION }} + inputs: + azureSubscription: ${{ variables.SERVICE_CONNECTION }} + scriptLocation: inlineScript + scriptType: bash + inlineScript: | + uri="https://github.com/innofactororg/bicep-action/raw/${VERSION}/scripts/${SCRIPT}.sh" + HTTP_CODE=$(curl -sSL --remote-name --retry 4 \ + --write-out "%{response_code}" \ + --header 'Accept: application/vnd.github.raw' "${uri}" + ) + if [[ "${HTTP_CODE}" -lt 200 || "${HTTP_CODE}" -gt 299 ]]; then + echo "##[error]Unable to get ${uri}! Response code: ${HTTP_CODE}" + exit 1 + fi + chmod +x ${SCRIPT}.sh + ./${SCRIPT}.sh + + - task: AzureCLI@2 + condition: and(succeeded(), not(eq(variables['plan_providers'], ''))) + displayName: Check Azure providers + env: + IN_PROVIDERS: $(plan_providers) + IN_SEVERITY: ${{ variables.LOG_SEVERITY }} + LOG_NAME: check_providers + LOG_ORDER: b3 + LOG_PATH: $(Pipeline.Workspace)/logs + SCRIPT: azure-providers + SUBSCRIPTION_ID: ${{ variables.AZURE_SUBSCRIPTION_ID }} + VERSION: ${{ variables.WORKFLOW_VERSION }} + WAIT_COUNT: ${{ variables.AZURE_PROVIDER_WAIT_COUNT }} + WAIT_SECONDS: ${{ variables.AZURE_PROVIDER_WAIT_SECONDS }} + inputs: + azureSubscription: ${{ variables.SERVICE_CONNECTION }} + scriptType: bash + scriptLocation: inlineScript + inlineScript: | + if [[ "${IN_PROVIDERS}" == *plan_providers* ]]; then + echo 'Skip! The plan.validate.providers output has not been set!' + exit + fi + uri="https://github.com/innofactororg/bicep-action/raw/${VERSION}/scripts/${SCRIPT}.sh" + HTTP_CODE=$(curl -sSL --remote-name --retry 4 \ + --write-out "%{response_code}" \ + --header 'Accept: application/vnd.github.raw' "${uri}" + ) + if [[ "${HTTP_CODE}" -lt 200 || "${HTTP_CODE}" -gt 299 ]]; then + echo "##[error]Unable to get ${uri}! Response code: ${HTTP_CODE}" + exit 1 + fi + chmod +x ${SCRIPT}.sh + ./${SCRIPT}.sh + + - task: AzureCLI@2 + displayName: Deploy + env: + IN_LOCATION: ${{ variables.LOCATION }} + IN_MANAGEMENT_GROUP: ${{ variables.MANAGEMENT_GROUP }} + IN_RESOURCE_GROUP: ${{ variables.RESOURCE_GROUP }} + IN_SCOPE: ${{ variables.SCOPE }} + IN_SEVERITY: ${{ variables.LOG_SEVERITY }} + IN_TEMPLATE: ${{ variables.TEMPLATE }} + IN_TEMPLATE_PARAMS: ${{ variables.TEMPLATE_PARAMETERS }} + LOG_NAME: deploy + LOG_ORDER: a1 + LOG_PATH: $(Pipeline.Workspace)/logs + RUN_ID: $(Build.BuildId) + SCRIPT: az-deploy + SOURCE_PATH: $(Build.SourcesDirectory) + SUBSCRIPTION_ID: ${{ variables.AZURE_SUBSCRIPTION_ID }} + VERSION: ${{ variables.WORKFLOW_VERSION }} + inputs: + azureSubscription: ${{ variables.SERVICE_CONNECTION }} + scriptType: bash + scriptLocation: inlineScript + inlineScript: | + uri="https://github.com/innofactororg/bicep-action/raw/${VERSION}/scripts/${SCRIPT}.sh" + HTTP_CODE=$(curl -sSL --remote-name --retry 4 \ + --write-out "%{response_code}" \ + --header 'Accept: application/vnd.github.raw' "${uri}" + ) + if [[ "${HTTP_CODE}" -lt 200 || "${HTTP_CODE}" -gt 299 ]]; then + echo "##[error]Unable to get ${uri}! Response code: ${HTTP_CODE}" + exit 1 + fi + chmod +x ${SCRIPT}.sh + ./${SCRIPT}.sh create + + - task: Bash@3 + condition: and(always(), eq(variables['Build.Reason'], 'PullRequest')) + displayName: Add comment + env: + COMMENTS_URL: $(System.CollectionUri)$(System.TeamProject)/_apis/git/repositories/$(Build.Repository.Name)/pullRequests/$(System.PullRequest.PullRequestId)/threads?api-version=7.1-preview.1 + COMMIT_SHA: $(System.PullRequest.SourceCommitId) + COMMIT_URL: $(System.CollectionUri)$(System.TeamProject)/_git/$(Build.Repository.Name)/commit/$(System.PullRequest.SourceCommitId) + EVENT_ACTION: $(Build.Reason) + EVENT_ACTOR: $(Build.SourceVersionAuthor) + EVENT_NO: "[$(System.PullRequest.PullRequestId)]($(Build.BuildUri))" + JOB_NAME: $(Build.BuildNumber) + JOB_STATUS: $(Agent.JobStatus) + JOB_URL: $(System.CollectionUri)$(System.TeamProject)/_build/results?buildId=$(Build.BuildId) + LOG_NAME: deploy_comment + LOG_PATH: $(Pipeline.Workspace)/logs + RUN_NUMBER: $(Build.BuildId) + SCRIPT: pr-comment + TOKEN: $(System.AccessToken) + VERSION: ${{ variables.WORKFLOW_VERSION }} + inputs: + targetType: inline + script: | + export COMMIT_SHA="[${COMMIT_SHA:0:8}](${COMMIT_URL})" + uri="https://github.com/innofactororg/bicep-action/raw/${VERSION}/scripts/${SCRIPT}.sh" + HTTP_CODE=$(curl -sSL --remote-name --retry 4 \ + --write-out "%{response_code}" \ + --header 'Accept: application/vnd.github.raw' "${uri}" + ) + if [[ "${HTTP_CODE}" -lt 200 || "${HTTP_CODE}" -gt 299 ]]; then + echo "##[error]Unable to get ${uri}! Response code: ${HTTP_CODE}" + exit 1 + fi + chmod +x ${SCRIPT}.sh + ./${SCRIPT}.sh + + - task: Bash@3 + condition: or(failed(), and(succeeded(), not(eq(variables['LOG_SEVERITY'], 'ERROR')))) + displayName: Show debug info + inputs: + targetType: inline + script: | + set -e + echo '##[group]environment variables' + for var in $(compgen -e); do + echo "${var} ${!var}" + done + echo '##[endgroup]' + + - task: PublishPipelineArtifact@1 + displayName: Upload logs + condition: always() + inputs: + targetPath: $(Pipeline.Workspace)/logs + artifact: deploy_logs + publishLocation: pipeline diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..22a75e4 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @DamianFlynn @reijoh diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..5ccc8c1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,55 @@ +--- +name: Bug +description: File a bug report +title: "[Bug]: " +labels: [bug] +body: + - type: markdown + attributes: + value: | + Before opening a bug report, please search the existing issues. + + --- + + Thank you for taking the time to file a bug report. + To address this bug as fast as possible, we need some information. + + --- + - type: input + id: os + attributes: + label: Operating system + description: Which operating system do you use? Please include version. + placeholder: "macOS Big Sur 11.5.2" + validations: + required: true + - type: input + id: bicep + attributes: + label: Bicep version + description: Please provide the full bicep version that you are using. + placeholder: "Bicep CLI version 0.23.1 (b02de2da48)" + validations: + required: true + - type: textarea + id: bug-description + attributes: + label: Bug description + description: What happened? + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps to reproduce + description: Which steps do we need to take to reproduce this error? + validations: + required: false + - type: textarea + id: logs + attributes: + label: Relevant log output + description: If applicable, provide relevant log output. + render: Shell + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..3ba13e0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml new file mode 100644 index 0000000..24eef2a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -0,0 +1,39 @@ +--- +name: Feature Request +description: Suggest an idea for this project +title: "[Feature]: " +labels: [enhancement] + +body: + - type: markdown + attributes: + value: | + Before opening a feature report, please search the existing issues. + + --- + + Thank you for taking the time to file a feature suggestion. + To process this as fast as possible, we need some information. + + --- + - type: textarea + id: feat-description + attributes: + label: Describe the solution you'd like + description: A clear and concise description of what you want to happen. + validations: + required: true + - type: textarea + id: feat-references + attributes: + label: Relevant examples or references + description: Miscellaneous information that will assist in solving the issue. + validations: + required: true + - type: textarea + id: feat-additional + attributes: + label: Additional information + description: Anything to give further context to the requested new feature. + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml new file mode 100644 index 0000000..0247cdf --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.yml @@ -0,0 +1,24 @@ +--- +name: Question +description: Ask a question to the maintainer +title: "[Question]: " +labels: [question] +body: + - type: markdown + attributes: + value: | + Before opening a question, please search the existing issues. + + --- + + Thank you for taking the time to file a query. + To process this as fast as possible, we need some information. + + --- + - type: textarea + id: question-description + attributes: + label: Your Query + description: A clear and concise query of what you want to ask. + validations: + required: true diff --git a/.github/actions/deploy/action.yml b/.github/actions/deploy/action.yml new file mode 100644 index 0000000..8819286 --- /dev/null +++ b/.github/actions/deploy/action.yml @@ -0,0 +1,236 @@ +# Copyright (c) Innofactor Plc & AUTHORS +# SPDX-License-Identifier: BSD-3-Clause +# +name: Create deployment +author: Innofactor Plc +description: Deploy Azure infrastructure. +branding: + icon: edit + color: blue +inputs: + auto_merge: + description: "Auto merge method to use after successful deployment." + required: false + default: "squash" + azure_client_id: + description: "The client ID of the service principal for Azure login." + required: true + azure_client_secret: + description: "The client secret of the service principal for Azure login." + required: false + default: "" + azure_providers: + description: "A comma separated list of Azure resource providers." + required: false + default: "" + azure_provider_wait_count: + description: "Times to check provider status before giving up." + required: false + default: 30 + azure_provider_wait_seconds: + description: "Seconds to wait between each provider status check." + required: false + default: 10 + azure_subscription_id: + description: "The subscription ID in which to deploy the resources." + required: true + azure_tenant_id: + description: "The tenant ID in which the subscription exists." + required: true + location: + description: "The Azure location to store the deployment metadata." + required: true + log_severity: + description: "The log verbosity." + required: false + default: "ERROR" + management_group: + description: "Management group to create deployment at for mg scope." + required: false + default: "" + resource_group: + description: "Resource group to create deployment at for group scope." + required: false + default: "" + scope: + description: "The deployment scope. Accepted: tenant, mg, sub, group." + required: true + template: + description: "The template address." + required: true + template_parameters: + description: "Deployment parameter values." + required: false + default: "" + token: + description: "GitHub token used to comment on pull request and to enable auto-merge." + required: false + default: ${{ github.token }} +runs: + using: composite + steps: + - name: Add scripts to system path + shell: bash + run: | + folder=$(readlink -f "${GITHUB_ACTION_PATH}/../../../scripts") + echo "Script folder: ${folder}" + echo "${folder}" >> "${GITHUB_PATH}" + + - name: Install tools + env: + IN_SEVERITY: ${{ inputs.log_severity }} + IN_TEMPLATE: ${{ inputs.template }} + LOG_NAME: install_tools + LOG_ORDER: b1 + LOG_PATH: ${{ runner.workspace }}/logs + shell: bash + run: install-tools.sh + + - name: Azure login + env: + IN_SEVERITY: ${{ inputs.log_severity }} + CLIENT_ID: ${{ inputs.azure_client_id }} + CLIENT_SECRET: ${{ inputs.azure_client_secret }} + LOG_NAME: azure_login + LOG_ORDER: b2 + LOG_PATH: ${{ runner.workspace }}/logs + SUBSCRIPTION_ID: ${{ inputs.azure_subscription_id }} + TENANT_ID: ${{ inputs.azure_tenant_id }} + shell: bash + run: az-login.sh + + - name: Check resource providers + if: inputs.azure_providers != '' + env: + IN_PROVIDERS: ${{ inputs.azure_providers }} + IN_SEVERITY: ${{ inputs.log_severity }} + LOG_NAME: check_providers + LOG_ORDER: b3 + LOG_PATH: ${{ runner.workspace }}/logs + WAIT_COUNT: ${{ inputs.azure_provider_wait_count }} + WAIT_SECONDS: ${{ inputs.azure_provider_wait_seconds }} + shell: bash + run: azure-providers.sh + + - name: Create deployment + env: + IN_LOCATION: ${{ inputs.location }} + IN_MANAGEMENT_GROUP: ${{ inputs.management_group }} + IN_RESOURCE_GROUP: ${{ inputs.resource_group }} + IN_SCOPE: ${{ inputs.scope }} + IN_SEVERITY: ${{ inputs.log_severity }} + IN_TEMPLATE: ${{ inputs.template }} + IN_TEMPLATE_PARAMS: ${{ inputs.template_parameters }} + LOG_NAME: create_deployment + LOG_ORDER: a1 + LOG_PATH: ${{ runner.workspace }}/logs + RUN_ID: ${{ github.run_id }} + SOURCE_PATH: ${{ github.workspace }} + shell: bash + run: az-deploy.sh create + + - name: Enable auto merge + if: > + ( + github.event_name == 'pull_request' || + github.event_name == 'pull_request_target' + ) && + contains( + fromJSON('["merge", "squash", "rebase"]'), inputs.auto_merge + ) + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea #v7.0.1 + env: + METHOD: ${{ inputs.auto_merge }} + with: + github-token: ${{ inputs.token }} + script: | + const {METHOD} = process.env; + try { + const response = await github.graphql(` + mutation { + enablePullRequestAutoMerge(input: { + pullRequestId: "${context.payload.pull_request.node_id}", + mergeMethod: ${METHOD.toUpperCase()}, + }) { + pullRequest { + autoMergeRequest { + enabledAt + } + } + } + } + `); + core.info('Auto-merge enabled!'); + } catch (e) { + const msg = `Failed to enable auto-merge! ${e}`; + core.info(msg); + core.setFailed(msg); + } + + - name: Job cancelled + if: cancelled() + shell: bash + run: echo 'JOB_STATUS=cancelled' >> "$GITHUB_ENV" + + - name: Job failed + if: failure() + shell: bash + run: echo 'JOB_STATUS=failed' >> "$GITHUB_ENV" + + - name: Add comment + if: > + always() && + ( + github.event_name == 'pull_request' || + github.event_name == 'pull_request_target' + ) + env: + COMMENTS_URL: ${{ github.event.pull_request.comments_url }} + COMMIT_SHA: ${{ github.sha }} + EVENT_ACTION: ${{ github.event.action }} + EVENT_ACTOR: ${{ github.actor }} + EVENT_NO: "#${{ github.event.number }}" + JOB_NAME: ${{ github.workflow }} + JOB_STATUS: ${{ env.JOB_STATUS }} + JOB_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + LOG_NAME: deploy_comment + LOG_PATH: ${{ runner.workspace }}/logs + RUN_NUMBER: ${{ github.run_number }} + TOKEN: ${{ inputs.token }} + shell: bash + run: pr-comment.sh + + - name: Show debug info + if: > + ( + success() && + inputs.log_severity != 'ERROR' + ) || failure() + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea #v7.0.1 + with: + github-token: ${{ inputs.token }} + script: | + const fs = require('fs'); + const event = JSON.parse(fs.readFileSync(process.env['GITHUB_EVENT_PATH'])); + console.log('::group::environment variables'); + console.log('::stop-commands::77e6a57ef9854574'); + for (const [key, value] of Object.entries(process.env).sort()) { + if (key != 'INPUT_SCRIPT') { + console.log(`${key}=${value}`); + } + } + console.log('::77e6a57ef9854574::'); + console.log('::endgroup::'); + console.log('::group::github event'); + console.log('::stop-commands::77e6a57ef9854574'); + console.log(JSON.stringify(event, null, 2)); + console.log('::77e6a57ef9854574::'); + console.log('::endgroup::'); + + - name: Upload logs + if: always() + uses: actions/upload-artifact@ef09cdac3e2d3e60d8ccadda691f4f1cec5035cb #v4.3.1 + 3 commits + with: + if-no-files-found: ignore + name: deploy_logs + path: ${{ runner.workspace }}/logs/ diff --git a/.github/actions/plan/action.yml b/.github/actions/plan/action.yml new file mode 100644 index 0000000..18e6af8 --- /dev/null +++ b/.github/actions/plan/action.yml @@ -0,0 +1,303 @@ +# Copyright (c) Innofactor Plc & AUTHORS +# SPDX-License-Identifier: BSD-3-Clause +# +name: Plan deployment +author: Innofactor Plc +description: Plan a deployment of Azure infrastructure. +branding: + icon: eye + color: green +inputs: + azure_client_id: + description: "The client ID of the service principal for Azure login." + required: true + azure_client_secret: + description: "The client secret of the service principal for Azure login." + required: false + default: "" + azure_providers: + description: "A comma separated list of Azure resource providers." + required: false + default: "" + azure_subscription_id: + description: "The subscription ID in which to deploy the resources." + required: true + azure_tenant_id: + description: "The tenant ID in which the subscription exists." + required: true + cost_threshold: + description: "Max acceptable estimated cost." + required: false + default: -1 + currency: + description: "Currency code to use for estimations." + required: false + default: "EUR" + location: + description: "The Azure location to store the deployment metadata." + required: true + log_severity: + description: "The log verbosity." + required: false + default: "ERROR" + management_group: + description: "Management group to create deployment at for mg scope." + required: false + default: "" + resource_group: + description: "Resource group to create deployment at for group scope." + required: false + default: "" + rule_baseline: + description: "The name of a PSRule baseline to use." + required: false + default: "Azure.Default" + rule_modules: + description: "A comma separated list of modules to use for analysis." + required: false + default: "Az.Resources,PSRule.Rules.Azure" + rule_option: + description: "The path to an options file." + required: false + default: "" + scope: + description: "The deployment scope. Accepted: tenant, mg, sub, group." + required: true + template: + description: "The template address." + required: true + template_parameters: + description: "Deployment parameter values." + required: false + default: "" + token: + description: "GitHub token used to comment on pull request." + required: false + default: ${{ github.token }} + version_ace_tool: + description: "Azure Cost Estimator version." + required: false + default: "1.4" +outputs: + providers: + description: The Azure resource providers that should be registered. + value: ${{ steps.validate.outputs.providers }} +runs: + using: composite + steps: + - name: Add scripts to system path + shell: bash + run: | + folder=$(readlink -f "${GITHUB_ACTION_PATH}/../../../scripts") + echo "Script folder: ${folder}" + echo "${folder}" >> "${GITHUB_PATH}" + + - name: Install tools + env: + IN_SEVERITY: ${{ inputs.log_severity }} + IN_TEMPLATE: ${{ inputs.template }} + LOG_NAME: install_tools + LOG_ORDER: b1 + LOG_PATH: ${{ runner.workspace }}/logs + shell: bash + run: install-tools.sh + + - name: Azure login + env: + IN_SEVERITY: ${{ inputs.log_severity }} + CLIENT_ID: ${{ inputs.azure_client_id }} + CLIENT_SECRET: ${{ inputs.azure_client_secret }} + LOG_NAME: azure_login + LOG_ORDER: b2 + LOG_PATH: ${{ runner.workspace }}/logs + SUBSCRIPTION_ID: ${{ inputs.azure_subscription_id }} + TENANT_ID: ${{ inputs.azure_tenant_id }} + shell: bash + run: az-login.sh + + - name: Bicep build + id: bicep_build + if: ${{ !startsWith(inputs.template, '/subscriptions/') }} + env: + IN_SEVERITY: ${{ inputs.log_severity }} + IN_TEMPLATE: ${{ inputs.template }} + LOG_NAME: bicep_build + LOG_ORDER: b3 + LOG_PATH: ${{ runner.workspace }}/logs + SOURCE_PATH: ${{ github.workspace }} + shell: bash + run: az-bicep.sh build + + - name: Bicep build-params + id: bicep_build_params + if: inputs.parameters != '' + env: + IN_SEVERITY: ${{ inputs.log_severity }} + IN_TEMPLATE: ${{ inputs.template_parameters }} + LOG_NAME: bicep_build_params + LOG_ORDER: b4 + LOG_PATH: ${{ runner.workspace }}/logs + SOURCE_PATH: ${{ github.workspace }} + shell: bash + run: az-bicep.sh build-params + + - name: Validate + env: + IN_LOCATION: ${{ inputs.location }} + IN_MANAGEMENT_GROUP: ${{ inputs.management_group }} + IN_PROVIDERS: ${{ inputs.azure_providers }} + IN_RESOURCE_GROUP: ${{ inputs.resource_group }} + IN_SCOPE: ${{ inputs.scope }} + IN_SEVERITY: ${{ inputs.log_severity }} + IN_TEMPLATE: ${{ inputs.template }} + IN_TEMPLATE_PARAMS: ${{ inputs.template_parameters }} + LOG_NAME: validate + LOG_ORDER: b5 + LOG_PATH: ${{ runner.workspace }}/logs + RUN_ID: ${{ github.run_id }} + SOURCE_PATH: ${{ github.workspace }} + shell: bash + run: az-deploy.sh validate + + - name: What-if + env: + IN_LOCATION: ${{ inputs.location }} + IN_MANAGEMENT_GROUP: ${{ inputs.management_group }} + IN_RESOURCE_GROUP: ${{ inputs.resource_group }} + IN_SCOPE: ${{ inputs.scope }} + IN_SEVERITY: ${{ inputs.log_severity }} + IN_TEMPLATE: ${{ inputs.template }} + IN_TEMPLATE_PARAMS: ${{ inputs.template_parameters }} + LOG_NAME: what-if + LOG_ORDER: a1 + LOG_PATH: ${{ runner.workspace }}/logs + RUN_ID: ${{ github.run_id }} + SOURCE_PATH: ${{ github.workspace }} + shell: bash + run: az-deploy.sh what-if + + - name: PSRule config + id: psrule_config + if: inputs.rule_option != '' + env: + LOG_NAME: psrule_config + LOG_PATH: ${{ runner.workspace }}/logs + OPTION: ${{ inputs.rule_option }} + TEMPLATE_FILE: ${{ steps.bicep_build.outputs.file }} + TEMPLATE_PARAMS_FILE: ${{ steps.bicep_build_params.outputs.file }} + shell: bash + run: psrule-config.sh + + - name: PSRule analysis + if: inputs.rule_option != '' && steps.psrule_config.outputs.error == '' + uses: microsoft/ps-rule@001a0fcdab97b1d83e25c559163ecececc80cc6f # v2.9.0 + 6 commits + with: + baseline: ${{ inputs.rule_baseline }} + inputType: repository + modules: ${{ inputs.rule_modules }} + option: ${{ inputs.rule_option }} + outputFormat: Markdown + outputPath: ${{ runner.workspace }}/logs/psrule_analysis.md + summary: false + + - name: PSRule report + if: > + ( + success() || failure() + ) && + inputs.rule_option != '' + env: + CONFIG_ERROR: ${{ steps.psrule_config.outputs.error }} + LOG_NAME: psrule_report + LOG_ORDER: b6 + LOG_PATH: ${{ runner.workspace }}/logs + shell: bash + run: psrule-report.sh + + - name: Cost estimate + if: steps.bicep_build.outputs.file != '' + env: + IN_CURRENCY: ${{ inputs.currency }} + IN_LOCATION: ${{ inputs.location }} + IN_MANAGEMENT_GROUP: ${{ inputs.management_group }} + IN_RESOURCE_GROUP: ${{ inputs.resource_group }} + IN_SCOPE: ${{ inputs.scope }} + IN_TEMPLATE_PARAMS: ${{ inputs.template_parameters }} + LOG_NAME: cost_estimate + LOG_ORDER: a2 + LOG_PATH: ${{ runner.workspace }}/logs + SUBSCRIPTION_ID: ${{ inputs.azure_subscription_id }} + TEMPLATE_FILE: ${{ steps.bicep_build.outputs.file }} + TEMPLATE_PARAMS_FILE: ${{ steps.bicep_build_params.outputs.file }} + THRESHOLD: ${{ inputs.cost_threshold }} + VERSION_ACE: ${{ inputs.version_ace_tool }} + shell: bash + run: azure-cost.sh + + - name: Job cancelled + if: cancelled() + shell: bash + run: echo 'JOB_STATUS=cancelled' >> "$GITHUB_ENV" + + - name: Job failed + if: failure() + shell: bash + run: echo 'JOB_STATUS=failed' >> "$GITHUB_ENV" + + - name: Add comment + if: > + always() && + ( + github.event_name == 'pull_request' || + github.event_name == 'pull_request_target' + ) + env: + COMMENTS_URL: ${{ github.event.pull_request.comments_url }} + COMMIT_SHA: ${{ github.sha }} + EVENT_ACTION: ${{ github.event.action }} + EVENT_ACTOR: ${{ github.actor }} + EVENT_NO: "#${{ github.event.number }}" + JOB_NAME: ${{ github.workflow }} + JOB_STATUS: ${{ env.JOB_STATUS }} + JOB_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + LOG_NAME: plan_comment + LOG_PATH: ${{ runner.workspace }}/logs + RUN_NUMBER: ${{ github.run_number }} + TOKEN: ${{ inputs.token }} + shell: bash + run: pr-comment.sh + + - name: Show debug info + if: > + ( + success() && + inputs.log_severity != 'ERROR' + ) || failure() + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea #v7.0.1 + with: + github-token: ${{ inputs.token }} + script: | + const fs = require('fs'); + const event = JSON.parse(fs.readFileSync(process.env['GITHUB_EVENT_PATH'])); + console.log('::group::environment variables'); + console.log('::stop-commands::77e6a57ef9854574'); + for (const [key, value] of Object.entries(process.env).sort()) { + if (key != 'INPUT_SCRIPT') { + console.log(`${key}=${value}`); + } + } + console.log('::77e6a57ef9854574::'); + console.log('::endgroup::'); + console.log('::group::github event'); + console.log('::stop-commands::77e6a57ef9854574'); + console.log(JSON.stringify(event, null, 2)); + console.log('::77e6a57ef9854574::'); + console.log('::endgroup::'); + + - name: Upload logs + if: always() + uses: actions/upload-artifact@ef09cdac3e2d3e60d8ccadda691f4f1cec5035cb #v4.3.1 + 3 commits + with: + if-no-files-found: ignore + name: plan_logs + path: ${{ runner.workspace }}/logs/ diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000..e97ed28 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,24 @@ +--- +changelog: + exclude: + labels: + - "ignore for release" + categories: + - title: "✨ New Features" + labels: + - "enhancement" + - title: "πŸ› Bug Fixes" + labels: + - "bug" + - title: "πŸ“š Documentation" + labels: + - "documentation" + - title: "⬆️ Dependencies" + labels: + - "dependencies" + - title: "πŸ’₯ Breaking Changes" + labels: + - "breaking change" + - title: "πŸ”¨ Other Changes" + labels: + - "*" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d0904ab --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,29 @@ +--- +name: "πŸŽ‰ Release" +run-name: "Create release ${{ github.event.inputs.tag }}" + +on: + workflow_dispatch: + inputs: + tag: + description: "The release tag" + required: true + type: string + +permissions: read-all + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: true + +jobs: + release: + name: "Release" + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: "Release" + uses: innofactororg/code-release@v2 + with: + tag: ${{ github.event.inputs.tag }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6cc24d1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,133 @@ +# Created by https://www.toptal.com/developers/gitignore/api/linux,macos,windows,microsoftoffice,visualstudiocode,git +# Edit at https://www.toptal.com/developers/gitignore?templates=linux,macos,windows,microsoftoffice,visualstudiocode,git + +### Git ### +# Created by git for backups. To disable backups in Git: +# $ git config --global mergetool.keepBackup false +*.orig + +# Created by git when using merge tools for conflicts +*.BACKUP.* +*.BASE.* +*.LOCAL.* +*.REMOTE.* +*_BACKUP_*.txt +*_BASE_*.txt +*_LOCAL_*.txt +*_REMOTE_*.txt + +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### MicrosoftOffice ### +*.tmp + +# Word temporary +~$*.doc* + +# Word Auto Backup File +Backup of *.doc* + +# Excel temporary +~$*.xls* + +# Excel Backup File +*.xlk + +# PowerPoint temporary +~$*.ppt* + +# Visio autosave temporary files +*.~vsd* + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# End of https://www.toptal.com/developers/gitignore/api/linux,macos,windows,microsoftoffice,visualstudiocode,git \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..156c36c --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,51 @@ +{ + "debug.internalConsoleOptions": "neverOpen", + "editor.accessibilitySupport": "off", + "editor.bracketPairColorization.enabled": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.detectIndentation": false, + "editor.fontFamily": "FiraCode Nerd Font Mono", + "editor.fontSize": 14, + "editor.formatOnPaste": true, + "editor.formatOnSave": true, + "editor.guides.bracketPairs": "active", + "editor.tabCompletion": "on", + "editor.tabSize": 2, + "files.autoSave": "onFocusChange", + "files.encoding": "utf8", + "files.insertFinalNewline": true, + "files.trimTrailingWhitespace": true, + "git.pruneOnFetch": true, + "git.enableSmartCommit": true, + "githubPullRequests.pullBranch": "always", + "markdown.extension.orderedList.autoRenumber": false, + "markdown.extension.toc.updateOnSave": false, + "markdownlint.config": { + "MD025": { + "front_matter_title": "" + }, + "MD028": false, + "MD041": false + }, + "terminal.integrated.defaultProfile.linux": "zsh", + "workbench.colorTheme": "Catppuccin Mocha", + "workbench.editor.decorations.colors": true, + "workbench.editor.decorations.badges": true, + "workbench.editor.wrapTabs": true, + "workbench.iconTheme": "catppuccin-macchiato", + "workbench.startupEditor": "none", + "[json,jsonc]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[markdown]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.tabSize": 2, + "editor.wordWrap": "off", + "files.encoding": "utf8", + "files.insertFinalNewline": true, + "files.trimTrailingWhitespace": true + }, + "[yaml]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + } +} diff --git a/LICENSE b/LICENSE index a015c3c..bef2d8b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,29 @@ -MIT License +BSD 3-Clause License -Copyright (c) 2023 Innofactor Plc +Copyright (c) 2024 Innofactor Plc & AUTHORS. +All rights reserved. -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md index a34deb5..e3c7068 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,358 @@ -# bicep-action -Reusable workflow to perform infrastructure as code deployments using bicep +# Bicep deploy + +This repository includes GitHub actions and a Azure DevOps [pipeline](./.azuredevops/README.md) to plan and deploy Azure infrastructure. + +## Overview + +![Flow overview](./images/deploy-flow.drawio.png) + +1. The user creates a new branch, then commits and push the code. +1. The user creates a pull request. +1. The workflow is automatically triggered and starts the [plan job](#plan-job). +1. If the plan job was successful, the workflow will wait for a [required reviewer](#get-started) to approve the [deploy job](#deploy-job). +1. When a reviewer has approved, the workflow starts the [deploy job](#deploy-job) to deploy the code. + +## Get started + +To set up a bicep deploy workflow, several prerequisite steps are required: + +1. Create an [environment](https://docs.github.com/actions/deployment/targeting-different-environments/using-environments-for-deployment#creating-an-environment). + +1. To prevent unapproved deployments, add **"Required reviewers"** to the environment. Remember to save the protection rules after making changes. + +1. Register a [Microsoft identity platform application](https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-register-app). + +1. Assign [Azure roles](https://learn.microsoft.com/en-us/azure/role-based-access-control/role-assignments-steps) to the application so it can deploy Azure infrastructure. For example, give it the **"Owner"** role on the target Azure subscription. + +1. Give the repository Azure login permission: + + - **Option 1**: Add [federated credentials](https://docs.microsoft.com/azure/developer/github/connect-from-azure?tabs=azure-portal%2Clinux#use-the-azure-login-action-with-openid-connect) (recommended) + + - Use the scenario **"GitHub Actions deploying Azure resources"**. + - Select entity type **"Pull request"** (needed for the [plan job](#plan-job)). + - Save the credential. + - Add another federated credential with the scenario **"GitHub Actions deploying Azure resources"**. + - Select entity type **"Environment"** (needed for the [deploy job](#deploy-job)). + - Specify the environment name that was created in step 1. + - Save the credential. + + Note that there is a limit of 20 federated credentials per application. For this reason, and for security reasons, it is recommended to create a separate application for each repository. + + - **Option 2**: Add [client secret](https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-register-app#add-a-client-secret) + + - Add the secret from the app registration as a secret in the repository. Remember that the secret must be replaced when it expires. + - Add **"AZURE_CLIENT_SECRET"** to the workflow, see [passing secrets](#passing-secrets). + +### Auto merge + +To allow pull requests to merge automatically once all required reviews and status checks have passed, enable **"Allow auto-merge"** in the repository settings under **"General"**. + +For auto merge to work as intended, [branch protection](#branch-protection) must be configured. + +### Branch protection + +It is recommended to protect the default branch. This is done in the repository settings under **"Branches"**. + +Recommended branch protection for production use: + +- Require a pull request before merging + - Require approvals + - Dismiss stale pull request approvals when new commits are pushed + - Require approval of the most recent reviewable push +- Require status checks to pass before merging + - Require branches to be up to date before merging + - Add the following status checks: + - πŸƒ Deploy +- Require conversation resolution before merging +- Require linear history +- Require deployments to succeed before merging (and select the environment that must succeed) + +This ensures that no changes to the pull request are possible between the approval and the merging and that a successful plan and deploy has occurred. + +## Workflow + +The workflow is designed to run when a pull request is created or updated. + +It has been tested on a [standard GitHub-hosted runner](https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners/about-github-hosted-runners#standard-github-hosted-runners-for-public-repositories) with workflow label **"ubuntu-22.04"**. + +The concurrency setting is configured to ensure that only one workflow runs at any given time. If a new workflow starts with the same name, GitHub Actions will cancel any workflow already running with that name. + +The following tools are used: + +- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/) + - [az login](https://learn.microsoft.com/en-us/cli/azure/reference-index?view=azure-cli-latest#az-login) + - [az bicep build](https://learn.microsoft.com/en-us/cli/azure/bicep?view=azure-cli-latest#az-bicep-build) + - [az bicep build-params](https://learn.microsoft.com/en-us/cli/azure/bicep?view=azure-cli-latest#az-bicep-build-params) + - [az provider register](https://learn.microsoft.com/en-us/cli/azure/provider?view=azure-cli-latest#az-provider-register) + - [az deployment {SCOPE} create](https://learn.microsoft.com/en-us/cli/azure/deployment/sub?view=azure-cli-latest#az-deployment-sub-create) + - [az deployment {SCOPE} validate](https://learn.microsoft.com/en-us/cli/azure/deployment/sub?view=azure-cli-latest#az-deployment-sub-validate) + - [az deployment {SCOPE} what-if](https://learn.microsoft.com/en-us/cli/azure/deployment/sub?view=azure-cli-latest#az-deployment-sub-what-if) +- [Azure Cost Estimator](https://github.com/TheCloudTheory/arm-estimator) +- [curl](https://curl.se/) +- GitHub actions: + - [checkout](https://github.com/actions/checkout/tree/b4ffde65f46336ab88eb53be808477a3936bae11) + - [github-script](https://github.com/actions/github-script/tree/60a0d83039c74a4aee543508d2ffcb1c3799cdea) + - [microsoft/ps-rule](https://github.com/microsoft/ps-rule/tree/001a0fcdab97b1d83e25c559163ecececc80cc6f) + - [upload-artifact](https://github.com/actions/upload-artifact/tree/ef09cdac3e2d3e60d8ccadda691f4f1cec5035cb) +- [GNU bash](https://www.gnu.org/software/bash/) +- [GNU bc](https://www.gnu.org/software/bc/) +- [GNU core utilities](https://www.gnu.org/software/coreutils/coreutils.html) +- [GNU find utilities](https://www.gnu.org/software/findutils/) +- [jq](https://jqlang.github.io/jq/) +- [sed](https://www.gnu.org/software/sed/) +- [unzip](https://infozip.sourceforge.net/) + +### Plan job + +The plan job will build and test the code. If no issues are found in the code, a [what-if](https://docs.microsoft.com/cli/azure/deployment/sub#az-deployment-sub-what-if) report is generated. + +The PSRule steps will only run if **"rule_option"** is specified and points to a file that exist. + +For more information about PSRule configuration, see: + +- [Sample ps-rule.yaml](./ps-rule.yaml) +- [Configuring options](https://azure.github.io/PSRule.Rules.Azure/setup/configuring-options/) +- [Configuring rule defaults](https://azure.github.io/PSRule.Rules.Azure/setup/configuring-rules/) +- [Available Options](https://microsoft.github.io/PSRule/v2/concepts/PSRule/en-US/about_PSRule_Options/) +- [Available Rules by resource type](https://azure.github.io/PSRule.Rules.Azure/en/rules/resource/) + +### Deploy job + +The deploy job will only run when the plan job was successful. + +A specific [environment](#get-started) must be specified for this job. + +If the environment is configured with **required reviewers**, the job will require manual approval. + +### Passing secrets + +It is optional to pass **"azure_client_id"**, **"azure_subscription_id"** and **"azure_tenant_id"** as secret. + +Secrets are masked in the job log. The result is that IDs can't be seen and it may be difficult to see if the wrong ID is used. + +On the other hand, passing these values as secrets will make them less exposed and is therefore the more secure option. + +When using **"Option 2"** in [Get started](#get-started), the input **"azure_client_secret"** is needed and must be passed as a secret. + +Secrets are passed using the secrets syntax, for example: + +```yaml +with: + azure_client_id: ${{ secrets.AZURE_CLIENT_ID }} + azure_client_secret: ${{ secrets.AZURE_CLIENT_SECRET }} + azure_subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + azure_tenant_id: ${{ secrets.AZURE_TENANT_ID }} +``` + +### Action Inputs + +#### Required + +- **azure_client_id**: (Required) The client ID of the service principal for Azure login. + + This service principal must have permission to deploy within the Azure subscription. + +- **azure_subscription_id**: (Required) The subscription ID in which to deploy the resources. + +- **azure_tenant_id**: (Required) The tenant ID in which the subscription exists. + +- **location**: (Required) The Azure location to store the deployment metadata. + +- **scope**: (Required) The deployment scope. Accepted: tenant, mg, sub, group. + +- **template**: (Required) The template address. A path or URI to a file or a template spec resource id. + +#### Optional + +- **auto_merge**: Auto merge method to use after successful deployment. + + Can be one of: + + - merge + - squash + - rebase + - disable (turn off auto merge) + + Default: **"squash"** + +- **azure_providers**: A comma separated list of Azure resource providers. + + The workflow will try to register the specified providers in addition to the providers that is detected in code by deployment validate. + + Default: **""** + +- **azure_provider_wait_count**: Times to check provider status before giving up. + + Default: **"30"** + +- **azure_provider_wait_seconds**: Seconds to wait between each provider status check. + + Default: **"10"** + +- **cost_threshold**: Max acceptable estimated cost. Exceeding threshold causes plan to fail. + + A value of **"-1"** means no cost threshold. + + Default: **"-1"** + +- **currency**: Currency code to use for estimations. + + See allowed values at + + Default: **"EUR"** + +- **log_severity**: The log verbosity. Can be one of: + + - **ERROR**: Only show errors, suppressing warnings. + - **INFO**: Standard log level. + - **VERBOSE**: Increase logging verbosity. + - **DEBUG**: Show all debug logs. + + Default: **ERROR** + +- **management_group**: Management group to create deployment at for mg scope. + + Default: **""** + +- **resource_group**: Resource group to create deployment at for group scope. + + Default: **""** + +- **rule_baseline**: The name of a PSRule baseline to use. + + For a list of baseline names for module PSRule.Rules.Azure see + + Default: **Azure.Default** + +- **rule_modules**: A comma separated list of modules to use for analysis. + + For a list of modules see + + Default: **Az.Resources,PSRule.Rules.Azure** + +- **rule_option**: The path to an options file. + + For example: `bicep/pattern1/ps-rule.prod.yaml` + + Default: **""** + +- **template_parameters**: Deployment parameter values. + + Either a path, URI, JSON string, or pairs. + + For example: `bicep/pattern1/main.prod.bicepparam` + + Default: **""** + +- **version_ace_tool**: Azure Cost Estimator version. + + The version to use for cost estimation. See versions at + + Default: **"1.4"** + +### Usage + +```yaml +name: Azure Deploy +on: + pull_request: + branches: [main] + paths: ["bicep/**.bicep*"] + types: [opened, synchronize] + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: true + +permissions: {} + +env: + auto_merge: squash + azure_client_id: d0d0d0d0-4558-43bb-896a-008e763058bd # when using a secret, remove this line and add the secret to each job below + azure_providers: Microsoft.Advisor,Microsoft.AlertsManagement,Microsoft.Authorization,Microsoft.Consumption,Microsoft.EventGrid,microsoft.insights,Microsoft.ManagedIdentity,Microsoft.Management,Microsoft.Network,Microsoft.PolicyInsights,Microsoft.ResourceHealth,Microsoft.Resources,Microsoft.Security + azure_provider_wait_count: 30 + azure_provider_wait_seconds: 10 + azure_subscription_id: d0d0d0d0-ed29-4694-ac26-2e358c364506 # when using a secret, remove this line and add the secret to each job below + azure_tenant_id: d0d0d0d0-b93b-4f96-9e73-4ea6caa2f3b4 # when using a secret, remove this line and add the secret to each job below + cost_threshold: 1000 + currency: EUR + location: westeurope + log_severity: INFO + rule_option: ps-rule.yaml + scope: sub + template: bicep/pattern1/main.bicep + template_parameters: bicep/pattern1/main.bicepparam + +jobs: + plan: + name: πŸ—“οΈ Plan + permissions: + contents: read # for checkout + id-token: write # for Azure login with open id + pull-requests: write # for pull request comment + outputs: + providers: ${{ steps.plan.outputs.providers }} + runs-on: ubuntu-22.04 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 1 + persist-credentials: false + + - name: Plan + id: plan + uses: innofactororg/bicep-action/.github/actions/plan@v1 + with: + azure_client_id: ${{ env.azure_client_id }} # for secret, use ${{ secrets.AZURE_CLIENT_ID }} + # azure_client_secret: ${{ secrets.AZURE_CLIENT_SECRET }} # use this if choosing Option 2 in Get started + azure_providers: ${{ env.azure_providers }} + azure_subscription_id: ${{ env.azure_subscription_id }} # for secret, use ${{ secrets.AZURE_SUBSCRIPTION_ID }} + azure_tenant_id: ${{ env.azure_tenant_id }} # for secret, use ${{ secrets.AZURE_TENANT_ID }} + cost_threshold: ${{ env.cost_threshold }} + currency: ${{ env.currency }} + location: ${{ env.location }} + log_severity: ${{ env.log_severity }} + rule_option: ${{ env.rule_option }} + scope: ${{ env.scope }} + template: ${{ env.template }} + template_parameters: ${{ env.template_parameters }} + + deploy: + name: πŸƒ Deploy + needs: plan + environment: production + permissions: + contents: write # for auto merge + id-token: write # for Azure login with open id + pull-requests: write # for pull request comment + runs-on: ubuntu-22.04 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + fetch-depth: 1 + + - name: Deploy + id: deploy + uses: innofactororg/bicep-action/.github/actions/deploy@v1 + with: + auto_merge: ${{ env.auto_merge }} + azure_client_id: ${{ env.azure_client_id }} # for secret, use ${{ secrets.AZURE_CLIENT_ID }} + # azure_client_secret: ${{ secrets.AZURE_CLIENT_SECRET }} # use this if choosing Option 2 in Get started + azure_providers: ${{ needs.plan.outputs.providers }} + azure_provider_wait_count: ${{ env.azure_provider_wait_count }} + azure_provider_wait_seconds: ${{ env.azure_provider_wait_seconds }} + azure_subscription_id: ${{ env.azure_subscription_id }} # for secret, use ${{ secrets.AZURE_SUBSCRIPTION_ID }} + azure_tenant_id: ${{ env.azure_tenant_id }} # for secret, use ${{ secrets.AZURE_TENANT_ID }} + location: ${{ env.location }} + log_severity: ${{ env.log_severity }} + scope: ${{ env.scope }} + template: ${{ env.template }} + template_parameters: ${{ env.template_parameters }} +``` + +## License + +The code and documentation in this project are released under the [BSD 3-Clause License](./LICENSE). diff --git a/images/deploy-flow.azdo.drawio.png b/images/deploy-flow.azdo.drawio.png new file mode 100644 index 0000000..5c5c92f Binary files /dev/null and b/images/deploy-flow.azdo.drawio.png differ diff --git a/images/deploy-flow.drawio.png b/images/deploy-flow.drawio.png new file mode 100644 index 0000000..bfbd74a Binary files /dev/null and b/images/deploy-flow.drawio.png differ diff --git a/ps-rule.yaml b/ps-rule.yaml new file mode 100644 index 0000000..0e490d0 --- /dev/null +++ b/ps-rule.yaml @@ -0,0 +1,32 @@ +binding: + preferTargetInfo: true + targetType: + - type + - resourceType +configuration: + AZURE_PARAMETER_FILE_EXPANSION: true +execution: + unprocessedObject: Ignore +include: + module: + - Az.Resources + - PSRule.Rules.Azure +input: + pathIgnore: + - bicepconfig.json + - modules/**/*.bicep +output: + culture: + - en-US + footer: RuleCount + format: Markdown + jobSummaryPath: psrule_summary.md + outcome: Processed + style: Detect +rule: + exclude: + - Azure.Template.UseDescriptions + - Azure.Resource.UseTags + - Azure.Storage.SoftDelete + - Azure.Storage.ContainerSoftDelete + - Azure.Storage.Firewall diff --git a/scripts/az-bicep.sh b/scripts/az-bicep.sh new file mode 100755 index 0000000..826c28e --- /dev/null +++ b/scripts/az-bicep.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# Copyright (c) Innofactor Plc & AUTHORS +# SPDX-License-Identifier: BSD-3-Clause +# +set -e +mkdir -p "${LOG_PATH}" +SCRIPT_ACTION="${1}" +log="${LOG_PATH}/step_${LOG_ORDER}_${LOG_NAME}.log" +trap 'error $? $LINENO "$BASH_COMMAND" $log' ERR +trap cleanup EXIT +cleanup() { + if test -z "${TF_BUILD-}"; then + echo '::endgroup::' + fi +} +error() { + local msg + msg="Error on or near line $(("${2}" + 1)) (exit code ${1})" + msg+=" in ${LOG_NAME/_/ } at $(date '+%Y-%m-%d %H:%M:%S')" + if test -n "${TF_BUILD-}"; then + echo "##[error]${msg}" + else + echo "::error::${msg}" + fi + log_output "${4}" "${msg}" "${3}" + exit "${1}" +} +log_output() { + local data='' + local errors='' + local summary='' + local warnings='' + if test -f "${1}"; then + data=$(cat "${1}") + data="${data//${SOURCE_PATH}/}" + warnings=$(echo "${data}" | sed -n -e '/) : Warning /p') + warnings="${warnings//WARNING: /}" + errors=$(echo "${data}" | sed -n -e '/) : Error /p') + errors="${errors//ERROR: /}" + fi + if test -n "${errors}" || test -n "${2}"; then + summary="The ${LOG_NAME/_/ } failed. ${2}❗" + if test -n "${3}"; then + summary+='\n\nCommand that failed:\n\n```text\n' + summary+="$(eval echo "${3}")" + summary+='\n```' + fi + fi + if test -n "${summary}"; then + local output="## Bicep ${SCRIPT_ACTION}\n\n${summary}" + if test -n "${warnings}"; then + output+='\n\n:warning: **WARNINGS**:\n\n```text\n' + output+="${warnings}\n" + output+='```' + fi + if test -n "${errors}"; then + output+='\n\n:x: **ERRORS**:\n\n```text\n' + output+="${errors}\n" + output+='```' + fi + echo -e "${output}" > "${1/.log/.md}" + fi +} +if test -z "${TF_BUILD-}"; then + echo "::group::Output" +fi +if [ "${SCRIPT_ACTION}" = 'build-params' ]; then + IN_TEMPLATE="${IN_TEMPLATE%% *}" + src_file_extension='bicepparam' + out_file_extension='parameters.json' +else + src_file_extension='bicep' + out_file_extension='json' +fi +if [[ $IN_TEMPLATE == http* ]]; then + file="${IN_TEMPLATE##*/}" + uri="${IN_TEMPLATE}" + echo "Download ${uri}" + HTTP_CODE=$(curl -sSL --retry 4 --output "${file}" \ + --write-out "%{response_code}" "${uri}" + ) + if [ "${HTTP_CODE}" -lt 200 ] || [ "${HTTP_CODE}" -gt 299 ]; then + if test -n "${TF_BUILD-}"; then + echo "##[error]Unable to get ${file}! Response code: ${HTTP_CODE}" + else + echo "::error::Unable to get ${file}! Response code: ${HTTP_CODE}" + fi + exit 1 + fi + IN_TEMPLATE="${IN_TEMPLATE##*/}" +fi +if [[ $IN_TEMPLATE == *.${src_file_extension} ]]; then + out_file=$(readlink -f "${IN_TEMPLATE/.${src_file_extension}/.${out_file_extension}}") + echo "Set output: file='${out_file}'" + if test -n "${TF_BUILD-}"; then + echo "##vso[task.setvariable variable=file;isoutput=true]${out_file}" + else + echo "file=${out_file}" >> "$GITHUB_OUTPUT" + fi + cmd="az bicep ${SCRIPT_ACTION} --file ${IN_TEMPLATE} --outfile ${out_file}" + case "${IN_SEVERITY}" in + ERROR) cmd+=' --only-show-errors';; + VERBOSE) cmd+=' --verbose';; + DEBUG) cmd+=' --debug';; + esac + echo "Run: ${cmd}" + eval "${cmd}" 1> >(tee -a "${log}") 2> >(tee -a "${log}" >&2) + if test -f "${out_file}"; then + cp "${out_file}" "${LOG_PATH}/" + fi +else + echo "Skip bicep ${SCRIPT_ACTION}, not a ${src_file_extension} file: ${IN_TEMPLATE}" +fi +log_output "${log}" '' '' diff --git a/scripts/az-deploy.sh b/scripts/az-deploy.sh new file mode 100755 index 0000000..b4e998f --- /dev/null +++ b/scripts/az-deploy.sh @@ -0,0 +1,204 @@ +#!/usr/bin/env bash +# Copyright (c) Innofactor Plc & AUTHORS +# SPDX-License-Identifier: BSD-3-Clause +# +set -e +mkdir -p "${LOG_PATH}" +SCRIPT_ACTION="${1}" +log="${LOG_PATH}/step_${LOG_ORDER}_${LOG_NAME}.log" +trap 'error $? $LINENO "$BASH_COMMAND" $log' ERR +trap cleanup EXIT +cleanup() { + if test -z "${TF_BUILD-}"; then + echo '::endgroup::' + fi +} +error() { + local msg + msg="Error on or near line $(("${2}" + 1)) (exit code ${1})" + msg+=" in ${LOG_NAME/_/ } at $(date '+%Y-%m-%d %H:%M:%S')" + if test -n "${TF_BUILD-}"; then + echo "##[error]${msg}" + else + echo "::error::${msg}" + fi + log_output "${4}" "${msg}" "${3}" + exit "${1}" +} +log_output() { + local data='' + local errors='' + local json_object='' + local output='' + local summary='' + local warnings='' + if test -f "${1}"; then + if [ "${SCRIPT_ACTION}" = 'what-if' ]; then + data=$( + sed -r 's/^([[:space:]]+)([-+~x])[[:space:]]/\2\1/g' "${1}" | \ + sed -e 's/^~/!/g' + ) + else + data=$(sed '/^{$/,$d' "${1}") + data="${data//${SOURCE_PATH}/}" + json_object=$(sed -n '/^{$/,$p' "${1}") + warnings=$(echo "${data}" | sed -n -e '/) : Warning /p') + warnings="${warnings//WARNING: /}" + errors=$(echo "${data}" | sed -n '/^ERROR: /,$p') + errors="${errors//ERROR: /}" + fi + fi + if test -n "${errors}" || test -n "${2}"; then + if [ "${SCRIPT_ACTION}" = 'validate' ]; then + summary="The ${LOG_NAME/_/ } failed. ${2}❗" + else + summary="${2}" + fi + if test -n "${3}"; then + summary+='\n\nCommand that failed:\n\n```text\n' + summary+="$(eval echo "${3}")" + summary+='\n```' + fi + elif [ "${SCRIPT_ACTION}" = 'validate' ] && \ + test -z "${json_object}" && \ + test -z "${warnings}" + then + summary="The ${LOG_NAME/_/ } failed. No output found❗" + elif [ "${SCRIPT_ACTION}" != 'validate' ] && \ + test -z "${data}" && \ + test -z "${json_object}" && \ + test -z "${warnings}" + then + summary="No output found❗" + fi + if test -n "${summary}"; then + summary="\n\n${summary}" + fi + case "${SCRIPT_ACTION}" in + validate) output="## Deployment validate${summary}";; + *) output="${summary}";; + esac + if [ "${SCRIPT_ACTION}" = 'what-if' ] && test -n "${data}"; then + if [ ${#data} -gt 5000 ]; then + output+='\n\n
Click for details' + fi + output+='\n\n```diff\n' + output+="${data}\n" + output+='```' + if [ ${#data} -gt 5000 ]; then + output+='\n\n
' + fi + fi + if test -n "${warnings}"; then + output+='\n\n:warning: **WARNINGS**:\n\n```text\n' + output+="${warnings}\n" + output+='```' + fi + if test -n "${errors}"; then + output+='\n\n:x: **ERRORS**:\n\n```text\n' + output+="${errors}\n" + output+='```' + fi + if test -n "${json_object}"; then + output+='\n\n
Click for details\n' + output+='\n```json\n' + output+="${json_object}\n" + output+='```\n\n
' + fi + echo -e "${output}" > "${1/.log/.md}" + if [ "${SCRIPT_ACTION}" = 'validate' ]; then + local from_code='' + if [[ "${json_object}" == '{'* ]]; then + from_code=$( + echo "${json_object}" | \ + jq '.properties.providers | map(.namespace) | join(",")' + ) + echo "Resource providers discovered by ${LOG_NAME}:" + echo "${from_code}" + fi + local list + list=$(echo "${IN_PROVIDERS},${from_code}" | xargs) + echo "Set output: providers='${list}'" + if test -n "${TF_BUILD-}"; then + echo "##vso[task.setvariable variable=providers;isOutput=true]${list}" + else + echo "providers=${list}" >> "$GITHUB_OUTPUT" + fi + fi +} +if test -z "${TF_BUILD-}"; then + echo "::group::Output" +fi +if [[ $IN_TEMPLATE == http* ]]; then + file="${IN_TEMPLATE##*/}" + if ! test -f "${file}"; then + echo "Download ${IN_TEMPLATE}" + HTTP_CODE=$(curl -sSL --retry 4 --output "${file}" \ + --write-out "%{response_code}" "${IN_TEMPLATE}" + ) + if [ "${HTTP_CODE}" -lt 200 ] || [ "${HTTP_CODE}" -gt 299 ]; then + if test -n "${TF_BUILD-}"; then + echo "##[error]Unable to get ${file}! Response code: ${HTTP_CODE}" + else + echo "::error::Unable to get ${file}! Response code: ${HTTP_CODE}" + fi + exit 1 + fi + fi + IN_TEMPLATE="${file}" +fi +if [[ $IN_TEMPLATE_PARAMS == http* ]]; then + uri="${IN_TEMPLATE_PARAMS%% *}" + file="${file##*/}" + if ! test -f "${file}"; then + echo "Download ${IN_TEMPLATE_PARAMS}" + HTTP_CODE=$(curl -sSL --retry 4 --output "${file}" \ + --write-out "%{response_code}" "${uri}" + ) + if [ "${HTTP_CODE}" -lt 200 ] || [ "${HTTP_CODE}" -gt 299 ]; then + if test -n "${TF_BUILD-}"; then + echo "##[error]Unable to get ${file}! Response code: ${HTTP_CODE}" + else + echo "::error::Unable to get ${file}! Response code: ${HTTP_CODE}" + fi + exit 1 + fi + fi + IN_TEMPLATE_PARAMS="${file}" +fi +cmd="az deployment ${IN_SCOPE} ${SCRIPT_ACTION} --name ${LOG_NAME}_${RUN_ID}" +if [[ $IN_TEMPLATE == http* ]]; then + cmd+=" --template-uri ${IN_TEMPLATE}" +elif [[ $IN_TEMPLATE == /subscriptions/* ]]; then + cmd+=" --template-spec ${IN_TEMPLATE}" +else + cmd+=" --template-file ${IN_TEMPLATE}" +fi +if test -n "${IN_TEMPLATE_PARAMS}"; then + cmd+=" --parameters ${IN_TEMPLATE_PARAMS}" +fi +if ! [ "${IN_SCOPE}" = 'group' ]; then + cmd+=" --location ${IN_LOCATION}" +fi +if [ "${IN_SCOPE}" = 'mg' ]; then + cmd+=" --management-group-id ${IN_MANAGEMENT_GROUP}" +fi +if [ "${IN_SCOPE}" = 'group' ]; then + cmd+=" --resource-group ${IN_RESOURCE_GROUP}" +fi +case "${IN_SEVERITY}" in + ERROR) cmd+=' --only-show-errors';; + VERBOSE) cmd+=' --verbose';; + DEBUG) cmd+=' --debug';; +esac +case "${SCRIPT_ACTION}" in + create) cmd+=' --no-prompt true';; + validate) cmd+=' --no-prompt true -o json';; + what-if) cmd+=' --exclude-change-types Ignore NoChange --no-prompt true';; +esac +if test -n "${TF_BUILD-}"; then + az account set -s "${SUBSCRIPTION_ID}" 1> >(tee -a "${log}") 2> >(tee -a "${log}" >&2) +fi +echo "Run: ${cmd}" +eval "${cmd}" 1> >(tee -a "${log}") 2> >(tee -a "${log}" >&2) +log_output "${log}" '' '' diff --git a/scripts/az-login.sh b/scripts/az-login.sh new file mode 100755 index 0000000..e1f3594 --- /dev/null +++ b/scripts/az-login.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Copyright (c) Innofactor Plc & AUTHORS +# SPDX-License-Identifier: BSD-3-Clause +# +set -e +mkdir -p "${LOG_PATH}" +log="${LOG_PATH}/step_${LOG_ORDER}_${LOG_NAME}.log" +trap 'error $? $LINENO "$BASH_COMMAND" $log' ERR +trap cleanup EXIT +cleanup() { + if test -z "${TF_BUILD-}"; then + echo '::endgroup::' + fi +} +error() { + local msg + msg="Error on or near line $(("${2}" + 1)) (exit code ${1})" + msg+=" in ${LOG_NAME/_/ } at $(date '+%Y-%m-%d %H:%M:%S')" + if test -n "${TF_BUILD-}"; then + echo "##[error]${msg}" + else + echo "::error::${msg}" + fi + log_output "${4}" "${msg}" "${3}" + exit "${1}" +} +log_output() { + local data='' + local summary="${2}❗" + if test -n "${3}"; then + summary+='\n\nCommand that failed:\n\n```text\n' + summary+="$(eval echo "${3}")" + summary+='\n```' + fi + if test -f "${1}"; then + data=$( + sed -r 's/^([[:space:]]+)([-+~x])[[:space:]]/\2\1/g' "${1}" | \ + sed -e 's/^~/!/g' + ) + fi + local output="## Azure login\n\n${summary}" + if test -n "${data}"; then + if [ ${#data} -gt 5000 ]; then + output+='\n\n
Click for details' + fi + output+='\n\n```text\n' + output+="${data}\n" + output+='```' + if [ ${#data} -gt 5000 ]; then + output+='\n\n
' + fi + fi + echo -e "${output}" > "${1/.log/.md}" +} +if test -z "${TF_BUILD-}"; then + echo "::group::Output" +fi +case "${IN_SEVERITY}" in + ERROR) log_severity=' --only-show-errors';; + VERBOSE) log_severity=' --verbose';; + DEBUG) log_severity=' --debug';; + *) log_severity='';; +esac +cmd="az login --service-principal -t ${TENANT_ID} -u ${CLIENT_ID}" +if test -n "${CLIENT_SECRET}"; then + cmd+=" -p ${CLIENT_SECRET}" +else + HTTP_CODE=$(curl -sSL --retry 4 --output token.txt \ + --write-out "%{response_code}" \ + -H 'Accept: application/json; api-version=2.0' \ + -H "Authorization: bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \ + -H 'Content-Type: application/json' \ + -G --data-urlencode "audience=api://AzureADTokenExchange" \ + "${ACTIONS_ID_TOKEN_REQUEST_URL}" + ) + if [ "${HTTP_CODE}" -lt 200 ] || [ "${HTTP_CODE}" -gt 299 ]; then + if test -n "${TF_BUILD-}"; then + echo "##[error]Unable to get token! Response code: ${HTTP_CODE}" + else + echo "::error::Unable to get token! Response code: ${HTTP_CODE}" + fi + exit 1 + fi + token="$(jq -r '.value' token.txt)" + rm -f token.txt + echo "::add-mask::${token}" + cmd+=" --federated-token ${token}" +fi +cmd+=" --allow-no-subscriptions${log_severity}" +echo "Run: ${cmd}" +eval "${cmd}" 1> >(tee -a "${log}") 2> >(tee -a "${log}" >&2) +az account set -s "${SUBSCRIPTION_ID}" 1> >(tee -a "${log}") 2> >(tee -a "${log}" >&2) diff --git a/scripts/azure-cost.sh b/scripts/azure-cost.sh new file mode 100755 index 0000000..df83ec9 --- /dev/null +++ b/scripts/azure-cost.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +# Copyright (c) Innofactor Plc & AUTHORS +# SPDX-License-Identifier: BSD-3-Clause +# +set -e +mkdir -p "${LOG_PATH}" +log="${LOG_PATH}/step_${LOG_ORDER}_${LOG_NAME}.log" +trap 'error $? $LINENO "$BASH_COMMAND" $log' ERR +trap cleanup EXIT +cleanup() { + if test -z "${TF_BUILD-}"; then + echo '::endgroup::' + fi +} +error() { + local msg + msg="Error on or near line $(("${2}" + 1)) (exit code ${1})" + msg+=" in ${LOG_NAME/_/ } at $(date '+%Y-%m-%d %H:%M:%S')" + if test -n "${TF_BUILD-}"; then + echo "##[error]${msg}" + else + echo "::error::${msg}" + fi + log_output "${4}" "${msg}" "${3}" + exit "${1}" +} +log_output() { + local data='' + local output='' + local over='' + local summary='' + if test -f "${1}"; then + data=$(cat "${1}") + fi + if test -n "${2}"; then + summary="The ${LOG_NAME/_/ } failed. ${2}❗" + if test -n "${3}"; then + summary+='\n\nCommand that failed:\n\n```text\n' + summary+="$(eval echo "${3}")" + summary+='\n```' + fi + elif test -z "${data}"; then + summary="The ${LOG_NAME/_/ } failed. No output found❗" + fi + if test -n "${summary}"; then + summary="\n\n${summary}" + fi + local output="## Cost estimate${summary}" + local file="${LOG_NAME}.json" + if test -f "${file}"; then + mv -f "${file}" "${LOG_PATH}/" + file="${LOG_PATH}/${LOG_NAME}.json" + local currency + local delta + local total + local txt='' + total=$(jq -r '.TotalCost.Value | select (.!=null)' "${file}") + delta=$(jq -r '.Delta.Value | select (.!=null)' "${file}") + currency=$(jq -r '.Currency | select (.!=null)' "${file}") + if [ "${total}" != "${delta}" ]; then + if [ "${delta}" = '0.00' ]; then + txt='No cost change detected! ' + elif [[ "${delta}" =~ ^[0-9]+(\.[0-9]+)?$ ]]; then + if [ "$(echo "${delta} > 0" | bc -l)" -eq 1 ]; then + txt="Estimated increase is +${delta} ${currency}! " + else + txt="Estimated decrease is -${delta} ${currency}! " + fi + fi + if [[ "${total}" =~ ^[0-9]+(\.[0-9]+)?$ ]]; then + txt+="Total estimated cost is ${total} ${currency}." + fi + elif [ "${total}" = '0.00' ]; then + txt='No cost detected!' + elif [[ "${total}" =~ ^[0-9]+(\.[0-9]+)?$ ]]; then + txt="Estimated cost increase is +${total} ${currency}!" + fi + if [[ "${THRESHOLD}" =~ ^[0-9]+(\.[0-9]+)?$ ]] && \ + [[ "${total}" =~ ^[0-9]+(\.[0-9]+)?$ ]] && \ + [ "$(echo "${total} > ${THRESHOLD}" | bc -l)" -eq 1 ] + then + over="Total estimated cost exceeds ${THRESHOLD} ${currency}❗" + txt+="\n\n${over}" + fi + output+="\n\n${txt}" + fi + if test -n "${data}"; then + output+='\n\n
Click for details' + output+='\n\n```text\n' + output+="${data}\n" + output+='```\n' + output+='\n
' + fi + echo -e "${output}" > "${1/.log/.md}" + if test -n "${over}"; then + if test -n "${TF_BUILD-}"; then + echo "##[error]${over}" + else + echo "::error::${over}" + fi + if test -z "${2}"; then + exit 1 + fi + fi +} +if test -z "${TF_BUILD-}"; then + echo "::group::Output" +fi +if ! test -f "${TEMPLATE_FILE}"; then + if test -n "${TF_BUILD-}"; then + echo "##[error]Unable to find ${TEMPLATE_FILE}." + else + echo "::error::Unable to find ${TEMPLATE_FILE}." + fi + exit 1 +fi +cmd='./708gyals2sgas/azure-cost-estimator' +file='linux-x64.zip' +uri='https://github.com/TheCloudTheory/arm-estimator/' +uri+="releases/download/${VERSION_ACE}/${file}" +mkdir -p "708gyals2sgas" +echo "Download ${uri}" +HTTP_CODE=$(curl -sSL --retry 4 --output "708gyals2sgas/${file}" \ + --write-out "%{response_code}" "${uri}" +) +if [ "${HTTP_CODE}" -lt 200 ] || [ "${HTTP_CODE}" -gt 299 ]; then + if test -n "${TF_BUILD-}"; then + echo "##[error]Unable to get ${file}! Response code: ${HTTP_CODE}" + else + echo "::error::Unable to get ${file}! Response code: ${HTTP_CODE}" + fi + exit 1 +fi +unzip -q "708gyals2sgas/${file}" -d '708gyals2sgas' +chmod +x ./708gyals2sgas/azure-cost-estimator +PATH=$PATH:$(readlink -f '708gyals2sgas/') +case "${IN_SCOPE}" in + tenant) cmd+=" ${IN_SCOPE} ${TEMPLATE_FILE} ${IN_LOCATION}";; + mg) cmd+=" ${IN_SCOPE} ${TEMPLATE_FILE} ${IN_MANAGEMENT_GROUP} ${IN_LOCATION}";; + sub) cmd+=" ${IN_SCOPE} ${TEMPLATE_FILE} ${SUBSCRIPTION_ID} ${IN_LOCATION}";; + group) cmd+=" ${TEMPLATE_FILE} ${SUBSCRIPTION_ID} ${IN_RESOURCE_GROUP}";; +esac +if [[ $TEMPLATE_PARAMS_FILE == *.parameters.json ]]; then + cmd+=" --parameters ${TEMPLATE_PARAMS_FILE}" +fi +if [[ $IN_TEMPLATE_PARAMS == *=* ]]; then + IFS=' ' read -ra param_list <<< "${IN_TEMPLATE_PARAMS}" + for pair in "${param_list[@]}"; do + if test -n "${pair%=*}" && [[ "${pair}" == *=* ]]; then + cmd+=" --inline ${pair%=*}=${pair#*=}" + fi + done +fi +cmd+=" --currency ${IN_CURRENCY}" +cmd+=' --disable-cache --generateJsonOutput' +cmd+=" --jsonOutputFilename ${LOG_NAME}" +echo "Run: ${cmd}" +eval "${cmd}" 1> >(tee -a "${log}") 2> >(tee -a "${log}" >&2) +log_output "${log}" '' '' diff --git a/scripts/azure-providers.sh b/scripts/azure-providers.sh new file mode 100755 index 0000000..94cc55e --- /dev/null +++ b/scripts/azure-providers.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# Copyright (c) Innofactor Plc & AUTHORS +# SPDX-License-Identifier: BSD-3-Clause +# +set -e +mkdir -p "${LOG_PATH}" +log="${LOG_PATH}/step_${LOG_ORDER}_${LOG_NAME}.log" +trap 'error $? $LINENO "$BASH_COMMAND" $log' ERR +trap cleanup EXIT +cleanup() { + if test -z "${TF_BUILD-}"; then + echo '::endgroup::' + fi +} +error() { + local msg + msg="Error on or near line $(("${2}" + 1)) (exit code ${1})" + msg+=" in ${LOG_NAME/_/ } at $(date '+%Y-%m-%d %H:%M:%S')" + if test -n "${TF_BUILD-}"; then + echo "##[error]${msg}" + else + echo "::error::${msg}" + fi + log_output "${4}" "${msg}" "${3}" + exit "${1}" +} +log_output() { + local data='' + local summary='' + if test -f "${1}"; then + data=$(cat "${1}") + fi + if test -n "${2}"; then + summary="The ${LOG_NAME/_/ } failed. ${2}❗" + if test -n "${3}"; then + summary+='\n\nCommand that failed:\n\n```text\n' + summary+="$(eval echo "${3}")" + summary+='\n```' + fi + elif test -z "${data}"; then + summary="The ${LOG_NAME/_/ } failed. No output found❗" + fi + if test -n "${summary}"; then + summary="\n\n${summary}" + fi + local output="## Resource providers${summary}" + if test -n "${data}"; then + output+='\n\n
Click for details' + output+='\n\n```text\n' + output+="${data}\n" + output+='```\n\n
' + fi + echo -e "${output}" > "${1/.log/.md}" +} +if test -z "${TF_BUILD-}"; then + echo "::group::Output" +fi +IFS=',' read -ra provider_list <<< "${IN_PROVIDERS}" +provider_sorted="$(printf '%s\n' "${provider_list[@]}" | sort -u | tr '\n' ',')" +IFS=',' read -ra providers <<< "${provider_sorted}" +checkProviders=() +case "${IN_SEVERITY}" in + ERROR) log_severity=' --only-show-errors';; + VERBOSE) log_severity=' --verbose';; + DEBUG) log_severity=' --debug';; + *) log_severity='';; +esac +out_option="-o tsv${log_severity}" +consent_option="--consent-to-permissions${log_severity}" +if test -n "${TF_BUILD-}"; then + az account set -s "${SUBSCRIPTION_ID}" 1> >(tee -a "${log}") 2> >(tee -a "${log}" >&2) +fi +echo 'Check resource providers...' +checkProviders=() +registered='' +registered_result=$(az provider list --query "[?registrationState=='Registered'].namespace" -o tsv) +if test -n "${registered_result}"; then + IFS=',' read -ra registered_list <<< "$(echo "${registered_result}" | tr '\n' ',')" + echo 'Currently registered:' | tee -a "${log}" + printf '%s\n' "${registered_list[@]}" | sort | tee -a "${log}" + registered=$(echo "${registered_list[*]}" | tr '[:upper:]' '[:lower:]') +else + echo 'Could not find any registered providers!' | tee -a "${log}" +fi +for provider in "${providers[@]}"; do + value=$(echo " ${provider} " | tr '[:upper:]' '[:lower:]') + if [[ ! " ${registered} " =~ ${value} ]]; then + echo "Register ${provider}..." | tee -a "${log}" + cmd='az provider register --namespace' + cmd+=" ${provider}" + cmd+=" ${consent_option}" + eval "${cmd}" 1> >(tee -a "${log}") 2> >(tee -a "${log}" >&2) + checkProviders+=("${provider}") + fi +done +if [ ${#checkProviders} -eq 0 ]; then + echo 'All providers registered!' | tee -a "${log}" +else + for provider in "${checkProviders[@]}"; do + state='Registering' + timesTried=0 + while [ "${state}" != 'Registered' ] || \ + [ "${timesTried}" -gt "${WAIT_COUNT}" ] + do + echo "Waiting for ${provider} to register..." + cmd='az provider show --query "registrationState" --namespace' + cmd+=" ${provider}" + cmd+=" ${out_option}" + state=$( + eval "${cmd}" 1> >(tee -a "${log}") 2> >(tee -a "${log}" >&2) + ) + timesTried=$(("${timesTried}" + 1)) + sleep "${WAIT_SECONDS}" + done + if ! [ "${state}" = 'Registered' ]; then + echo "Timeout: ${provider} in ${state} state..." | tee -a "${log}" + else + echo 'Providers successfully registered!' | tee -a "${log}" + fi + done +fi +log_output "${log}" '' '' diff --git a/scripts/install-tools.sh b/scripts/install-tools.sh new file mode 100755 index 0000000..5a1de7f --- /dev/null +++ b/scripts/install-tools.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Copyright (c) Innofactor Plc & AUTHORS +# SPDX-License-Identifier: BSD-3-Clause +# +set -e +mkdir -p "${LOG_PATH}" +log="${LOG_PATH}/step_${LOG_ORDER}_${LOG_NAME}.log" +trap 'error $? $LINENO "$BASH_COMMAND" $log' ERR +error() { + local msg + msg="Error on or near line $(("${2}" + 1)) (exit code ${1})" + msg+=" in ${LOG_NAME/_/ } at $(date '+%Y-%m-%d %H:%M:%S')" + if test -n "${TF_BUILD-}"; then + echo "##[error]${msg}" + else + echo "::error::${msg}" + fi + log_output "${4}" "${msg}" "${3}" + exit "${1}" +} +log_output() { + local summary="${2}❗" + if test -n "${3}"; then + summary+='\n\nCommand that failed:\n\n```text\n' + summary+="$(eval echo "${3}")" + summary+='\n```' + fi + local data + data=$(cat "${1}" 2>/dev/null || true) + local output="## Install tools\n\n${summary}" + if test -n "${data}"; then + output+='\n\n```text\n' + output+="${data}\n" + output+='```' + fi + echo -e "${output}" > "${1/.log/.md}" +} +az_version=$(az version | jq -r '."azure-cli"') +echo "Azure CLI ${az_version} with extensions:" | tee -a "${log}" +az version --query extensions -o yaml | tee -a "${log}" +if [[ $IN_TEMPLATE == *.bicep ]]; then + az config set bicep.use_binary_from_path=False >/dev/null 2>&1 + cmd="az bicep install" + case "${IN_SEVERITY}" in + ERROR) cmd+=' --only-show-errors';; + VERBOSE) cmd+=' --verbose';; + DEBUG) cmd+=' --debug';; + esac + echo "Run: ${cmd}" + eval "${cmd}" 1> >(tee -a "${log}") 2> >(tee -a "${log}" >&2) +fi diff --git a/scripts/pr-comment.sh b/scripts/pr-comment.sh new file mode 100755 index 0000000..987be07 --- /dev/null +++ b/scripts/pr-comment.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Copyright (c) Innofactor Plc & AUTHORS +# SPDX-License-Identifier: BSD-3-Clause +# +set -e +mkdir -p "${LOG_PATH}" +add_output() { + local data='' + local name='' + local output='' + while read -r file; do + data=$(cat "${file}") + if test -n "${data}"; then + name=$(basename "${file}") + name=${name:8} + name=${name/_/ } + name=${name/psrule/PSRule} + name=${name/.md/} + name="$(tr '[:lower:]' '[:upper:]' <<< "${name:0:1}")${name:1}" + if [ ${#data} -gt 63900 ]; then + data=$(echo -e "## ${name}\n\nThe ${name} output is too long!") + fi + if test -z "${output}"; then + output="${data}" + else + output+="$(echo -e "\n\n${data}")" + fi + fi + done + echo "${output}" +} +output=$(find "${LOG_PATH}" -name 'step_*.md' -maxdepth 1 -type f | sort | add_output) +case "${JOB_STATUS}" in + cancelled|Canceled) summary='The job was cancelled ❎';; + failed|Failed) summary='The job failed β›”';; + *) summary='The job completed successfully βœ…';; +esac +if test -z "${output}"; then + summary+=' Output is missing β­•' +fi +summary+='\n\nPR | Commit | Run | Actor | Action\n' +summary+='---|---|---|---|---\n' +summary+="${EVENT_NO} | ${COMMIT_SHA} | [${RUN_NUMBER}](${JOB_URL}) |" +summary+=" ${EVENT_ACTOR} | ${EVENT_ACTION}\n\n" +if [ "${LOG_NAME}" = 'plan_comment' ]; then + output="# Plan for ${JOB_NAME}\n\n${summary}${output}" +else + output="# ${JOB_NAME}\n\n${summary}${output}" +fi +output=$(echo -e "${output}") +echo "Comment has ${#output} characters." +echo "${output}" > "${LOG_PATH}/${LOG_NAME}.md" +if test -n "${TF_BUILD-}"; then + echo "##vso[task.uploadsummary]${LOG_PATH}/${LOG_NAME}.md" + data=$(jq --arg content "${output}" '.comments[0].content = $content' <<< '{"comments": [{"parentCommentId": 0,"content": "","commentType": 1}],"status": 1}') +else + echo "${output}" >> "$GITHUB_STEP_SUMMARY" + data=$(jq --arg body "${output}" '.body = $body' <<< '{"body": ""}') +fi +HTTP_CODE=$(curl --request POST --data "${data}" \ + --write-out "%{response_code}" --silent --retry 4 \ + --header 'Accept: application/json' \ + --header "Authorization: Bearer ${TOKEN}" \ + --header 'Content-Type: application/json' \ + --output "${LOG_PATH}/comment.log" \ + --url "${COMMENTS_URL// /%20}" +) +if [[ "${HTTP_CODE}" -lt 200 || "${HTTP_CODE}" -gt 299 ]]; then + if test -n "${TF_BUILD-}"; then + echo "##[error]Unable to create comment! Response code: ${HTTP_CODE}}" + else + echo "::error::Unable to create comment! Response code: ${HTTP_CODE}" + fi + if test -f "${LOG_PATH}/comment.log"; then + cat "${LOG_PATH}/comment.log" + fi + exit 1 +fi diff --git a/scripts/psrule-config.sh b/scripts/psrule-config.sh new file mode 100755 index 0000000..53cf156 --- /dev/null +++ b/scripts/psrule-config.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Copyright (c) Innofactor Plc & AUTHORS +# SPDX-License-Identifier: BSD-3-Clause +# +set -e +missing='' +if ! test -f "${OPTION}"; then + missing="Unable to find rule_option file ${OPTION}" +else + echo "Use PSRule config at ${OPTION}" + if test -n "${TEMPLATE_FILE}" && [[ $TEMPLATE_PARAMS_FILE == *.parameters.json ]]; then + file=$TEMPLATE_PARAMS_FILE + t=$(basename "${TEMPLATE_FILE}") + p=$(basename "${file}") + if [ "${t/.json/}" != "${p/.parameters.json/}" ]; then + meta=$(jq -r '.metadata | select (.!=null)' "${file}") + if test -z "${meta}"; then + json=$(jq --arg t "${t}" '. += {metadata:{template:$t}}' "${file}") + if test -n "${json}"; then + printf '%s\n' "${json}" >"${file}" + fi + else + json=$(jq --arg t "${t}" '.metadata += {template:$t}' "${file}") + if test -n "${json}"; then + printf '%s\n' "${json}" >"${file}" + fi + fi + fi + fi +fi +echo "Set output: error='${missing}'" +if test -n "${TF_BUILD-}"; then + echo "##vso[task.setvariable variable=error;isoutput=true]${missing}" +else + echo "error=${missing}" >> "${GITHUB_OUTPUT}" +fi +if test -n "${missing}"; then + if test -n "${TF_BUILD-}"; then + echo "##[error]${missing}" + else + echo "::error::${missing}" + fi + exit 1 +fi diff --git a/scripts/psrule-report.sh b/scripts/psrule-report.sh new file mode 100755 index 0000000..c3f4110 --- /dev/null +++ b/scripts/psrule-report.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Copyright (c) Innofactor Plc & AUTHORS +# SPDX-License-Identifier: BSD-3-Clause +# +set -e +mkdir -p "${LOG_PATH}" +output='' +if test -n "${CONFIG_ERROR}"; then + output='## PSRule\n\n' + output+="${CONFIG_ERROR}❗" +elif test -f "${LOG_PATH}/psrule_analysis.md"; then + data=$(cat "${LOG_PATH}/psrule_analysis.md") + if test -z "${data}" || [ "${data}" = '# PSRule' ]; then + echo 'PSRule report is empty' + else + current_pwd="$(pwd)/" + output='## PSRule' + if test -f psrule_summary.md; then + summary=$( + sed -e ':a' -e 'N' -e '$!ba' psrule_summary.md \ + -e 's|# PSRule result summary\n\n||g' \ + -e 's|## |### |g' + ) + if test -n "${summary}"; then + output+="\n\n${summary}" + mv -f psrule_summary.md "${LOG_PATH}/" + fi + fi + data=$( + echo "${data}" | sed -e ':a' -e 'N' -e '$!ba' \ + -e 's|# PSRule\n\n||g' \ + -e 's|## |### |g' \ + -e "s|${current_pwd}||g" + ) + if test -n "${data}"; then + output+='\n\n
Click for details' + output+="\n\n${data}\n\n
" + fi + echo 'PSRule report created' + fi +fi +if test -n "${output}"; then + echo -e "${output}" > "${LOG_PATH}/step_${LOG_ORDER}_${LOG_NAME}.md" +fi