diff --git a/.github/workflows/cicd-1-pull-request.yaml b/.github/workflows/cicd-1-pull-request.yaml index 004b11a5..1af7da19 100644 --- a/.github/workflows/cicd-1-pull-request.yaml +++ b/.github/workflows/cicd-1-pull-request.yaml @@ -22,6 +22,7 @@ jobs: nodejs_version: ${{ steps.variables.outputs.nodejs_version }} python_version: ${{ steps.variables.outputs.python_version }} terraform_version: ${{ steps.variables.outputs.terraform_version }} + golang_version: ${{ steps.variables.outputs.golang_version }} version: ${{ steps.variables.outputs.version }} does_pull_request_exist: ${{ steps.pr_exists.outputs.does_pull_request_exist }} docker_file_exists: ${{ steps.check_compose.outputs.docker_file_exists }} @@ -37,9 +38,10 @@ jobs: echo "build_datetime=$datetime" >> $GITHUB_OUTPUT echo "build_timestamp=$(date --date=$datetime -u +'%Y%m%d%H%M%S')" >> $GITHUB_OUTPUT echo "build_epoch=$(date --date=$datetime -u +'%s')" >> $GITHUB_OUTPUT - echo "nodejs_version=$(grep "^nodejs" .tool-versions | cut -f2 -d' ')" >> $GITHUB_OUTPUT - echo "python_version=$(grep "^nodejs" .tool-versions | cut -f2 -d' ')" >> $GITHUB_OUTPUT - echo "terraform_version=$(grep "^terraform" .tool-versions | cut -f2 -d' ')" >> $GITHUB_OUTPUT + echo "nodejs_version=$(grep "^nodejs" .tool-versions | cut -f2 -d' ' || echo 20.11.0)" >> $GITHUB_OUTPUT + echo "python_version=$(grep "^python" .tool-versions | cut -f2 -d' ' || echo 3.12.1)" >> $GITHUB_OUTPUT + echo "terraform_version=$(grep "^terraform" .tool-versions | cut -f2 -d' ' || echo 1.13.0)" >> $GITHUB_OUTPUT + echo "golang_version=$(grep "^golang" .tool-versions | cut -f2 -d' ' || echo 1.22.5)" >> $GITHUB_OUTPUT echo "version=$(head -n 1 .version 2> /dev/null || echo unknown)" >> $GITHUB_OUTPUT - name: "Check if pull request exists for this branch" id: pr_exists @@ -87,6 +89,7 @@ jobs: nodejs_version: "${{ needs.metadata.outputs.nodejs_version }}" python_version: "${{ needs.metadata.outputs.python_version }}" terraform_version: "${{ needs.metadata.outputs.terraform_version }}" + golang_version: "${{ needs.metadata.outputs.golang_version }}" version: "${{ needs.metadata.outputs.version }}" secrets: inherit test-stage: # Recommended maximum execution time is 5 minutes @@ -100,6 +103,7 @@ jobs: nodejs_version: "${{ needs.metadata.outputs.nodejs_version }}" python_version: "${{ needs.metadata.outputs.python_version }}" terraform_version: "${{ needs.metadata.outputs.terraform_version }}" + golang_version: "${{ needs.metadata.outputs.golang_version }}" version: "${{ needs.metadata.outputs.version }}" secrets: inherit build-stage: # Recommended maximum execution time is 3 minutes @@ -126,5 +130,6 @@ jobs: nodejs_version: "${{ needs.metadata.outputs.nodejs_version }}" python_version: "${{ needs.metadata.outputs.python_version }}" terraform_version: "${{ needs.metadata.outputs.terraform_version }}" + golang_version: "${{ needs.metadata.outputs.golang_version }}" version: "${{ needs.metadata.outputs.version }}" secrets: inherit diff --git a/.github/workflows/stage-1-commit.yaml b/.github/workflows/stage-1-commit.yaml index a97d49ee..e56eb93d 100644 --- a/.github/workflows/stage-1-commit.yaml +++ b/.github/workflows/stage-1-commit.yaml @@ -27,6 +27,10 @@ on: description: "Terraform version, set by the CI/CD pipeline workflow" required: true type: string + golang_version: + description: "Go version, set by the CI/CD pipeline workflow" + required: true + type: string version: description: "Version of the software, set by the CI/CD pipeline workflow" required: true diff --git a/.github/workflows/stage-2-test.yaml b/.github/workflows/stage-2-test.yaml index b10c159f..099850d9 100644 --- a/.github/workflows/stage-2-test.yaml +++ b/.github/workflows/stage-2-test.yaml @@ -27,71 +27,136 @@ on: description: "Terraform version, set by the CI/CD pipeline workflow" required: true type: string + golang_version: + description: "Go version, set by the CI/CD pipeline workflow" + required: true + type: string version: description: "Version of the software, set by the CI/CD pipeline workflow" required: true type: string jobs: - test-unit: - name: "Unit tests" - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: "Checkout code" - uses: actions/checkout@v4 - - name: "Run unit test suite" - run: | - make test-unit - - name: "Save the result of fast test suite" - run: | - echo "Nothing to save" - test-lint: - name: "Linting" + terraform-lint: + name: "Terraform lint (tflint)" runs-on: ubuntu-latest - timeout-minutes: 5 + timeout-minutes: 10 steps: - name: "Checkout code" uses: actions/checkout@v4 - - name: "Run linting" - run: | - make test-lint - - name: "Save the linting result" + - name: Setup TFLint + uses: terraform-linters/setup-tflint@v4 + with: + tflint_version: latest + - name: "Run TFLint on modules" + id: tflint + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - echo "Nothing to save" - test-coverage: - name: "Test coverage" - needs: [test-unit] + echo "## TFLint Results" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + total_issues=0 + for module_dir in $(find infrastructure/modules -mindepth 1 -maxdepth 1 -type d); do + module_name=$(basename "$module_dir") + # Only lint directories containing .tf files + if ls "$module_dir"/*.tf > /dev/null 2>&1; then + echo "=== Linting $module_dir ===" + # Init tflint for the module (downloads plugins if needed) + tflint --init --chdir="$module_dir" > /dev/null 2>&1 || true + + # Capture output and count issues + output=$(tflint --chdir="$module_dir" --format=compact 2>&1 || true) + issue_count=$(echo "$output" | grep -c ":" || echo "0") + + if [ "$issue_count" -gt 0 ] && [ -n "$output" ]; then + total_issues=$((total_issues + issue_count)) + echo "### ⚠️ $module_name ($issue_count issues)" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + echo "$output" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + else + echo "### ✅ $module_name" >> $GITHUB_STEP_SUMMARY + fi + echo "" >> $GITHUB_STEP_SUMMARY + fi + done + echo "---" >> $GITHUB_STEP_SUMMARY + echo "**Total issues: $total_issues**" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + if [ $total_issues -gt 0 ]; then + echo "> **Note:** TFLint issues are advisory only. Please address these issues to improve code quality." >> $GITHUB_STEP_SUMMARY + fi + # Always exit 0 - this job is advisory only + exit 0 + terraform-security: + name: "Terraform security scan" runs-on: ubuntu-latest - timeout-minutes: 5 + timeout-minutes: 10 + permissions: + contents: read + security-events: write steps: - name: "Checkout code" uses: actions/checkout@v4 - - name: "Run test coverage check" + - name: "Run tfsec with SARIF output" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - make test-coverage - - name: "Save the coverage check result" + # Install tfsec + curl -s https://raw.githubusercontent.com/aquasecurity/tfsec/master/scripts/install_linux.sh | bash + # Run tfsec and output SARIF format + tfsec infrastructure/ --format sarif --out tfsec-results.sarif --soft-fail + - name: "Upload SARIF to GitHub Code Scanning" + uses: github/codeql-action/upload-sarif@v4 + if: always() + with: + sarif_file: tfsec-results.sarif + category: terraform-security + - name: "Generate summary" + if: always() run: | - echo "Nothing to save" + echo "## Terraform Security Scan" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Security findings are uploaded to the **Security** tab → **Code scanning alerts**." >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "> **Note:** Findings are informational and do not block merges." >> $GITHUB_STEP_SUMMARY + echo "> To make blocking, enable 'Require code scanning results' in branch protection rules." >> $GITHUB_STEP_SUMMARY unit-test-terraform-modules: name: "Unit test terraform modules" - needs: [test-unit] runs-on: ubuntu-latest - timeout-minutes: 5 + timeout-minutes: 10 steps: - name: "Checkout code" uses: actions/checkout@v4 - name: Install Terraform uses: hashicorp/setup-terraform@v3 with: - terraform_version: 1.12.2 - - name: "run the tests" + terraform_version: ${{ inputs.terraform_version }} + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: ${{ inputs.golang_version }} + cache: false # Disable cache to avoid tar restore errors + - name: "Run module tests" run: | - cd tests/modules - go test -v + # Find all module test directories and run tests + failed=0 + for test_dir in $(find infrastructure/modules -type d -name "tests"); do + if ls "$test_dir"/*_test.go 1> /dev/null 2>&1; then + echo "=== Running tests in $test_dir ===" + cd "$test_dir" + go mod tidy + if ! go test -v ./...; then + failed=1 + fi + cd - > /dev/null + fi + done + if [ $failed -eq 1 ]; then + exit 1 + fi perform-static-analysis: name: "Perform static analysis" - needs: [test-unit] runs-on: ubuntu-latest permissions: id-token: write diff --git a/.github/workflows/stage-4-acceptance.yaml b/.github/workflows/stage-4-acceptance.yaml index d554f98a..f182af4d 100644 --- a/.github/workflows/stage-4-acceptance.yaml +++ b/.github/workflows/stage-4-acceptance.yaml @@ -27,6 +27,10 @@ on: description: "Terraform version, set by the CI/CD pipeline workflow" required: true type: string + golang_version: + description: "Go version, set by the CI/CD pipeline workflow" + required: true + type: string version: description: "Version of the software, set by the CI/CD pipeline workflow" required: true diff --git a/.tool-versions b/.tool-versions index b850c185..9b07693d 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,7 +1,10 @@ # This file is for you! Please, updated to the versions agreed by your team. -terraform 1.9.2 -pre-commit 3.6.0 +terraform 1.13.0 +pre-commit 4.5.1 +tflint 0.60.0 +tfsec 1.28.13 +golang 1.22.5 # ============================================================================== # The section below is reserved for Docker image versions. diff --git a/infrastructure/modules/key-vault/tfdocs.md b/infrastructure/modules/key-vault/tfdocs.md index 0bb170a1..16917d22 100644 --- a/infrastructure/modules/key-vault/tfdocs.md +++ b/infrastructure/modules/key-vault/tfdocs.md @@ -60,6 +60,62 @@ Type: `string` The following input variables are optional (have default values): +### [action\_group\_id](#input\_action\_group\_id) + +Description: The ID of the Action Group to use for alerts. + +Type: `string` + +Default: `null` + +### [certificate\_expired\_alert](#input\_certificate\_expired\_alert) + +Description: n/a + +Type: + +```hcl +object({ + evaluation_frequency = string + window_duration = string + threshold = number + }) +``` + +Default: + +```json +{ + "evaluation_frequency": "PT15M", + "threshold": 1, + "window_duration": "PT1H" +} +``` + +### [certificate\_near\_expiry\_alert](#input\_certificate\_near\_expiry\_alert) + +Description: n/a + +Type: + +```hcl +object({ + evaluation_frequency = string + window_duration = string + threshold = number + }) +``` + +Default: + +```json +{ + "evaluation_frequency": "P1D", + "threshold": 1, + "window_duration": "P1D" +} +``` + ### [disk\_encryption](#input\_disk\_encryption) Description: Should the disk encryption be enabled @@ -68,6 +124,14 @@ Type: `bool` Default: `true` +### [enable\_alerting](#input\_enable\_alerting) + +Description: Whether monitoring and alerting is enabled for the Key Vault. + +Type: `bool` + +Default: `false` + ### [enable\_rbac\_authorization](#input\_enable\_rbac\_authorization) Description: n/a @@ -108,6 +172,62 @@ Type: `list(string)` Default: `[]` +### [resource\_group\_name\_monitoring](#input\_resource\_group\_name\_monitoring) + +Description: The name of the resource group in which to create the Monitoring resources for the Key Vault. Changing this forces a new resource to be created. + +Type: `string` + +Default: `null` + +### [secret\_expired\_alert](#input\_secret\_expired\_alert) + +Description: n/a + +Type: + +```hcl +object({ + evaluation_frequency = string + window_duration = string + threshold = number + }) +``` + +Default: + +```json +{ + "evaluation_frequency": "PT15M", + "threshold": 1, + "window_duration": "PT1H" +} +``` + +### [secret\_near\_expiry\_alert](#input\_secret\_near\_expiry\_alert) + +Description: n/a + +Type: + +```hcl +object({ + evaluation_frequency = string + window_duration = string + threshold = number + }) +``` + +Default: + +```json +{ + "evaluation_frequency": "P1D", + "threshold": 1, + "window_duration": "P1D" +} +``` + ### [sku\_name](#input\_sku\_name) Description: Type of the Key Vault's SKU. @@ -253,4 +373,8 @@ Description: n/a The following resources are used by this module: - [azurerm_key_vault.keyvault](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/key_vault) (resource) +- [azurerm_monitor_scheduled_query_rules_alert_v2.kv_certificate_expired](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/monitor_scheduled_query_rules_alert_v2) (resource) +- [azurerm_monitor_scheduled_query_rules_alert_v2.kv_certificate_near_expiry](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/monitor_scheduled_query_rules_alert_v2) (resource) +- [azurerm_monitor_scheduled_query_rules_alert_v2.kv_secret_expired](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/monitor_scheduled_query_rules_alert_v2) (resource) +- [azurerm_monitor_scheduled_query_rules_alert_v2.kv_secret_near_expiry](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/monitor_scheduled_query_rules_alert_v2) (resource) - [azurerm_client_config.current](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/data-sources/client_config) (data source) diff --git a/infrastructure/modules/managed-identity/README.md b/infrastructure/modules/managed-identity/README.md new file mode 100644 index 00000000..5d6ffb8b --- /dev/null +++ b/infrastructure/modules/managed-identity/README.md @@ -0,0 +1,109 @@ +# Managed Identity Module + +Creates an Azure User-Assigned Managed Identity. + +## Usage + +```hcl +module "managed_identity" { + source = "../../modules/managed-identity" + + resource_group_name = "rg-myapp-prod" + location = "uksouth" + uai_name = "mi-myapp-prod" + + tags = { + environment = "production" + managed_by = "terraform" + } +} +``` + +## Requirements + +| Name | Version | +|------|---------| +| terraform | >= 1.0 | +| azurerm | >= 3.0 | + +## Inputs + +| Name | Description | Type | Required | Default | +|------|-------------|------|----------|---------| +| `resource_group_name` | The name of the resource group in which to create the Identity | `string` | Yes | - | +| `location` | Azure region for the resource | `string` | Yes | - | +| `uai_name` | The name of the user assigned identity (3-128 chars, alphanumeric, hyphens, underscores) | `string` | Yes | - | +| `tags` | Resource tags to be applied | `map(string)` | No | `{}` | + +### Naming Constraints + +The `uai_name` must: +- Be between 3 and 128 characters +- Start with an alphanumeric character +- End with an alphanumeric character or underscore +- Contain only alphanumeric characters, hyphens, and underscores + +## Outputs + +| Name | Description | +|------|-------------| +| `id` | The resource ID of the User Assigned Identity | +| `name` | The name of the User Assigned Identity | +| `principal_id` | The Principal ID (Object ID) for the Service Principal associated with this Identity | +| `client_id` | The Client ID (Application ID) for the Service Principal associated with this Identity | + +## Example: Assigning to a Function App + +```hcl +module "managed_identity" { + source = "../../modules/managed-identity" + + resource_group_name = azurerm_resource_group.main.name + location = azurerm_resource_group.main.location + uai_name = "mi-funcapp-prod" +} + +resource "azurerm_linux_function_app" "example" { + # ... other config ... + + identity { + type = "UserAssigned" + identity_ids = [module.managed_identity.id] + } +} +``` + +## Example: Granting Key Vault Access (RBAC) + +```hcl +module "managed_identity" { + source = "../../modules/managed-identity" + + resource_group_name = azurerm_resource_group.main.name + location = azurerm_resource_group.main.location + uai_name = "mi-myapp-prod" +} + +resource "azurerm_role_assignment" "keyvault_secrets" { + scope = azurerm_key_vault.main.id + role_definition_name = "Key Vault Secrets User" + principal_id = module.managed_identity.principal_id +} +``` + +## Testing + +This module includes automated tests using Terratest. + +```bash +cd tests +go test -v ./... +``` + +Tests validate: +- Valid input configurations are accepted +- Invalid identity names are rejected by validation rules + +## Documentation + +Auto-generated documentation is available in [tfdocs.md](./tfdocs.md). diff --git a/infrastructure/modules/managed-identity/tests/go.mod b/infrastructure/modules/managed-identity/tests/go.mod new file mode 100644 index 00000000..037948a6 --- /dev/null +++ b/infrastructure/modules/managed-identity/tests/go.mod @@ -0,0 +1,40 @@ +module managed-identity-tests + +go 1.25.6 + +require ( + github.com/gruntwork-io/terratest v0.55.0 + github.com/stretchr/testify v1.11.1 +) + +require ( + github.com/agext/levenshtein v1.2.3 // indirect + github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect + github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/hashicorp/errwrap v1.0.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-getter/v2 v2.2.3 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-safetemp v1.0.0 // indirect + github.com/hashicorp/go-version v1.7.0 // indirect + github.com/hashicorp/hcl/v2 v2.22.0 // indirect + github.com/hashicorp/terraform-json v0.23.0 // indirect + github.com/jinzhu/copier v0.0.0-20190924061706-b57f9002281a // indirect + github.com/klauspost/compress v1.16.5 // indirect + github.com/mattn/go-zglob v0.0.2-0.20190814121620-e3c945676326 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mitchellh/go-testing-interface v1.14.1 // indirect + github.com/mitchellh/go-wordwrap v1.0.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/tmccombs/hcl2json v0.6.4 // indirect + github.com/ulikunitz/xz v0.5.10 // indirect + github.com/zclconf/go-cty v1.15.0 // indirect + golang.org/x/crypto v0.45.0 // indirect + golang.org/x/mod v0.29.0 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.31.0 // indirect + golang.org/x/tools v0.38.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/infrastructure/modules/managed-identity/tests/go.sum b/infrastructure/modules/managed-identity/tests/go.sum new file mode 100644 index 00000000..bca54390 --- /dev/null +++ b/infrastructure/modules/managed-identity/tests/go.sum @@ -0,0 +1,72 @@ +github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= +github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= +github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= +github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= +github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d h1:xDfNPAt8lFiC1UJrqV3uuy861HCTo708pDMbjHHdCas= +github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d/go.mod h1:6QX/PXZ00z/TKoufEY6K/a0k6AhaJrQKdFe6OfVXsa4= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-test/deep v1.0.7 h1:/VSMRlnY/JSyqxQUzQLKVMAskpY/NZKFA5j2P+0pP2M= +github.com/go-test/deep v1.0.7/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/gruntwork-io/terratest v0.55.0 h1:NgG6lm2dArdQ3KcOofw6PTfVRK1Flt7L3NNhFSBo72A= +github.com/gruntwork-io/terratest v0.55.0/go.mod h1:OE0Jsc8Wn5kw/QySLbBd53g9Gt+xfDyDKChwRHwkKvI= +github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-getter/v2 v2.2.3 h1:6CVzhT0KJQHqd9b0pK3xSP0CM/Cv+bVhk+jcaRJ2pGk= +github.com/hashicorp/go-getter/v2 v2.2.3/go.mod h1:hp5Yy0GMQvwWVUmwLs3ygivz1JSLI323hdIE9J9m7TY= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-safetemp v1.0.0 h1:2HR189eFNrjHQyENnQMMpCiBAsRxzbTMIgBhEyExpmo= +github.com/hashicorp/go-safetemp v1.0.0/go.mod h1:oaerMy3BhqiTbVye6QuFhFtIceqFoDHxNAB65b+Rj1I= +github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= +github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/hcl/v2 v2.22.0 h1:hkZ3nCtqeJsDhPRFz5EA9iwcG1hNWGePOTw6oyul12M= +github.com/hashicorp/hcl/v2 v2.22.0/go.mod h1:62ZYHrXgPoX8xBnzl8QzbWq4dyDsDtfCRgIq1rbJEvA= +github.com/hashicorp/terraform-json v0.23.0 h1:sniCkExU4iKtTADReHzACkk8fnpQXrdD2xoR+lppBkI= +github.com/hashicorp/terraform-json v0.23.0/go.mod h1:MHdXbBAbSg0GvzuWazEGKAn/cyNfIB7mN6y7KJN6y2c= +github.com/jinzhu/copier v0.0.0-20190924061706-b57f9002281a h1:zPPuIq2jAWWPTrGt70eK/BSch+gFAGrNzecsoENgu2o= +github.com/jinzhu/copier v0.0.0-20190924061706-b57f9002281a/go.mod h1:yL958EeXv8Ylng6IfnvG4oflryUi3vgA3xPs9hmII1s= +github.com/klauspost/compress v1.16.5 h1:IFV2oUNUzZaz+XyusxpLzpzS8Pt5rh0Z16For/djlyI= +github.com/klauspost/compress v1.16.5/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/mattn/go-zglob v0.0.2-0.20190814121620-e3c945676326 h1:ofNAzWCcyTALn2Zv40+8XitdzCgXY6e9qvXwN9W0YXg= +github.com/mattn/go-zglob v0.0.2-0.20190814121620-e3c945676326/go.mod h1:9fxibJccNxU2cnpIKLRRFA7zX7qhkJIQWBb449FYHOo= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= +github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= +github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tmccombs/hcl2json v0.6.4 h1:/FWnzS9JCuyZ4MNwrG4vMrFrzRgsWEOVi+1AyYUVLGw= +github.com/tmccombs/hcl2json v0.6.4/go.mod h1:+ppKlIW3H5nsAsZddXPy2iMyvld3SHxyjswOZhavRDk= +github.com/ulikunitz/xz v0.5.10 h1:t92gobL9l3HE202wg3rlk19F6X+JOxl9BBrCCMYEYd8= +github.com/ulikunitz/xz v0.5.10/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/zclconf/go-cty v1.15.0 h1:tTCRWxsexYUmtt/wVxgDClUe+uQusuI443uL6e+5sXQ= +github.com/zclconf/go-cty v1.15.0/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= +github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= +github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/infrastructure/modules/managed-identity/tests/managed_identity_test.go b/infrastructure/modules/managed-identity/tests/managed_identity_test.go new file mode 100644 index 00000000..b766e7e4 --- /dev/null +++ b/infrastructure/modules/managed-identity/tests/managed_identity_test.go @@ -0,0 +1,202 @@ +package test + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/gruntwork-io/terratest/modules/files" + "github.com/gruntwork-io/terratest/modules/terraform" + "github.com/stretchr/testify/assert" +) + +func setupTestModule(t *testing.T, vars map[string]any) string { + moduleDir := "../" + + tempDir, err := files.CopyTerraformFolderToTemp(moduleDir, t.Name()) + if err != nil { + tempDir = t.TempDir() + copyTerraformFiles(t, moduleDir, tempDir) + } + + // We only need to inject the provider block, as required_providers is now in versions.tf + providerContent := "provider \"azurerm\" {\nfeatures {}\n}\n" + providerPath := filepath.Join(tempDir, "provider_test.tf") + err = os.WriteFile(providerPath, []byte(providerContent), 0644) + if err != nil { + t.Fatalf("failed to write provider file: %v", err) + } + + writeVarsFile(t, tempDir, vars) + + return tempDir +} + +func writeVarsFile(t *testing.T, dir string, vars map[string]any) { + var content string + for k, v := range vars { + switch val := v.(type) { + case string: + content += fmt.Sprintf("%s = %q\n", k, val) + case map[string]string: + content += fmt.Sprintf("%s = {\n", k) + for mk, mv := range val { + content += fmt.Sprintf(" %s = %q\n", mk, mv) + } + content += "}\n" + case nil: + content += fmt.Sprintf("%s = {}\n", k) + default: + content += fmt.Sprintf("%s = %v\n", k, val) + } + } + + varsPath := filepath.Join(dir, "terraform.tfvars") + err := os.WriteFile(varsPath, []byte(content), 0644) + if err != nil { + t.Fatalf("failed to write tfvars file: %v", err) + } +} + +func copyTerraformFiles(t *testing.T, src, dst string) { + entries, err := os.ReadDir(src) + if err != nil { + t.Fatalf("failed to read source directory: %v", err) + } + + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".tf" { + continue + } + + srcPath := filepath.Join(src, entry.Name()) + dstPath := filepath.Join(dst, entry.Name()) + + content, err := os.ReadFile(srcPath) + if err != nil { + t.Fatalf("failed to read file %s: %v", srcPath, err) + } + + err = os.WriteFile(dstPath, content, 0644) + if err != nil { + t.Fatalf("failed to write file %s: %v", dstPath, err) + } + } +} + +func TestManagedIdentity_ValidInputs(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + uaiName string + tags map[string]string + }{ + { + name: "standard_name", + uaiName: "mi-test-identity", + tags: map[string]string{"environment": "test"}, + }, + { + name: "minimum_length_name", + uaiName: "abc", + tags: nil, + }, + { + name: "name_with_underscores", + uaiName: "mi_test_identity", + tags: map[string]string{}, + }, + { + name: "name_with_mixed_separators", + uaiName: "mi-test_identity-01", + tags: map[string]string{"team": "platform", "cost_center": "12345"}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + vars := map[string]any{ + "resource_group_name": "rg-test", + "location": "uksouth", + "uai_name": tc.uaiName, + "tags": tc.tags, + } + tempDir := setupTestModule(t, vars) + + terraformOptions := &terraform.Options{ + TerraformDir: tempDir, + NoColor: true, + } + + _, err := terraform.InitAndValidateE(t, terraformOptions) + assert.NoError(t, err, "Module should validate successfully with valid inputs") + }) + } +} + +// Variable validation rules are only evaluated during 'plan' or 'apply', not 'validate'. +func TestManagedIdentity_InvalidName(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + uaiName string + description string + }{ + { + name: "too_short_one_char", + uaiName: "a", + description: "single character should be rejected", + }, + { + name: "too_short_two_chars", + uaiName: "ab", + description: "two characters should be rejected", + }, + { + name: "starts_with_hyphen", + uaiName: "-mi-identity", + description: "name starting with hyphen should be rejected", + }, + { + name: "starts_with_underscore", + uaiName: "_mi-identity", + description: "name starting with underscore should be rejected", + }, + { + name: "contains_invalid_chars", + uaiName: "mi.test.identity", + description: "name containing dots should be rejected", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + vars := map[string]any{ + "resource_group_name": "rg-test", + "location": "uksouth", + "uai_name": tc.uaiName, + } + tempDir := setupTestModule(t, vars) + + terraformOptions := &terraform.Options{ + TerraformDir: tempDir, + NoColor: true, + } + + _, err := terraform.InitAndPlanE(t, terraformOptions) + + assert.Error(t, err, tc.description) + if err != nil { + assert.Contains(t, err.Error(), "User-Assigned Managed Identity name", + "Error message should mention the identity name validation") + } + }) + } +} diff --git a/infrastructure/modules/managed-identity/versions.tf b/infrastructure/modules/managed-identity/versions.tf new file mode 100644 index 00000000..58dfc8e6 --- /dev/null +++ b/infrastructure/modules/managed-identity/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = ">= 3.0" + } + } +} diff --git a/scripts/init.mk b/scripts/init.mk index 373f8a4f..8df7e3f6 100644 --- a/scripts/init.mk +++ b/scripts/init.mk @@ -1,6 +1,6 @@ # WARNING: Please DO NOT edit this file! It is maintained in the Repository Template (https://github.com/nhs-england-tools/repository-template). Raise a PR instead. -include scripts/docker/docker.mk +# include scripts/docker/docker.mk include scripts/tests/test.mk -include scripts/terraform/terraform.mk diff --git a/scripts/terraform/terraform.mk b/scripts/terraform/terraform.mk index 5cfc4b5c..f798d3cc 100644 --- a/scripts/terraform/terraform.mk +++ b/scripts/terraform/terraform.mk @@ -1,3 +1,4 @@ + # This file is for you! Edit it to implement your own Terraform make targets. # ============================================================================== @@ -43,6 +44,95 @@ terraform-validate: # Validate Terraform configuration - optional: terraform_dir dir=$(or ${terraform_dir}, ${dir}) \ opts=$(or ${terraform_opts}, ${opts}) +terraform-lint: # Lint Terraform modules using tflint - optional: module=[name of the module to lint, e.g. 'managed-identity'] @Quality + echo "Running TFLint..." + make _install-dependency name="tflint" + if [ -n "${module}" ]; then \ + module_dir="infrastructure/modules/${module}"; \ + if [ ! -d "$$module_dir" ]; then echo "Error: Module directory $$module_dir not found"; exit 1; fi; \ + if ls "$$module_dir"/*.tf > /dev/null 2>&1; then \ + echo "=== Linting $$module_dir ==="; \ + tflint --init --chdir="$$module_dir" > /dev/null 2>&1 || true; \ + tflint --chdir="$$module_dir" --format=compact; \ + else \ + echo "Skipping $$module_dir (no .tf files)"; \ + fi; \ + else \ + total_issues=0; \ + for module_dir in $$(find infrastructure/modules -mindepth 1 -maxdepth 1 -type d); do \ + module_name=$$(basename "$$module_dir"); \ + if ls "$$module_dir"/*.tf > /dev/null 2>&1; then \ + echo "=== Linting $$module_dir ==="; \ + tflint --init --chdir="$$module_dir" > /dev/null 2>&1 || true; \ + output=$$(tflint --chdir="$$module_dir" --format=compact 2>&1 || true); \ + issue_count=$$(echo "$$output" | grep -c ":" || echo "0"); \ + if [ "$$issue_count" -gt 0 ] && [ -n "$$output" ]; then \ + total_issues=$$((total_issues + issue_count)); \ + echo "### ⚠️ $$module_name ($$issue_count issues)"; \ + echo "$$output"; \ + else \ + echo "### ✅ $$module_name"; \ + fi; \ + echo ""; \ + fi; \ + done; \ + echo "Total issues: $$total_issues"; \ + if [ $$total_issues -gt 0 ]; then \ + echo "> **Note:** TFLint issues are advisory only."; \ + fi; \ + fi + +terraform-security: # Run security scan using tfsec - optional: module=[name of the module to scan, e.g. 'managed-identity'] @Quality + echo "Running tfsec..." + make _install-dependency name="tfsec" + if [ -n "${module}" ]; then \ + module_dir="infrastructure/modules/${module}"; \ + if [ ! -d "$$module_dir" ]; then echo "Error: Module directory $$module_dir not found"; exit 1; fi; \ + echo "=== Scanning $$module_dir ==="; \ + tfsec "$$module_dir" --soft-fail; \ + else \ + tfsec infrastructure/ --soft-fail; \ + fi + +terraform-static-analysis: # Run static analysis using SonarScanner @Quality + echo "Running Static Analysis..." + ./scripts/reports/perform-static-analysis.sh + +terraform-test-modules: # Run Go unit tests for Terraform modules - optional: module=[name of the module to test, e.g. 'managed-identity'] @Testing + echo "Running Module Tests..." + make _install-dependency name="golang" + failed=0 + if [ -n "${module}" ]; then \ + test_dir="infrastructure/modules/${module}/tests"; \ + if [ ! -d "$$test_dir" ]; then echo "Error: Test directory $$test_dir not found"; exit 1; fi; \ + echo "=== Running tests in $$test_dir ==="; \ + cd "$$test_dir"; \ + go mod tidy; \ + if ! go test -v ./...; then failed=1; fi; \ + else \ + for test_dir in $$(find infrastructure/modules -type d -name "tests"); do \ + if ls "$$test_dir"/*_test.go 1> /dev/null 2>&1; then \ + echo "=== Running tests in $$test_dir ==="; \ + cd "$$test_dir"; \ + go mod tidy; \ + if ! go test -v ./...; then failed=1; fi; \ + cd - > /dev/null; \ + fi; \ + done; \ + fi; \ + if [ $$failed -eq 1 ]; then exit 1; fi + +terraform-test: # Run all Terraform quality checks and tests @Testing + make terraform-lint + make terraform-security + make terraform-test-modules + +terraform-install-tools: # Install all terraform related tools @Installation + make _install-dependency name="terraform" + make _install-dependency name="tflint" + make _install-dependency name="tfsec" + make _install-dependency name="golang" + clean:: # Remove Terraform files (terraform) - optional: terraform_dir|dir=[path to a directory where the command will be executed, relative to the project's top-level directory, default is one of the module variables or the example directory, if not set] @Operations make _terraform cmd="clean" \ dir=$(or ${terraform_dir}, ${dir}) \ @@ -77,6 +167,12 @@ ${VERBOSE}.SILENT: \ terraform-fmt \ terraform-init \ terraform-install \ + terraform-install-tools \ + terraform-lint \ terraform-plan \ + terraform-security \ terraform-shellscript-lint \ + terraform-static-analysis \ + terraform-test \ + terraform-test-modules \ terraform-validate \ diff --git a/scripts/tests/lint.sh b/scripts/tests/lint.sh new file mode 100755 index 00000000..989364b8 --- /dev/null +++ b/scripts/tests/lint.sh @@ -0,0 +1,5 @@ +#!/bin/bash +set -euo pipefail + +# Delegate to the Terraform-specific lint target +make terraform-lint diff --git a/scripts/tests/security.sh b/scripts/tests/security.sh new file mode 100755 index 00000000..4d1c6b51 --- /dev/null +++ b/scripts/tests/security.sh @@ -0,0 +1,5 @@ +#!/bin/bash +set -euo pipefail + +# Delegate to the Terraform-specific security target +make terraform-security diff --git a/scripts/tests/test.mk b/scripts/tests/test.mk index aab47c62..24fd2fb5 100644 --- a/scripts/tests/test.mk +++ b/scripts/tests/test.mk @@ -14,55 +14,14 @@ test-unit: # Run your unit tests from scripts/test/unit @Testing test-lint: # Lint your code from scripts/test/lint @Testing make _test name="lint" -test-coverage: # Evaluate code coverage from scripts/test/coverage @Testing - make _test name="coverage" - -test-accessibility: # Run your accessibility tests from scripts/test/accessibility @Testing - make _test name="accessibility" - -test-contract: # Run your contract tests from scripts/test/contract @Testing - make _test name="contract" - -test-integration: # Run your integration tests from scripts/test/integration @Testing - make _test name="integration" - -test-load: # Run all your load tests @Testing - make \ - test-capacity \ - test-soak \ - test-response-time - # You may wish to add more here, depending on your app - -test-capacity: # Test what load level your app fails at from scripts/test/capacity @Testing - make _test name="capacity" - -test-soak: # Test that resources don't get exhausted over time from scripts/test/soak @Testing - make _test name="soak" - -test-response-time: # Test your API response times from scripts/test/response-time @Testing - make _test name="response-time" - test-security: # Run your security tests from scripts/test/security @Testing make _test name="security" -test-ui: # Run your UI tests from scripts/test/ui @Testing - make _test name="ui" - -test-ui-performance: # Run UI render tests from scripts/test/ui-performance @Testing - make _test name="ui-performance" - test: # Run all the test tasks @Testing make \ test-unit \ test-lint \ - test-coverage \ - test-contract \ - test-security \ - test-ui \ - test-ui-performance \ - test-integration \ - test-accessibility \ - test-load + test-security _test: set -e @@ -76,16 +35,6 @@ _test: ${VERBOSE}.SILENT: \ _test \ test \ - test-accessibility \ - test-capacity \ - test-contract \ - test-coverage \ - test-soak \ - test-integration \ test-lint \ - test-load \ - test-response-time \ test-security \ - test-ui \ - test-ui-performance \ test-unit \ diff --git a/scripts/tests/unit.sh b/scripts/tests/unit.sh index c589be5b..ee6f37e4 100755 --- a/scripts/tests/unit.sh +++ b/scripts/tests/unit.sh @@ -1,20 +1,5 @@ #!/bin/bash - set -euo pipefail -cd "$(git rev-parse --show-toplevel)" - -# This file is for you! Edit it to call your unit test suite. Note that the same -# file will be called if you run it locally as if you run it on CI. - -# Replace the following line with something like: -# -# rails test:unit -# python manage.py test -# npm run test -# -# or whatever is appropriate to your project. You should *only* run your fast -# tests from here. If you want to run other test suites, see the predefined -# tasks in scripts/test.mk. - -echo "Unit tests are not yet implemented. See scripts/tests/unit.sh for more." +# Delegate to the Terraform-specific unit test target +make terraform-test-modules