diff --git a/azure-pipeline/build_frontend.yml b/azure-pipeline/build_frontend.yml index e17e212..38f0cf6 100644 --- a/azure-pipeline/build_frontend.yml +++ b/azure-pipeline/build_frontend.yml @@ -1,47 +1,128 @@ +# ===================================================== +# Template: Build Frontend +# ===================================================== +# Builds the React frontend application using Vite +# Supports validation builds (PR) and artifact publishing (CD) +# ===================================================== + parameters: - name: environment type: string + values: + - dev + - qa + - uat + - prod + + - name: isValidationBuild + type: boolean + default: false + + - name: nodeVersion + type: string + default: "22.x" jobs: - job: Build_Frontend_${{ parameters.environment }} - displayName: "Build Frontend [${{ parameters.environment }}]" + displayName: "πŸ—οΈ Build Frontend [${{ parameters.environment }}]" pool: - vmImage: "ubuntu-latest" + vmImage: ubuntu-latest + variables: - name: VITE_VERSION_NUMBER value: $(Build.BuildNumber) - name: VITE_ENV - value: "${{ parameters.environment }}" + value: ${{ parameters.environment }} - name: VITE_API_URL - value: "$(api-url-${{ parameters.environment }})" + value: $(api-url-${{ parameters.environment }}) - name: VITE_GA_TRACKING_ID - value: "$(ga-tracking-id-${{ parameters.environment }})" + value: $(ga-tracking-id-${{ parameters.environment }}) - name: VITE_GENERATE_SOURCEMAP - value: false + value: ${{ eq(parameters.environment, 'dev') }} + - name: CI + value: true steps: + # --------------------------------------------------- + # Setup Node.js Environment + # --------------------------------------------------- - task: NodeTool@0 - displayName: "Node.js Version" + displayName: "πŸ“¦ Setup Node.js ${{ parameters.nodeVersion }}" inputs: - versionSpec: "22.x" + versionSpec: ${{ parameters.nodeVersion }} + + # --------------------------------------------------- + # Prepare Cache Directory + # --------------------------------------------------- + - script: mkdir -p $(System.DefaultWorkingDirectory)/frontend/.yarn/cache + displayName: "πŸ“ Create cache directory" + workingDirectory: $(System.DefaultWorkingDirectory) + # --------------------------------------------------- + # Cache Dependencies + # --------------------------------------------------- + - task: Cache@2 + displayName: "πŸ“₯ Cache Yarn dependencies" + inputs: + key: 'yarn | "$(Agent.OS)" | frontend/yarn.lock' + restoreKeys: | + yarn | "$(Agent.OS)" + yarn + path: $(System.DefaultWorkingDirectory)/frontend/.yarn/cache + continueOnError: true + + # --------------------------------------------------- + # Install Dependencies + # --------------------------------------------------- - script: | - cd frontend corepack enable yarn set version stable - yarn - yarn build - displayName: "Install and Build using Yarn" + yarn install --immutable + displayName: "πŸ“₯ Install dependencies" + workingDirectory: $(System.DefaultWorkingDirectory)/frontend + env: + YARN_CACHE_FOLDER: $(System.DefaultWorkingDirectory)/frontend/.yarn/cache - - task: CopyFiles@2 - displayName: "Copy files to build folder" - inputs: - sourceFolder: "$(System.DefaultWorkingDirectory)/frontend/dist" - targetFolder: "$(Build.ArtifactStagingDirectory)" - cleanTargetFolder: true + # --------------------------------------------------- + # Code Quality Checks (PR only) + # --------------------------------------------------- + - ${{ if eq(parameters.isValidationBuild, true) }}: + - script: yarn lint + displayName: "πŸ” Run linting" + workingDirectory: $(System.DefaultWorkingDirectory)/frontend - - task: PublishBuildArtifacts@1 - displayName: "Publish artifact" - inputs: - pathtoPublish: "$(Build.ArtifactStagingDirectory)" - artifactName: "build_frontend_${{ parameters.environment }}" + - script: yarn tsc --noEmit + displayName: "πŸ” TypeScript type check" + workingDirectory: $(System.DefaultWorkingDirectory)/frontend + continueOnError: false + + # --------------------------------------------------- + # Build Application + # --------------------------------------------------- + - script: yarn build + displayName: "πŸ—οΈ Build application" + workingDirectory: $(System.DefaultWorkingDirectory)/frontend + env: + VITE_VERSION_NUMBER: $(VITE_VERSION_NUMBER) + VITE_ENV: $(VITE_ENV) + VITE_API_URL: $(VITE_API_URL) + VITE_GA_TRACKING_ID: $(VITE_GA_TRACKING_ID) + VITE_GENERATE_SOURCEMAP: $(VITE_GENERATE_SOURCEMAP) + + # --------------------------------------------------- + # Publish Artifacts (CD only) + # --------------------------------------------------- + - ${{ if eq(parameters.isValidationBuild, false) }}: + - task: CopyFiles@2 + displayName: "πŸ“‹ Copy build files" + inputs: + sourceFolder: $(System.DefaultWorkingDirectory)/frontend/dist + targetFolder: $(Build.ArtifactStagingDirectory)/frontend + cleanTargetFolder: true + + - task: PublishPipelineArtifact@1 + displayName: "πŸ“€ Publish build artifact" + inputs: + targetPath: $(Build.ArtifactStagingDirectory)/frontend + artifact: frontend-${{ parameters.environment }} + publishLocation: pipeline diff --git a/azure-pipeline/deploy_frontend.yml b/azure-pipeline/deploy_frontend.yml index 1d07fb0..0f84151 100644 --- a/azure-pipeline/deploy_frontend.yml +++ b/azure-pipeline/deploy_frontend.yml @@ -1,53 +1,157 @@ +# ===================================================== +# Template: Deploy Frontend +# ===================================================== +# Deploys React frontend to Azure Blob Storage +# with CDN cache purge +# ===================================================== + parameters: - name: environment type: string + values: + - dev + - qa + - uat + - prod + - name: commandOptions type: string - - name: depends_on + + - name: dependsOn type: object - default: "" + default: [] + + - name: terraformVersion + type: string + default: "1.10.3" jobs: - - deployment: "Deploy_Frontend_${{ parameters.environment }}" - displayName: "Deploy Frontend [${{ parameters.environment }}]" + - deployment: Deploy_Frontend_${{ parameters.environment }} + displayName: "πŸš€ Deploy Frontend [${{ parameters.environment }}]" environment: ${{ parameters.environment }} pool: - vmImage: "windows-latest" - ${{ if ne(parameters.depends_on, '')}}: - dependsOn: ${{ parameters.depends_on }} + vmImage: ubuntu-latest + ${{ if ne(length(parameters.dependsOn), 0) }}: + dependsOn: ${{ parameters.dependsOn }} + condition: succeeded() + + variables: + # Storage account: st + project_short_name + environment (no hyphens, max 24 chars) + - name: storageAccountName + value: st$(PROJECT_SHORT_NAME)${{ parameters.environment }} + - name: resourceGroupName + value: rg-$(PROJECT_SHORT_NAME)-${{ parameters.environment }} + - name: frontdoorProfileName + value: afd-$(PROJECT_SHORT_NAME)-${{ parameters.environment }} + - name: frontdoorEndpointName + value: ep-$(PROJECT_SHORT_NAME)-${{ parameters.environment }} strategy: runOnce: deploy: steps: + # --------------------------------------------------- + # Checkout Repository + # --------------------------------------------------- - checkout: self + clean: true + fetchDepth: 1 + + # --------------------------------------------------- + # Download Build Artifact + # --------------------------------------------------- + - task: DownloadPipelineArtifact@2 + displayName: "πŸ“₯ Download build artifact" + inputs: + artifact: frontend-${{ parameters.environment }} + path: $(Pipeline.Workspace)/frontend + # --------------------------------------------------- + # Apply Terraform Infrastructure + # --------------------------------------------------- - template: terraform_steps.yml parameters: environment: ${{ parameters.environment }} commandOptions: ${{ parameters.commandOptions }} - lastCommand: "apply" + command: apply + terraformVersion: ${{ parameters.terraformVersion }} + + # --------------------------------------------------- + # Deploy to Azure Blob Storage + # --------------------------------------------------- + - task: AzureCLI@2 + displayName: "πŸ—‘οΈ Clear existing blobs" + inputs: + azureSubscription: $(ARM_SERVICE_CONNECTION_NAME) + scriptType: bash + scriptLocation: inlineScript + addSpnToEnvironment: true + failOnStandardError: true + inlineScript: | + set -euo pipefail + + echo "Clearing existing content from storage..." + az storage blob delete-batch \ + --account-name "$(storageAccountName)" \ + --source '$web' \ + --auth-mode login \ + --only-show-errors - task: AzureCLI@2 - displayName: "Empty container and copy React build to blob storage" + displayName: "πŸ“€ Upload build to storage" inputs: - azureSubscription: "$(ARM_SERVICE_CONNECTION_NAME)" - scriptType: ps - scriptLocation: "inlineScript" + azureSubscription: $(ARM_SERVICE_CONNECTION_NAME) + scriptType: bash + scriptLocation: inlineScript addSpnToEnvironment: true + failOnStandardError: true inlineScript: | - $storageAccountName = "sa$(PROJECT_SHORT_NAME)${{ parameters.environment }}" - $buildSourcePath = "$(Pipeline.Workspace)/build_frontend_${{ parameters.environment }}" - $containerName = '$web' + set -euo pipefail - az storage blob delete-batch --account-name $storageAccountName --source $containerName - az storage blob upload-batch --account-name $storageAccountName --destination $containerName --source $buildSourcePath + echo "Uploading build to Azure Blob Storage..." + az storage blob upload-batch \ + --account-name "$(storageAccountName)" \ + --destination '$web' \ + --source "$(Pipeline.Workspace)/frontend" \ + --auth-mode login \ + --overwrite \ + --only-show-errors + echo "βœ… Upload completed successfully" + + # --------------------------------------------------- + # Purge Azure Front Door Cache + # --------------------------------------------------- - task: AzureCLI@2 - displayName: "Purge CDN after React build deployment" + displayName: "πŸ”„ Purge Front Door cache" inputs: - azureSubscription: "$(ARM_SERVICE_CONNECTION_NAME)" - scriptType: ps - scriptLocation: "inlineScript" + azureSubscription: $(ARM_SERVICE_CONNECTION_NAME) + scriptType: bash + scriptLocation: inlineScript + failOnStandardError: false inlineScript: | - az cdn endpoint purge -g rg-${{ parameters.environment }}-$(PROJECT_SHORT_NAME) -n cdne-${{ parameters.environment }}-$(PROJECT_SHORT_NAME) --profile-name cdnp-${{ parameters.environment }}-$(PROJECT_SHORT_NAME) --content-paths '/*' --no-wait + set -euo pipefail + + echo "Purging Azure Front Door cache..." + az afd endpoint purge \ + --resource-group "$(resourceGroupName)" \ + --endpoint-name "$(frontdoorEndpointName)" \ + --profile-name "$(frontdoorProfileName)" \ + --content-paths '/*' \ + --no-wait + + echo "βœ… Front Door cache purge initiated" + + # --------------------------------------------------- + # Deployment Summary + # --------------------------------------------------- + - script: | + echo "==============================================" + echo "πŸŽ‰ Deployment Summary" + echo "==============================================" + echo "Environment: ${{ parameters.environment }}" + echo "Build Version: $(Build.BuildNumber)" + echo "Storage Account: $(storageAccountName)" + echo "Front Door: $(frontdoorEndpointName)" + echo "==============================================" + displayName: "πŸ“Š Deployment summary" diff --git a/azure-pipeline/deploy_validation.yml b/azure-pipeline/deploy_validation.yml index 750a3cf..6cb56ac 100644 --- a/azure-pipeline/deploy_validation.yml +++ b/azure-pipeline/deploy_validation.yml @@ -1,20 +1,62 @@ +# ===================================================== +# Template: Deploy Validation +# ===================================================== +# Manual approval gate for production deployments +# Requires user validation before proceeding +# ===================================================== + parameters: - name: environment type: string - - name: depends_on + values: + - dev + - qa + - uat + - prod + + - name: dependsOn type: object + default: [] + + - name: timeoutMinutes + type: number + default: 60 + + - name: notifyUsers + type: string default: "" jobs: - job: Deploy_Validation_${{ parameters.environment }} - displayName: "Deploy Validation [${{ parameters.environment }}]" - ${{ if ne(parameters.depends_on, '')}}: - dependsOn: ${{ parameters.depends_on }} + displayName: "⏸️ Approval Gate [${{ parameters.environment }}]" + ${{ if ne(length(parameters.dependsOn), 0) }}: + dependsOn: ${{ parameters.dependsOn }} pool: server - timeoutInMinutes: 60 + timeoutInMinutes: ${{ parameters.timeoutMinutes }} + condition: succeeded() + steps: - - task: ManualValidation@0 - timeoutInMinutes: 60 + - task: ManualValidation@1 + displayName: "⏸️ Waiting for approval" + timeoutInMinutes: ${{ parameters.timeoutMinutes }} inputs: - instructions: "Please validate the Terraform plan for the environment before deploying. Approving this will deploy in the [${{ parameters.environment }}] environment." - onTimeout: "reject" + notifyUsers: ${{ parameters.notifyUsers }} + instructions: | + ## πŸš€ Deployment Approval Required + + **Environment:** `${{ parameters.environment }}` + **Build Version:** `$(Build.BuildNumber)` + **Requested By:** `$(Build.RequestedFor)` + + ### ⚠️ Pre-deployment Checklist + + Please verify the following before approving: + + - [ ] Terraform plan has been reviewed + - [ ] Build artifacts are correct + - [ ] All required tests have passed + + --- + + **Approving this will deploy to the `${{ parameters.environment }}` environment.** + onTimeout: reject diff --git a/azure-pipeline/environments_loop.yml b/azure-pipeline/environments_loop.yml index cc519f4..6cbecf9 100644 --- a/azure-pipeline/environments_loop.yml +++ b/azure-pipeline/environments_loop.yml @@ -1,45 +1,234 @@ +# ===================================================== +# Template: Environment Deployment Loop +# ===================================================== +# Iterates through environments and orchestrates the +# complete deployment pipeline for each +# ===================================================== + parameters: - name: environments type: object default: - - dev - - qa - # - uat - # - staging - # - prod + - name: dev + requiresApproval: false + dependsOn: [] + - name: qa + requiresApproval: false + dependsOn: + - Deploy_dev + - name: uat + requiresApproval: true + dependsOn: + - Deploy_qa + - name: prod + requiresApproval: true + dependsOn: + - Deploy_uat + + - name: nodeVersion + type: string + default: "22.x" + + - name: terraformVersion + type: string + default: "1.10.3" + + # Enable/disable environments + - name: enableDev + type: boolean + default: true + + - name: enableQa + type: boolean + default: true + + - name: enableUat + type: boolean + default: false + + - name: enableProd + type: boolean + default: false stages: - - ${{ each environment in parameters.environments }}: - - stage: "Deploy_${{ environment }}" - displayName: "Deploy [${{ environment }}]" - condition: and(not(or(failed(), canceled())), not(eq(variables['Build.Reason'], 'PullRequest'))) + # ===================================================== + # Development Environment + # ===================================================== + - ${{ if eq(parameters.enableDev, true) }}: + - stage: Deploy_dev + displayName: "🟒 Deploy [dev]" + condition: | + and( + succeeded(), + ne(variables['Build.Reason'], 'PullRequest') + ) + variables: + - group: web-react-skeleton-dev + - name: commandOptions + value: >- + --var-file=env/dev.tfvars + -var="project_short_name=$(PROJECT_SHORT_NAME)" + -input=false + jobs: + - template: terraform_plan.yml + parameters: + environment: dev + commandOptions: $(commandOptions) + terraformVersion: ${{ parameters.terraformVersion }} + + - template: build_frontend.yml + parameters: + environment: dev + nodeVersion: ${{ parameters.nodeVersion }} + + - template: deploy_frontend.yml + parameters: + environment: dev + commandOptions: $(commandOptions) + terraformVersion: ${{ parameters.terraformVersion }} + dependsOn: + - Terraform_Plan_dev + - Build_Frontend_dev + + # ===================================================== + # QA Environment + # ===================================================== + - ${{ if eq(parameters.enableQa, true) }}: + - stage: Deploy_qa + displayName: "🟑 Deploy [qa]" + dependsOn: + - ${{ if eq(parameters.enableDev, true) }}: + - Deploy_dev + condition: | + and( + not(or(failed(), canceled())), + ne(variables['Build.Reason'], 'PullRequest') + ) variables: - - group: "web-react-skeleton-${{ environment }}" + - group: web-react-skeleton-qa - name: commandOptions - value: > - --var-file=env/${{ environment }}.tfvars + value: >- + --var-file=env/qa.tfvars -var="project_short_name=$(PROJECT_SHORT_NAME)" -input=false jobs: - template: terraform_plan.yml parameters: - environment: ${{ environment }} - commandOptions: ${{ variables.commandOptions }} + environment: qa + commandOptions: $(commandOptions) + terraformVersion: ${{ parameters.terraformVersion }} + - template: build_frontend.yml parameters: - environment: ${{ environment }} - - ${{ if eq(environment, 'prod') }}: - - template: deploy_validation.yml - parameters: - environment: ${{ environment }} - depends_on: - - Terraform_Plan_${{ environment }} + environment: qa + nodeVersion: ${{ parameters.nodeVersion }} + + - template: deploy_frontend.yml + parameters: + environment: qa + commandOptions: $(commandOptions) + terraformVersion: ${{ parameters.terraformVersion }} + dependsOn: + - Terraform_Plan_qa + - Build_Frontend_qa + + # ===================================================== + # UAT Environment + # ===================================================== + - ${{ if eq(parameters.enableUat, true) }}: + - stage: Deploy_uat + displayName: "🟠 Deploy [uat]" + dependsOn: + - ${{ if eq(parameters.enableQa, true) }}: + - Deploy_qa + - ${{ if and(eq(parameters.enableQa, false), eq(parameters.enableDev, true)) }}: + - Deploy_dev + condition: | + and( + not(or(failed(), canceled())), + ne(variables['Build.Reason'], 'PullRequest') + ) + variables: + - group: web-react-skeleton-uat + - name: commandOptions + value: >- + --var-file=env/uat.tfvars + -var="project_short_name=$(PROJECT_SHORT_NAME)" + -input=false + jobs: + - template: terraform_plan.yml + parameters: + environment: uat + commandOptions: $(commandOptions) + terraformVersion: ${{ parameters.terraformVersion }} + + - template: build_frontend.yml + parameters: + environment: uat + nodeVersion: ${{ parameters.nodeVersion }} + + - template: deploy_validation.yml + parameters: + environment: uat + dependsOn: + - Terraform_Plan_uat + - Build_Frontend_uat + + - template: deploy_frontend.yml + parameters: + environment: uat + commandOptions: $(commandOptions) + terraformVersion: ${{ parameters.terraformVersion }} + dependsOn: + - Deploy_Validation_uat + + # ===================================================== + # Production Environment + # ===================================================== + - ${{ if eq(parameters.enableProd, true) }}: + - stage: Deploy_prod + displayName: "πŸ”΄ Deploy [prod]" + dependsOn: + - ${{ if eq(parameters.enableUat, true) }}: + - Deploy_uat + - ${{ if and(eq(parameters.enableUat, false), eq(parameters.enableQa, true)) }}: + - Deploy_qa + condition: | + and( + not(or(failed(), canceled())), + ne(variables['Build.Reason'], 'PullRequest') + ) + variables: + - group: web-react-skeleton-prod + - name: commandOptions + value: >- + --var-file=env/prod.tfvars + -var="project_short_name=$(PROJECT_SHORT_NAME)" + -input=false + jobs: + - template: terraform_plan.yml + parameters: + environment: prod + commandOptions: $(commandOptions) + terraformVersion: ${{ parameters.terraformVersion }} + + - template: build_frontend.yml + parameters: + environment: prod + nodeVersion: ${{ parameters.nodeVersion }} + + - template: deploy_validation.yml + parameters: + environment: prod + timeoutMinutes: 120 + dependsOn: + - Terraform_Plan_prod + - Build_Frontend_prod + - template: deploy_frontend.yml parameters: - environment: ${{ environment }} - commandOptions: ${{ variables.commandOptions }} - depends_on: - - ${{ if eq(environment, 'prod') }}: - - Deploy_Validation_${{ environment }} - - Terraform_Plan_${{ environment }} - - Build_Frontend_${{ environment }} + environment: prod + commandOptions: $(commandOptions) + terraformVersion: ${{ parameters.terraformVersion }} + dependsOn: + - Deploy_Validation_prod diff --git a/azure-pipeline/terraform_plan.yml b/azure-pipeline/terraform_plan.yml index cb1f501..53df44f 100644 --- a/azure-pipeline/terraform_plan.yml +++ b/azure-pipeline/terraform_plan.yml @@ -1,17 +1,42 @@ +# ===================================================== +# Template: Terraform Plan +# ===================================================== +# Executes Terraform plan for infrastructure preview +# ===================================================== + parameters: - name: environment type: string + values: + - dev + - qa + - uat + - prod + - name: commandOptions type: string + - name: terraformVersion + type: string + default: "1.10.3" + + - name: dependsOn + type: object + default: [] + jobs: - job: Terraform_Plan_${{ parameters.environment }} - displayName: "Terraform Plan [${{ parameters.environment }}]" + displayName: "πŸ“‹ Terraform Plan [${{ parameters.environment }}]" pool: - vmImage: "ubuntu-latest" + vmImage: ubuntu-latest + ${{ if ne(length(parameters.dependsOn), 0) }}: + dependsOn: ${{ parameters.dependsOn }} + condition: succeeded() + steps: - template: terraform_steps.yml parameters: environment: ${{ parameters.environment }} commandOptions: ${{ parameters.commandOptions }} - lastCommand: "plan" + command: plan + terraformVersion: ${{ parameters.terraformVersion }} diff --git a/azure-pipeline/terraform_steps.yml b/azure-pipeline/terraform_steps.yml index f770bc8..4306626 100644 --- a/azure-pipeline/terraform_steps.yml +++ b/azure-pipeline/terraform_steps.yml @@ -1,40 +1,94 @@ +# ===================================================== +# Template: Terraform Steps +# ===================================================== +# Reusable Terraform workflow steps for init, validate, +# plan, and apply operations +# ===================================================== + parameters: - name: environment type: string + values: + - dev + - qa + - uat + - prod + - name: commandOptions type: string - - name: lastCommand + + - name: command + type: string + values: + - plan + - apply + + - name: terraformVersion + type: string + default: "1.10.3" + + - name: workingDirectory type: string + default: $(System.DefaultWorkingDirectory)/terraform steps: + # --------------------------------------------------- + # Install Terraform + # --------------------------------------------------- - task: TerraformInstaller@1 - displayName: "Terraform Install" + displayName: "βš™οΈ Install Terraform ${{ parameters.terraformVersion }}" inputs: - terraformVersion: "1.9.2" + terraformVersion: ${{ parameters.terraformVersion }} + # --------------------------------------------------- + # Initialize Terraform + # --------------------------------------------------- - task: TerraformTaskV4@4 - displayName: "Terraform Initialize" + displayName: "βš™οΈ Terraform Init" inputs: - provider: "azurerm" - command: "init" - workingDirectory: $(System.DefaultWorkingDirectory)/terraform + provider: azurerm + command: init + workingDirectory: ${{ parameters.workingDirectory }} backendServiceArm: $(ARM_SERVICE_CONNECTION_NAME) - backendAzureRmResourceGroupName: "rg-global-$(PROJECT_SHORT_NAME)" - backendAzureRmStorageAccountName: "wrstfstorage" - backendAzureRmContainerName: "tfstate" - backendAzureRmKey: "${{ parameters.environment }}.tfstate" + backendAzureRmResourceGroupName: rg-terraform-state + backendAzureRmStorageAccountName: stterraformstate + backendAzureRmContainerName: tfstate + backendAzureRmKey: reactweb.${{ parameters.environment }}.tfstate + # --------------------------------------------------- + # Validate Terraform Configuration + # --------------------------------------------------- - task: TerraformTaskV4@4 - displayName: "Terraform Validate" + displayName: "βœ… Terraform Validate" inputs: - provider: "azurerm" - command: "validate" + provider: azurerm + command: validate + workingDirectory: ${{ parameters.workingDirectory }} + # --------------------------------------------------- + # Execute Terraform Command (Plan or Apply) + # --------------------------------------------------- - task: TerraformTaskV4@4 - displayName: "Terraform ${{ parameters.lastCommand }}" + displayName: "πŸ”§ Terraform ${{ parameters.command }}" inputs: - provider: "azurerm" - command: ${{ parameters.lastCommand }} - commandOptions: "${{ parameters.commandOptions }}" - workingDirectory: "$(System.DefaultWorkingDirectory)/terraform" + provider: azurerm + command: ${{ parameters.command }} + ${{ if eq(parameters.command, 'apply') }}: + commandOptions: ${{ parameters.commandOptions }} -auto-approve + ${{ else }}: + commandOptions: ${{ parameters.commandOptions }} + workingDirectory: ${{ parameters.workingDirectory }} environmentServiceNameAzureRm: $(ARM_SERVICE_CONNECTION_NAME) + + # --------------------------------------------------- + # Summary Output + # --------------------------------------------------- + - script: | + echo "==============================================" + echo "πŸ—οΈ Terraform ${{ parameters.command }} Complete" + echo "==============================================" + echo "Environment: ${{ parameters.environment }}" + echo "Command: ${{ parameters.command }}" + echo "Version: ${{ parameters.terraformVersion }}" + echo "==============================================" + displayName: "πŸ“Š Terraform summary" diff --git a/azure-pipelines.yml b/azure-pipelines.yml index bd1ae7c..c79aa27 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -1,20 +1,57 @@ +# ===================================================== +# Azure DevOps Pipeline - React Frontend CI/CD +# ===================================================== +# This pipeline handles: +# - CI: Build validation on pull requests +# - CD: Multi-environment deployment (dev, qa, uat, prod) +# - Infrastructure: Terraform-based IaC deployment +# ===================================================== + trigger: branches: include: + - main + - releases/* + +pr: + branches: + include: + - main - releases/* -name: v0.01$(Rev:.rr) +# Semantic versioning with build counter +name: "1.0.$(Rev:r)" +# ===================================================== +# Global Variables +# ===================================================== variables: - group: web-react-skeleton-azure + - name: nodeVersion + value: "24.x" + - name: terraformVersion + value: "1.14.4" +# ===================================================== +# Pipeline Stages +# ===================================================== stages: - - stage: Pull_request_build_stage - displayName: "Pull Request Build Stage" - condition: and(succeeded(), eq(variables['Build.Reason'], 'PullRequest')) + # --------------------------------------------------- + # Stage: PR Validation Build + # --------------------------------------------------- + - stage: PullRequest_Validation + displayName: "πŸ” PR Validation" + condition: eq(variables['Build.Reason'], 'PullRequest') jobs: - template: azure-pipeline/build_frontend.yml parameters: - environment: "dev" + environment: dev + isValidationBuild: true + # --------------------------------------------------- + # Stage: Environment Deployments + # --------------------------------------------------- - template: azure-pipeline/environments_loop.yml + parameters: + nodeVersion: $(nodeVersion) + terraformVersion: $(terraformVersion) diff --git a/frontend/src/styles/_export.scss b/frontend/src/styles/_export.scss index 95b0e83..034d6f4 100755 --- a/frontend/src/styles/_export.scss +++ b/frontend/src/styles/_export.scss @@ -4,17 +4,17 @@ = Exports = ============================================ */ -$_property: (gap); +$-property: (gap); -$_property-with-direction: ( +$-property-with-direction: ( m: margin, p: padding, ); -$_position: (top, bottom, left, right); +$-position: (top, bottom, left, right); // Property-spacing (eg: gap-xs -> gap: get-spacing(xs)) -@each $propertyKey in $_property { +@each $propertyKey in $-property { @each $spacingKey, $spacingValue in v.$spacing { #body .#{$propertyKey}-#{$spacingKey} { #{$propertyKey}: $spacingValue; @@ -23,7 +23,7 @@ $_position: (top, bottom, left, right); } // Property with direction-spacing (eg: mr-xs -> margin-right: get-spacing(xs)) -@each $propertyKey, $propertyValue in $_property-with-direction { +@each $propertyKey, $propertyValue in $-property-with-direction { @each $spacingKey, $spacingValue in v.$spacing { @each $directionKey, $directionValues in v.$direction { #body .#{$propertyKey}#{$directionKey}-#{$spacingKey} { @@ -40,7 +40,7 @@ $_position: (top, bottom, left, right); } // Position-spacing (eg: top-xs -> top: get-spacing(xs)) -@each $positionKey in $_position { +@each $positionKey in $-position { @each $spacingKey, $spacingValue in v.$spacing { #body .#{$positionKey}-#{$spacingKey} { #{$positionKey}: $spacingValue; diff --git a/frontend/src/styles/_variables.scss b/frontend/src/styles/_variables.scss index 7440938..daee001 100755 --- a/frontend/src/styles/_variables.scss +++ b/frontend/src/styles/_variables.scss @@ -4,14 +4,14 @@ // Allow utility classes to use the same spacing properties defined in the MUI theme $spacing: ( - a: var(--mui-customProperties-spacing-a), - xxs: var(--mui-customProperties-spacing-xxs), - xs: var(--mui-customProperties-spacing-xs), - sm: var(--mui-customProperties-spacing-sm), - md: var(--mui-customProperties-spacing-md), - lg: var(--mui-customProperties-spacing-lg), - xl: var(--mui-customProperties-spacing-xl), - xxl: var(--mui-customProperties-spacing-xxl), + a: var(--mui-custom-properties-spacing-a), + xxs: var(--mui-custom-properties-spacing-xxs), + xs: var(--mui-custom-properties-spacing-xs), + sm: var(--mui-custom-properties-spacing-sm), + md: var(--mui-custom-properties-spacing-md), + lg: var(--mui-custom-properties-spacing-lg), + xl: var(--mui-custom-properties-spacing-xl), + xxl: var(--mui-custom-properties-spacing-xxl), ); $direction: ( diff --git a/frontend/src/styles/mixins/_generics.scss b/frontend/src/styles/mixins/_generics.scss index 54706df..cf73713 100755 --- a/frontend/src/styles/mixins/_generics.scss +++ b/frontend/src/styles/mixins/_generics.scss @@ -3,10 +3,10 @@ ============================================ */ /* stylelint-disable scss/no-global-function-names */ -$_font-base-size: 16; +$-font-base-size: 16; @function rem($sizeInPx) { - @return calc($sizeInPx / $_font-base-size) * 1rem; + @return calc($sizeInPx / $-font-base-size) * 1rem; } @function get($map, $key) { diff --git a/frontend/src/themes/theme.ts b/frontend/src/themes/theme.ts index 93bf7c4..d8dfe0b 100644 --- a/frontend/src/themes/theme.ts +++ b/frontend/src/themes/theme.ts @@ -18,7 +18,7 @@ const theme = createTheme({ return spacingValues[value]; }, // custom properties will also be available as css variables - // for example: --mui-customProperties-spacing-a + // for example: --mui-custom-properties-spacing-a customProperties: { spacing: spacingValues, borderRadius: borderRadius, diff --git a/terraform/env/dev.tfvars b/terraform/env/dev.tfvars index 712e85e..4c16716 100644 --- a/terraform/env/dev.tfvars +++ b/terraform/env/dev.tfvars @@ -1 +1,7 @@ -environment = "dev" +environment = "dev" +project_short_name = "reactweb" +location = "canadacentral" +frontdoor_sku = "Standard_AzureFrontDoor" + +# Set via environment variable or CLI: -var="subscription_id=xxx" +# subscription_id = "" diff --git a/terraform/env/staging.tfvars b/terraform/env/staging.tfvars deleted file mode 100644 index b227228..0000000 --- a/terraform/env/staging.tfvars +++ /dev/null @@ -1 +0,0 @@ -environment = "staging" diff --git a/terraform/main.tf b/terraform/main.tf index 4a1b1c3..7e496f7 100644 --- a/terraform/main.tf +++ b/terraform/main.tf @@ -1,121 +1,181 @@ -data "azurerm_resource_group" "rg_env" { - name = "rg-${var.environment}-${var.project_short_name}" +locals { + resource_prefix = "${var.project_short_name}-${var.environment}" + # Storage account names must be 3-24 chars, lowercase alphanumeric only + storage_account_name = substr(replace("st${var.project_short_name}${var.environment}", "-", ""), 0, 24) + + tags = { + Environment = var.environment + Project = var.project_short_name + ManagedBy = "Terraform" + } + + # Content types for CDN compression + compressed_content_types = [ + "application/javascript", + "application/json", + "application/xml", + "application/wasm", + "text/css", + "text/html", + "text/javascript", + "text/plain", + "text/xml", + "image/svg+xml", + "font/woff", + "font/woff2" + ] } -resource "azurerm_storage_account" "default" { - name = "sa${var.project_short_name}${var.environment}" - resource_group_name = data.azurerm_resource_group.rg_env.name - location = data.azurerm_resource_group.rg_env.location - account_kind = "StorageV2" - account_tier = "Standard" - account_replication_type = "LRS" - enable_https_traffic_only = true +# Resource Group +resource "azurerm_resource_group" "main" { + name = "rg-${local.resource_prefix}" + location = var.location + tags = local.tags +} + +# Storage Account for Static Website +resource "azurerm_storage_account" "web" { + name = local.storage_account_name + resource_group_name = azurerm_resource_group.main.name + location = azurerm_resource_group.main.location + account_tier = "Standard" + account_replication_type = "LRS" + account_kind = "StorageV2" + min_tls_version = "TLS1_2" + https_traffic_only_enabled = true + public_network_access_enabled = true + tags = local.tags static_website { index_document = "index.html" - error_404_document = "404.html" + error_404_document = "index.html" # SPA fallback for React Router } - tags = { - description = "Managed by Terraform" - environment = var.environment + blob_properties { + cors_rule { + allowed_headers = ["*"] + allowed_methods = ["GET", "HEAD", "OPTIONS"] + allowed_origins = ["*"] + exposed_headers = ["*"] + max_age_in_seconds = 3600 + } } } -resource "azurerm_cdn_profile" "default" { - name = "cdnp-${var.environment}-${var.project_short_name}" - resource_group_name = data.azurerm_resource_group.rg_env.name - location = data.azurerm_resource_group.rg_env.location - sku = "Standard_Microsoft" - - tags = { - description = "Managed by Terraform" - environment = var.environment - } +# Azure Front Door Profile +resource "azurerm_cdn_frontdoor_profile" "main" { + name = "afd-${local.resource_prefix}" + resource_group_name = azurerm_resource_group.main.name + sku_name = var.frontdoor_sku + tags = local.tags } -resource "azurerm_cdn_endpoint" "default" { - name = "cdne-${var.environment}-${var.project_short_name}" - profile_name = azurerm_cdn_profile.default.name - location = data.azurerm_resource_group.rg_env.location - resource_group_name = data.azurerm_resource_group.rg_env.name - optimization_type = "GeneralWebDelivery" - querystring_caching_behaviour = "UseQueryString" - origin_host_header = replace(azurerm_storage_account.default.primary_web_host, "https://", "") - - origin { - name = var.project_short_name - host_name = replace(azurerm_storage_account.default.primary_web_host, "https://", "") - https_port = "443" - } +# Front Door Endpoint +resource "azurerm_cdn_frontdoor_endpoint" "web" { + name = "ep-${local.resource_prefix}" + cdn_frontdoor_profile_id = azurerm_cdn_frontdoor_profile.main.id + enabled = true + tags = local.tags +} - delivery_rule { - name = "EnforceHTTPS" - order = 1 +# Front Door Origin Group +resource "azurerm_cdn_frontdoor_origin_group" "web" { + name = "og-web" + cdn_frontdoor_profile_id = azurerm_cdn_frontdoor_profile.main.id + session_affinity_enabled = false - request_scheme_condition { - match_values = ["HTTP", ] - negate_condition = "false" - operator = "Equal" - } + load_balancing { + sample_size = 4 + successful_samples_required = 3 + additional_latency_in_milliseconds = 50 + } - url_redirect_action { - protocol = "Https" - redirect_type = "Found" - } + health_probe { + path = "/" + request_type = "HEAD" + protocol = "Https" + interval_in_seconds = 100 } +} - delivery_rule { - name = "SPArewrite" - order = 2 +# Front Door Origin (Storage Account) +resource "azurerm_cdn_frontdoor_origin" "web" { + name = "origin-storage" + cdn_frontdoor_origin_group_id = azurerm_cdn_frontdoor_origin_group.web.id + enabled = true + + certificate_name_check_enabled = true + host_name = azurerm_storage_account.web.primary_web_host + origin_host_header = azurerm_storage_account.web.primary_web_host + http_port = 80 + https_port = 443 + priority = 1 + weight = 1000 +} - url_file_extension_condition { - operator = "LessThan" - match_values = ["1"] - } +# Front Door Route +resource "azurerm_cdn_frontdoor_route" "web" { + name = "route-web" + cdn_frontdoor_endpoint_id = azurerm_cdn_frontdoor_endpoint.web.id + cdn_frontdoor_origin_group_id = azurerm_cdn_frontdoor_origin_group.web.id + cdn_frontdoor_origin_ids = [azurerm_cdn_frontdoor_origin.web.id] + + enabled = true + supported_protocols = ["Http", "Https"] + patterns_to_match = ["/*"] + forwarding_protocol = "HttpsOnly" + link_to_default_domain = true + https_redirect_enabled = true + + cache { + query_string_caching_behavior = "UseQueryString" + compression_enabled = true + content_types_to_compress = local.compressed_content_types + } +} - request_uri_condition { - operator = "Equal" - match_values = ["/404"] - negate_condition = "true" - } +# Front Door Security Policy with WAF (only for Premium SKU) +resource "azurerm_cdn_frontdoor_firewall_policy" "waf" { + count = var.frontdoor_sku == "Premium_AzureFrontDoor" ? 1 : 0 + + name = "wafpolicy${replace(local.resource_prefix, "-", "")}" + resource_group_name = azurerm_resource_group.main.name + sku_name = var.frontdoor_sku + enabled = true + mode = "Prevention" + custom_block_response_status_code = 403 + tags = local.tags + + managed_rule { + type = "Microsoft_DefaultRuleSet" + version = "2.1" + action = "Block" + } - url_rewrite_action { - source_pattern = "/" - destination = "/index.html" - preserve_unmatched_path = false - } + managed_rule { + type = "Microsoft_BotManagerRuleSet" + version = "1.0" + action = "Block" } +} - delivery_rule { - name = "SecurityHeader" - order = 3 +resource "azurerm_cdn_frontdoor_security_policy" "waf" { + count = var.frontdoor_sku == "Premium_AzureFrontDoor" ? 1 : 0 - modify_response_header_action { - action = "Append" - name = "X-Frame-Options" - value = "DENY" - } + name = "secpolicy-waf" + cdn_frontdoor_profile_id = azurerm_cdn_frontdoor_profile.main.id - modify_response_header_action { - action = "Append" - name = "Strict-Transport-Security" - value = "max-age=31536000; includeSubDomains" - } + security_policies { + firewall { + cdn_frontdoor_firewall_policy_id = azurerm_cdn_frontdoor_firewall_policy.waf[0].id - modify_response_header_action { - action = "Append" - name = "X-Content-Type-Options" - value = "nosniff" - } + association { + patterns_to_match = ["/*"] - request_uri_condition { - operator = "Any" + domain { + cdn_frontdoor_domain_id = azurerm_cdn_frontdoor_endpoint.web.id + } + } } } - - tags = { - description = "Managed by Terraform" - environment = var.environment - } } diff --git a/terraform/outputs.tf b/terraform/outputs.tf new file mode 100644 index 0000000..2205202 --- /dev/null +++ b/terraform/outputs.tf @@ -0,0 +1,44 @@ +output "resource_group_name" { + description = "Name of the resource group" + value = azurerm_resource_group.main.name +} + +output "resource_group_id" { + description = "ID of the resource group" + value = azurerm_resource_group.main.id +} + +output "storage_account_name" { + description = "Name of the storage account" + value = azurerm_storage_account.web.name +} + +output "storage_account_id" { + description = "ID of the storage account" + value = azurerm_storage_account.web.id +} + +output "storage_primary_web_endpoint" { + description = "Primary web endpoint of the storage account (direct access)" + value = azurerm_storage_account.web.primary_web_endpoint +} + +output "storage_primary_web_host" { + description = "Primary web host of the storage account" + value = azurerm_storage_account.web.primary_web_host +} + +output "frontdoor_profile_id" { + description = "Azure Front Door Profile ID" + value = azurerm_cdn_frontdoor_profile.main.id +} + +output "frontdoor_endpoint_hostname" { + description = "Azure Front Door endpoint hostname" + value = azurerm_cdn_frontdoor_endpoint.web.host_name +} + +output "frontdoor_endpoint_url" { + description = "Full URL to access the website via Front Door (use this)" + value = "https://${azurerm_cdn_frontdoor_endpoint.web.host_name}" +} diff --git a/terraform/provider.tf b/terraform/provider.tf index cd4ce4a..ba4c111 100644 --- a/terraform/provider.tf +++ b/terraform/provider.tf @@ -1,25 +1,26 @@ - -data "azurerm_resource_group" "rg_global" { - name = "rg-global-${var.project_short_name}" -} - terraform { + required_version = ">= 1.14.0" + required_providers { azurerm = { source = "hashicorp/azurerm" - version = "3.111.0" + version = "~> 4.58" } } backend "azurerm" { - resource_group_name = data.azurerm_resource_group.rg_global.name - storage_account_name = "wrstfstorage" + resource_group_name = "rg-terraform-state" + storage_account_name = "stterraformstate" container_name = "tfstate" - key = "terraform.tfstate" + key = "reactweb.terraform.tfstate" } } provider "azurerm" { - features {} - skip_provider_registration = true + features { + resource_group { + prevent_deletion_if_contains_resources = false + } + } + subscription_id = var.subscription_id } diff --git a/terraform/variables.tf b/terraform/variables.tf index 51346d3..da4e818 100644 --- a/terraform/variables.tf +++ b/terraform/variables.tf @@ -1,11 +1,44 @@ # Terraform Variables -variable "environment" { - type = string +variable "subscription_id" { + description = "Azure Subscription ID" + type = string + sensitive = true } -# Pipeline Variables +variable "environment" { + description = "Environment name (dev, qa, uat, prod)" + type = string + + validation { + condition = contains(["dev", "qa", "uat", "prod"], var.environment) + error_message = "Environment must be one of: dev, qa, uat, prod." + } +} variable "project_short_name" { - type = string + description = "Short name for the project used in resource naming (lowercase, no special chars)" + type = string + + validation { + condition = can(regex("^[a-z0-9]{3,10}$", var.project_short_name)) + error_message = "Project short name must be 3-10 lowercase alphanumeric characters." + } +} + +variable "location" { + description = "Azure region for resources" + type = string + default = "canadacentral" +} + +variable "frontdoor_sku" { + description = "Azure Front Door SKU (Standard_AzureFrontDoor or Premium_AzureFrontDoor)" + type = string + default = "Standard_AzureFrontDoor" + + validation { + condition = contains(["Standard_AzureFrontDoor", "Premium_AzureFrontDoor"], var.frontdoor_sku) + error_message = "Front Door SKU must be Standard_AzureFrontDoor or Premium_AzureFrontDoor." + } }