diff --git a/.github/workflows/module-ci.yaml b/.github/workflows/module-ci.yaml index 7bf55f7..ec15a50 100644 --- a/.github/workflows/module-ci.yaml +++ b/.github/workflows/module-ci.yaml @@ -1,5 +1,7 @@ name: module-ci on: + schedule: + - cron: "0 0 * * 0" # weekly on Sunday at 00:00 push: branches: - main @@ -10,9 +12,16 @@ on: - synchronize - labeled - unlabeled + jobs: module-ci: - uses: cloudeteer/terraform-governance/.github/workflows/module-ci.yaml@349-epic-the-revival-of-tf-mod-lib + uses: cloudeteer/terraform-governance/.github/workflows/module-ci.yaml@main permissions: contents: write + id-token: write + issues: write pull-requests: read + secrets: + ARM_CLIENT_ID: ${{ secrets.ARM_CLIENT_ID }} + ARM_SUBSCRIPTION_ID: ${{ secrets.ARM_SUBSCRIPTION_ID }} + ARM_TENANT_ID: ${{ secrets.ARM_TENANT_ID }} diff --git a/.gitignore b/.gitignore index 4a7aa0e..c00e85a 100644 --- a/.gitignore +++ b/.gitignore @@ -10,13 +10,6 @@ crash.log crash.*.log -# Exclude all .tfvars files, which are likely to contain sensitive data, such as -# password, private keys, and other secrets. These should not be part of version -# control as they are data points which are potentially sensitive and subject -# to change depending on the environment. -*.tfvars -*.tfvars.json - # Ignore override files as they are usually used to override resources locally and so # are not checked in override.tf @@ -25,7 +18,7 @@ override.tf.json *_override.tf.json # Include override files you do wish to add to version control using negated pattern -# !example_override.tf +!tests_override.tf # Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan # example: *tfplan* @@ -33,3 +26,6 @@ override.tf.json # Ignore CLI configuration files .terraformrc terraform.rc + +# Ignore rc files +.envrc diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..9d7deda --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,33 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace + - repo: https://github.com/terraform-docs/terraform-docs + rev: v0.18.0 + hooks: + - id: terraform-docs-system + args: ["."] + - repo: https://github.com/antonbabenko/pre-commit-terraform + rev: "v1.92.0" + hooks: + - id: terraform_fmt + - id: terraform_tflint + exclude: ^examples/ + args: + - --args=--config=__GIT_WORKING_DIR__/.tflint.hcl + - --hook-config=--delegate-chdir + - id: terraform_tflint + alias: terraform_tflint_examples + name: Terraform validate examples with tflint + files: ^examples/ + args: + - --args=--config=__GIT_WORKING_DIR__/.tflint.examples.hcl + - --hook-config=--delegate-chdir + - id: terraform_trivy + exclude: ^(examples|tests)/ + args: + - --args=--skip-dirs="examples/" + - --args=--skip-dirs="tests/" diff --git a/.terraform-docs.yaml b/.terraform-docs.yaml index ac96ae4..7fd85c3 100644 --- a/.terraform-docs.yaml +++ b/.terraform-docs.yaml @@ -1,7 +1,7 @@ -formatter: markdown +formatter: markdown document settings: - anchor: false + hide-empty: true lockfile: false output: @@ -14,6 +14,8 @@ sort: content: |- ## Usage + {{ include "examples/usage/main.md" }} + ```hcl {{ include "examples/usage/main.tf" }} ``` diff --git a/.tflint.examples.hcl b/.tflint.examples.hcl new file mode 100644 index 0000000..23bb204 --- /dev/null +++ b/.tflint.examples.hcl @@ -0,0 +1,21 @@ +tflint { + required_version = "~> 0.50" +} + +plugin "azurerm" { + enabled = true + version = "0.27.0" + source = "github.com/terraform-linters/tflint-ruleset-azurerm" +} + +rule "terraform_required_version" { + enabled = false +} + +rule "terraform_required_providers" { + enabled = false +} + +rule "terraform_module_version" { + enabled = false +} diff --git a/.tflint.hcl b/.tflint.hcl new file mode 100644 index 0000000..23fc23b --- /dev/null +++ b/.tflint.hcl @@ -0,0 +1,9 @@ +tflint { + required_version = "~> 0.50" +} + +plugin "azurerm" { + enabled = true + version = "0.27.0" + source = "github.com/terraform-linters/tflint-ruleset-azurerm" +} diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 8a90b6d..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,36 +0,0 @@ -# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - - diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4f46b63..e040ed1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,3 +1,3 @@ # Contribution -Find our contribution guide at [terraform-governance/docs/Module - CONTRIBUTING](https://github.com/cloudeteer/terraform-governance/blob/main/docs/Module%20-%20CONTRIBUTING.md) \ No newline at end of file +Find our contribution guide at [terraform-governance/docs/Module - CONTRIBUTING](https://github.com/cloudeteer/terraform-governance/blob/main/docs/Module%20-%20CONTRIBUTING.md) diff --git a/README.md b/README.md index cd30bea..a40ef90 100644 --- a/README.md +++ b/README.md @@ -1,41 +1,286 @@ -# terraform-module-template + -[![SemVer](https://img.shields.io/badge/SemVer-2.0.0-blue.svg)](CHANGELOG.md) -[![Keep a Changelog](https://img.shields.io/badge/changelog-Keep%20a%20Changelog%20v1.0.0-%23E05735)](CHANGELOG.md) -[![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](.github/CONTRIBUTION.md) +> [!NOTE] +> This repository is publicly accessible as part of our open-source initiative. We welcome contributions from the community alongside our organization's primary development efforts. -Terraform Module Template +--- + +# terraform-azurerm-launchpad + +[![SemVer](https://img.shields.io/badge/SemVer-2.0.0-blue.svg)](https://github.com/cloudeteer/terraform-azurerm-launchpad/releases) + +This module provisions all essential infrastructure components within an Azure tenant to enable secure, automated management using Terraform and GitHub. It sets up a GitHub private runner, a Terraform state storage account, and other key resources necessary for fully automated Terraform deployments. The module is designed to adhere to security best practices throughout the process. +## Design + +The IaC Launchpad is a collection of essential Azure resources required for managing Terraform deployments via Cloudeteer GitHub Actions. The term “Launchpad” draws an analogy to rocket science, emphasizing the foundational role it plays. + +[![Launchpad Design](images/diagram.svg)](images/diagram.png) ## Usage +This example demonstrates how to deploy the Launchpad in a default scenario. + +The two variables, `runner_github_pat` and `runner_github_repo`, should be set at runtime during deployment using the environment variables `TF_VAR_runner_github_pat` and `TF_VAR_runner_github_repo`. + ```hcl -module "terraform_module_example" { - source = "cloudeteer/terraform-module-example/azurerm" +variable "my_runner_github_pat" { + type = string +} +variable "my_runner_github_repo" { + type = string +} + +resource "azurerm_resource_group" "example" { + location = "germanywestcentral" + name = "rg-example-dev-gwc-01" +} + +module "example" { + source = "cloudeteer/launchpad/azurerm" + + resource_group_name = azurerm_resource_group.example.name + location = azurerm_resource_group.example.location + + runner_github_pat = var.my_runner_github_pat + runner_github_repo = var.my_runner_github_repo + + virtual_network_address_space = ["10.0.0.0/16"] + subnet_address_prefixes = ["10.0.2.0/24"] + management_group_names = ["mg-example"] } ``` ## Providers -No providers. +The following providers are used by this module: + +- [azurerm](#provider\_azurerm) (>= 3.114) + +- [random](#provider\_random) (>= 3.6) -## Modules -No modules. ## Resources -No resources. +The following resources are used by this module: + +- [azurerm_federated_identity_credential.this](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/federated_identity_credential) (resource) +- [azurerm_key_vault.this](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/key_vault) (resource) +- [azurerm_key_vault_secret.virtual_machine_scale_set_admin_password](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/key_vault_secret) (resource) +- [azurerm_linux_virtual_machine_scale_set.this](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/linux_virtual_machine_scale_set) (resource) +- [azurerm_management_lock.storage_account_lock](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/management_lock) (resource) +- [azurerm_network_security_group.this](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/network_security_group) (resource) +- [azurerm_private_endpoint.key_vault](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/private_endpoint) (resource) +- [azurerm_private_endpoint.storage_account](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/private_endpoint) (resource) +- [azurerm_role_assignment.key_vault_admin_current_user](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_assignment) (resource) +- [azurerm_role_assignment.management_group_owner](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_assignment) (resource) +- [azurerm_role_assignment.resource_specific](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_assignment) (resource) +- [azurerm_role_assignment.storage_account_blob_owner_current_user](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_assignment) (resource) +- [azurerm_role_assignment.subscription_owner](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_assignment) (resource) +- [azurerm_storage_account.this](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/storage_account) (resource) +- [azurerm_storage_container.this](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/storage_container) (resource) +- [azurerm_subnet.this](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/subnet) (resource) +- [azurerm_subnet_network_security_group_association.this](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/subnet_network_security_group_association) (resource) +- [azurerm_user_assigned_identity.this](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/user_assigned_identity) (resource) +- [azurerm_virtual_network.this](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/virtual_network) (resource) +- [random_password.virtual_machine_scale_set_admin_password](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/password) (resource) +- [random_string.kvlaunchpadprd_suffix](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/string) (resource) +- [random_string.stlaunchpadprd_suffix](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/string) (resource) +- [azurerm_client_config.current](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/data-sources/client_config) (data source) +- [azurerm_management_group.managed_by_launchpad](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/data-sources/management_group) (data source) +- [azurerm_subscription.managed_by_launchpad](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/data-sources/subscription) (data source) + +## Required Inputs + +The following input variables are required: + +### [location](#input\_location) + +Description: The geographic location where the resources will be deployed. This is must be a region name supported by Azure. + +Type: `string` + +### [management\_group\_names](#input\_management\_group\_names) + +Description: A list of management group in order the Launchpad gets Owner-permission in these management-groups. + +Type: `list(string)` + +### [resource\_group\_name](#input\_resource\_group\_name) + +Description: The name of the resource group in which the virtual machine should exist. Changing this forces a new resource to be created. + +Type: `string` + +### [runner\_github\_pat](#input\_runner\_github\_pat) + +Description: GitHub PAT that will be used to register GitHub Action Runner tokens + +Type: `string` + +### [runner\_github\_repo](#input\_runner\_github\_repo) + +Description: Specify the GitHub repository owner and name seperated by `/` to register the action runner. e.g. `cloudeteer/squad-customer` + +Type: `string` + +### [subnet\_address\_prefixes](#input\_subnet\_address\_prefixes) + +Description: A list of IP address prefixes (CIDR blocks) to be assigned to the subnet. Each entry in the list represents a CIDR block used to define the address space of the subnet within the virtual network. + +Type: `list(string)` + +### [virtual\_network\_address\_space](#input\_virtual\_network\_address\_space) + +Description: A list of IP address ranges to be assigned to the virtual network (VNet). Each entry in the list represents a CIDR block used to define the address space of the VNet. + +Type: `list(string)` + +## Optional Inputs + +The following input variables are optional (have default values): + +### [init](#input\_init) + +Description: Is used for initiating the module itself for the first time. For more information please go here https://github.com/cloudeteer/terraform-azurerm-launchpad/blob/main/INSTALL.md + +Type: `bool` + +Default: `false` + +### [init\_access\_azure\_principal\_id](#input\_init\_access\_azure\_principal\_id) + +Description: n/a + +Type: `string` + +Default: `null` + +### [init\_access\_ip\_address](#input\_init\_access\_ip\_address) + +Description: Set the IP Address of your current public IP in order to access the new created resources. For more information please go here https://github.com/cloudeteer/terraform-azurerm-launchpad/blob/main/INSTALL.md + +Type: `string` + +Default: `null` -## Inputs +### [key\_vault\_private\_dns\_zone\_ids](#input\_key\_vault\_private\_dns\_zone\_ids) -| Name | Description | Type | Default | Required | -|------|-------------|------|---------|:--------:| -| example\_variable | Example variable (between 3 and 13 characters) | `string` | n/a | yes | +Description: A list of ID´s of DNS Zones in order to add the Private Endpoint of the Keyvault into your DNS Zones. + +Type: `list(string)` + +Default: `[]` + +### [runner\_arch](#input\_runner\_arch) + +Description: The CPU architecture to run the GitHub actions runner. Can be `x64` or `arm64`. + +Type: `string` + +Default: `"arm64"` + +### [runner\_count](#input\_runner\_count) + +Description: Specify the number of instances of a GitHub Action runner to install on a single virtual machine instance. + +Type: `string` + +Default: `"5"` + +### [runner\_github\_environments](#input\_runner\_github\_environments) + +Description: List of Github environments used by federal identity. + +Type: `map(string)` + +Default: + +```json +{ + "prod-azure": "prod-azure", + "prod-azure-plan": "prod-azure (plan)" +} +``` + +### [runner\_public\_ip\_address](#input\_runner\_public\_ip\_address) + +Description: Set the value of this variable to `true` if you want to allocate a public IP address to each instance within the Virtual Machine Scale Set. Enabling this option may be necessary to establish internet access when a direct connection to a HUB is currently unavailable. + +Type: `bool` + +Default: `false` + +### [runner\_user](#input\_runner\_user) + +Description: An unprivileged user to run the Runner application. If this user does not exist on the system, a new user will be created. + +Type: `string` + +Default: `"actions-runner"` + +### [runner\_version](#input\_runner\_version) + +Description: Set a specific GitHub action runner version (without the `v` in the version string) or use `latest`. + +Type: `string` + +Default: `"latest"` + +### [runner\_vm\_instances](#input\_runner\_vm\_instances) + +Description: Set the amount of VM´s in the Virtual Machine Sscale Set (VMSS). (Default '1') + +Type: `string` + +Default: `1` + +### [subscription\_ids](#input\_subscription\_ids) + +Description: A list of subscription IDs, which the Launchpad will manage.Each must be exactly 36 characters long. + +Type: `list(string)` + +Default: `[]` + +### [tags](#input\_tags) + +Description: A mapping of tags which should be assigned to all resources in this module. + +Type: `map(string)` + +Default: `{}` ## Outputs -| Name | Description | -|------|-------------| -| example\_output | n/a | - \ No newline at end of file +The following outputs are exported: + +### [LAUNCHPAD\_AZURE\_CLIENT\_ID](#output\_LAUNCHPAD\_AZURE\_CLIENT\_ID) + +Description: The client ID of the Azure user identity assigned to the Launchpad. + +### [LAUNCHPAD\_AZURE\_STORAGE\_ACCOUNT\_NAME](#output\_LAUNCHPAD\_AZURE\_STORAGE\_ACCOUNT\_NAME) + +Description: The storage account name used by the Launchpad for the Terraform state backend. + +### [LAUNCHPAD\_AZURE\_TENANT\_ID](#output\_LAUNCHPAD\_AZURE\_TENANT\_ID) + +Description: The tenant ID of the Azure user identity assigned to the Launchpad + +### [subnet\_id](#output\_subnet\_id) + +Description: The ID of the subnet within the Virtual Network, associated with the Launchpad production environment. + +### [subnet\_name](#output\_subnet\_name) + +Description: The name of the subnet within the Virtual Network, associated with the Launchpad production environment. + +### [virtual\_network\_id](#output\_virtual\_network\_id) + +Description: The ID of the Azure Virtual Network (VNet) associated with the Launchpad. + +### [virtual\_network\_name](#output\_virtual\_network\_name) + +Description: The name of the Azure Virtual Network (VNet) associated with the Launchpad. + diff --git a/assets/install_github_actions_runner.sh.tftpl b/assets/install_github_actions_runner.sh.tftpl new file mode 100644 index 0000000..9445218 --- /dev/null +++ b/assets/install_github_actions_runner.sh.tftpl @@ -0,0 +1,105 @@ +#!/usr/bin/env bash + +set -eu + +# do no rerun on exists VM +# shellcheck disable=SC2154 +if grep -q "${storage_account_hostname}" /etc/hosts; then + exit 0 +fi + +# shellcheck disable=SC1083,SC2288 +%{ if private_endpoint_storage_account_ip != "" && storage_account_hostname != "" } +# shellcheck disable=SC2154 +echo "${private_endpoint_storage_account_ip} ${storage_account_hostname}" >>/etc/hosts +# shellcheck disable=SC1083,SC2288 +%{ endif } + +# shellcheck disable=SC1083,SC2288 +%{ if private_endpoint_key_vault_ip != "" && key_vault_hostname != "" } +# shellcheck disable=SC2154 +echo "${private_endpoint_key_vault_ip} ${key_vault_hostname}" >>/etc/hosts +# shellcheck disable=SC1083,SC2288 +%{ endif } + +export DEBIAN_FRONTEND=noninteractive +apt-get update -yqq +apt-get install -yqq curl jq unzip python3 python3-pip +ln -s /usr/bin/python3 /usr/bin/python + +RUNNER_NAME=$(hostname) + +# Fill variables with Terraform templatefile() +# shellcheck disable=SC2154 +GITHUB_PAT=${runner_github_pat} +# shellcheck disable=SC2154 +RUNNER_ARCH=${runner_arch} +# shellcheck disable=SC2154 +RUNNER_COUNT=${runner_count} +# shellcheck disable=SC2154 +RUNNER_GITHUB_REPO=${runner_github_repo} +# shellcheck disable=SC2154 +RUNNER_VERSION=${runner_version} +# shellcheck disable=SC2154 +RUNNER_USER=${runner_user} + +# Get the latest version +if [ "$RUNNER_VERSION" = "latest" ]; then + RUNNER_VERSION=$(curl -sS -L \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + https://api.github.com/repos/actions/runner/releases/latest | jq -r '.tag_name') + RUNNER_VERSION=$${RUNNER_VERSION#v} +fi + +# Register new runner token (with GITHUB_PAT) +RUNNER_TOKEN=$(curl -sS --fail -L \ + -X POST \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer $GITHUB_PAT" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "https://api.github.com/repos/$RUNNER_GITHUB_REPO/actions/runners/registration-token" | jq -r ".token") + +# Set up unprivileged user +id "$RUNNER_USER" &>/dev/null || useradd --create-home --system \ + --comment "GitHub actions runner unprivileged user" \ + --home "/home/$RUNNER_USER" \ + --shell /usr/sbin/nologin \ + "$RUNNER_USER" + +# Create base install directory +mkdir -p /opt/actions-runner +cd /opt/actions-runner + +# Download the latest runner package +curl -sS --fail -L -o "actions-runner-linux-$RUNNER_ARCH-$RUNNER_VERSION.tar.gz" \ + "https://github.com/actions/runner/releases/download/v$RUNNER_VERSION/actions-runner-linux-$RUNNER_ARCH-$RUNNER_VERSION.tar.gz" + +for i in $(seq 1 "$RUNNER_COUNT"); do + current_dir=$PWD + + mkdir -p "$i" + chown "$RUNNER_USER" "$i" + cd "$i" + + # Extract the installer + sudo -u "$RUNNER_USER" -- tar xzf "../actions-runner-linux-$RUNNER_ARCH-$RUNNER_VERSION.tar.gz" + + # Disable debug output while running 3rd party scripts + set +x + + # Configure the runner + sudo -u "$RUNNER_USER" -- ./config.sh --unattended --replace \ + --url "https://github.com/$RUNNER_GITHUB_REPO" \ + --token "$RUNNER_TOKEN" \ + --name "$RUNNER_NAME-$i" \ + --labels cdt-iac-launchpad + + ./svc.sh install "$RUNNER_USER" + ./svc.sh start + + # Enable debug output again + set -x + cd "$current_dir" + +done diff --git a/examples/usage/README.md b/examples/usage/README.md new file mode 100644 index 0000000..5e9f22e --- /dev/null +++ b/examples/usage/README.md @@ -0,0 +1,11 @@ +# Example: Usage + +This primary usage example is also included in the [README.md](../../README.md) of this module. It demonstrates the launchpad module using as many default values as possible. + +## Prerequisites + +Before you begin, ensure you have the following: + +- [Terraform](https://www.terraform.io/downloads.html) installed on your local machine. +- An [Azure account](https://azure.microsoft.com/en-us/free/) with the appropriate permissions. +- [Azure CLI](https://docs.microsoft.com/en-us/cli/azure/install-azure-cli) installed and authenticated. diff --git a/examples/usage/main.md b/examples/usage/main.md new file mode 100644 index 0000000..bfb7feb --- /dev/null +++ b/examples/usage/main.md @@ -0,0 +1,3 @@ +This example demonstrates how to deploy the Launchpad in a default scenario. + +The two variables, `runner_github_pat` and `runner_github_repo`, should be set at runtime during deployment using the environment variables `TF_VAR_runner_github_pat` and `TF_VAR_runner_github_repo`. diff --git a/examples/usage/main.tf b/examples/usage/main.tf index 8acda17..4405288 100644 --- a/examples/usage/main.tf +++ b/examples/usage/main.tf @@ -1,3 +1,25 @@ -module "terraform_module_example" { - source = "cloudeteer/terraform-module-example/azurerm" +variable "my_runner_github_pat" { + type = string +} +variable "my_runner_github_repo" { + type = string +} + +resource "azurerm_resource_group" "example" { + location = "germanywestcentral" + name = "rg-example-dev-gwc-01" +} + +module "example" { + source = "cloudeteer/launchpad/azurerm" + + resource_group_name = azurerm_resource_group.example.name + location = azurerm_resource_group.example.location + + runner_github_pat = var.my_runner_github_pat + runner_github_repo = var.my_runner_github_repo + + virtual_network_address_space = ["10.0.0.0/16"] + subnet_address_prefixes = ["10.0.2.0/24"] + management_group_names = ["mg-example"] } diff --git a/examples/usage/tests_override.tf b/examples/usage/tests_override.tf new file mode 100644 index 0000000..4c6640c --- /dev/null +++ b/examples/usage/tests_override.tf @@ -0,0 +1,26 @@ +# This override file is mandatory for Terraform tests. +# Not needed to use this example. + +terraform { + required_providers { + azurerm = { + source = "hashicorp/azurerm" + } + random = { + source = "hashicorp/random" + } + } +} + +module "example" { + source = "../.." +} + +variable "my_runner_github_pat" { + type = string + default = "github_pat_0000000000000000000000_00000000000000000000000000000000000000000000000000000000000" +} +variable "my_runner_github_repo" { + type = string + default = "owner/repository" +} diff --git a/images/diagram.d2 b/images/diagram.d2 new file mode 100644 index 0000000..2ebc30c --- /dev/null +++ b/images/diagram.d2 @@ -0,0 +1,135 @@ +# d2 --theme=0 --dark-theme=200 diagram.d2 diagram.svg +# d2 --theme=0 diagram.d2 diagram.png + +title: |md + # IaC Launchpad – High Level Design +| {near: top-center} + +**.style.font-size: 36 +(** -> **)[*].style.font-size: 42 + +Cloudeteer Environment: { + label: "" + GitHub: { + shape: image + icon: https://icons.terrastruct.com/dev%2Fgithub.svg + } + + readme: |md + ## Cloudeteer GitHub Enterprise + - Private Repositories + - Workflow Definitions + - Terraform Code + - Microsoft Entra SSO + | +} + +Azure API: { + shape: image + icon: https://icons.terrastruct.com/azure%2FCompute%20Service%20Color%2FCloud%20Services.svg +} + +Customer Azure Tenant: { + Managed Service Subscription: { + icon: https://icons.terrastruct.com/azure%2FGeneral%20Service%20Icons%2FSubscriptions.svg + + Managed Identity: { + label: "" + image: { + label: "" + shape: image + icon: https://icons.terrastruct.com/azure%2FIdentity%20Service%20Color%2FManaged%20Identities.svg + } + + readme: |md + ## Managed Identity + ### Permissions + - **Owner** on selected Management Groups + ### Federation + - Issuer: GitHub Actions + - Subject: github-repo:Environment + | + } + + Managed Service Spoke Network: { + icon: https://icons.terrastruct.com/azure%2FNetworking%20Service%20Color%2FVirtual%20Networks.svg + + GitHub Runner: { + label: GitHub\nRunner + shape: image + icon: https://icons.terrastruct.com/azure%2FCompute%20Service%20Color%2FVM%2FVM%20Scale%20Sets.svg + } + + Storage Account: { + label: Storage\nAccount + shape: image + icon: https://icons.terrastruct.com/azure%2FStorage%20Service%20Color%2FStorage%20Accounts.svg + } + + readme: |md + ## Terraform State + - Infrastrcuture encryption + - Storage Account Key deactivated + - Managed Identity Authentication + - Private Endpoint _only_ + - Geo-Redundant Storage + | + } + + Managed Identity -> Managed Service Spoke Network.GitHub Runner: workload identity + } + + Management Groups: { + style: { + multiple: true + opacity: 0.7 + } + **.style.opacity: 0.7 + + icon: https://icons.terrastruct.com/azure%2FGeneral%20Service%20Icons%2FManagement%20Groups.svg + + Hub Subscription: { + icon: https://icons.terrastruct.com/azure%2FGeneral%20Service%20Icons%2FSubscriptions.svg + + Virtual Network: { + label: Virtual\nNetwork + shape: image + icon: https://icons.terrastruct.com/azure%2FNetworking%20Service%20Color%2FVirtual%20Networks.svg + } + Network Gateway: { + label: Network\nGateway + shape: image + icon: https://icons.terrastruct.com/azure%2FNetworking%20Service%20Color%2FLocal%20Network%20Gateways.svg + } + } + + Spoke Subscriptions: { + icon: https://icons.terrastruct.com/azure%2FGeneral%20Service%20Icons%2FSubscriptions.svg + style.multiple: true + + Virtual Network: { + label: Virtual\nNetwork + shape: image + icon: https://icons.terrastruct.com/azure%2FNetworking%20Service%20Color%2FVirtual%20Networks.svg + } + Resource Groups: { + label: Resource\nGroups + shape: image + icon: https://icons.terrastruct.com/azure%2FGeneral%20Service%20Icons%2FResource%20Groups.svg + } + Bastion Host: { + label: Bastion\nHost + shape: image + icon: https://icons.terrastruct.com/azure%2FCompute%20Service%20Color%2FVM%2FVM.svg + } + } + + Hub Subscription.Virtual Network <-> Spoke Subscriptions.Virtual Network: {style.opacity: 0} + } +} + +Customer Azure Tenant.Managed Service Subscription.Managed Identity -> Cloudeteer Environment: federation +Customer Azure Tenant.Managed Service Subscription.Managed Service Spoke Network.GitHub Runner -> Cloudeteer Environment: job pull + +Customer Azure Tenant.Managed Service Subscription.Managed Service Spoke Network.GitHub Runner -> Azure API: internet access +Azure API -> Customer Azure Tenant diff --git a/images/diagram.png b/images/diagram.png new file mode 100644 index 0000000..401b8f1 Binary files /dev/null and b/images/diagram.png differ diff --git a/images/diagram.svg b/images/diagram.svg new file mode 100644 index 0000000..c9a7881 --- /dev/null +++ b/images/diagram.svg @@ -0,0 +1,969 @@ +

IaC Launchpad – High Level Design

+
Azure APICustomer Azure TenantGitHub

Cloudeteer GitHub Enterprise

+
    +
  • Private Repositories
  • +
  • Workflow Definitions
  • +
  • Terraform Code
  • +
  • Microsoft Entra SSO
  • +
+
Managed Service SubscriptionManagement GroupsManaged Service Spoke NetworkHub SubscriptionSpoke Subscriptions

Managed Identity

+

Permissions

+
    +
  • Owner on selected Management Groups
  • +
+

Federation

+
    +
  • Issuer: GitHub Actions
  • +
  • Subject: github-repo:Environment
  • +
+
GitHubRunnerStorageAccount

Terraform State

+
    +
  • Infrastrcuture encryption
  • +
  • Storage Account Key deactivated
  • +
  • Managed Identity Authentication
  • +
  • Private Endpoint only
  • +
  • Geo-Redundant Storage
  • +
+
VirtualNetworkNetworkGatewayVirtualNetworkResourceGroupsBastionHost workload identity federationjob pullinternet access + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/images/github-pat-settings.png b/images/github-pat-settings.png new file mode 100644 index 0000000..a9f55d8 Binary files /dev/null and b/images/github-pat-settings.png differ diff --git a/locals.tf b/locals.tf new file mode 100644 index 0000000..4234d92 --- /dev/null +++ b/locals.tf @@ -0,0 +1,80 @@ +locals { + init_access_azure_principal_id = ( + var.init_access_azure_principal_id == null ? + data.azurerm_client_config.current.object_id : + var.init_access_azure_principal_id + ) + location_short = { + asia = "asia" + asiapacific = "apac" + australia = "aus" + australiacentral = "auc" + australiacentral2 = "auc2" + australiaeast = "aue" + australiasoutheast = "ause" + brazil = "bra" + brazilsouth = "brs" + brazilsoutheast = "brse" + canada = "can" + canadacentral = "cac" + canadaeast = "cae" + centralindia = "inc" + centralus = "usc" + chinaeast = "cne" + chinaeast2 = "cne2" + chinaeast3 = "cne3" + chinanorth = "cnn" + chinanorth2 = "cnn2" + chinanorth3 = "cnn3" + eastasia = "asea" + eastus = "use" + eastus2 = "use2" + europe = "eu" + francecentral = "frc" + francesouth = "frs" + germanycentral = "gce" + germanynorth = "gno" + germanynortheast = "gne" + germanywestcentral = "gwc" + global = "glob" + india = "ind" + israelcentral = "ilc" + italynorth = "itn" + japan = "jap" + japaneast = "jpe" + japanwest = "jpw" + korea = "kor" + koreacentral = "krc" + koreasouth = "krs" + northcentralus = "usnc" + northeurope = "eun" + norway = "nor" + norwayeast = "noe" + norwaywest = "now" + polandcentral = "polc" + qatarcentral = "qatc" + singapore = "sgp" + southafricanorth = "san" + southafricawest = "saw" + southcentralus = "ussc" + southeastasia = "asse" + southindia = "ins" + sweden = "swe" + swedencentral = "swec" + swedensouth = "swes" + switzerlandnorth = "swn" + switzerlandwest = "sww" + uaecentral = "uaec" + uaenorth = "uaen" + uk = "uk" + uksouth = "uks" + ukwest = "ukw" + unitedstates = "us" + westcentralus = "uswc" + westeurope = "euw" + westindia = "inw" + westus = "usw" + westus2 = "usw2" + westus3 = "usw3" + } +} diff --git a/main.tf b/main.tf index e69de29..ab3432c 100644 --- a/main.tf +++ b/main.tf @@ -0,0 +1,11 @@ +data "azurerm_client_config" "current" {} + +data "azurerm_subscription" "managed_by_launchpad" { + for_each = toset(var.subscription_ids) + subscription_id = each.key +} + +data "azurerm_management_group" "managed_by_launchpad" { + for_each = toset(var.management_group_names) + name = each.value +} diff --git a/outputs.tf b/outputs.tf index fc8ab24..9cc295d 100644 --- a/outputs.tf +++ b/outputs.tf @@ -1,3 +1,34 @@ -output "example_output" { - value = var.example_variable +output "LAUNCHPAD_AZURE_CLIENT_ID" { + value = azurerm_user_assigned_identity.this.client_id + description = "The client ID of the Azure user identity assigned to the Launchpad." +} + +output "LAUNCHPAD_AZURE_STORAGE_ACCOUNT_NAME" { + value = azurerm_storage_account.this.name + description = "The storage account name used by the Launchpad for the Terraform state backend." +} + +output "LAUNCHPAD_AZURE_TENANT_ID" { + value = azurerm_user_assigned_identity.this.tenant_id + description = "The tenant ID of the Azure user identity assigned to the Launchpad" +} + +output "subnet_id" { + value = azurerm_subnet.this.id + description = "The ID of the subnet within the Virtual Network, associated with the Launchpad production environment." +} + +output "subnet_name" { + value = azurerm_subnet.this.name + description = "The name of the subnet within the Virtual Network, associated with the Launchpad production environment." +} + +output "virtual_network_id" { + value = azurerm_virtual_network.this.id + description = "The ID of the Azure Virtual Network (VNet) associated with the Launchpad." +} + +output "virtual_network_name" { + value = azurerm_virtual_network.this.name + description = "The name of the Azure Virtual Network (VNet) associated with the Launchpad." } diff --git a/r-identity.tf b/r-identity.tf new file mode 100644 index 0000000..85812c3 --- /dev/null +++ b/r-identity.tf @@ -0,0 +1,52 @@ +resource "azurerm_user_assigned_identity" "this" { + name = "id-launchpad-prd-${local.location_short[var.location]}" + location = var.location + resource_group_name = var.resource_group_name + tags = var.tags +} + +resource "azurerm_federated_identity_credential" "this" { + for_each = var.runner_github_environments + + name = each.key + + audience = ["api://AzureADTokenExchange"] + issuer = "https://token.actions.githubusercontent.com" + parent_id = azurerm_user_assigned_identity.this.id + resource_group_name = azurerm_user_assigned_identity.this.resource_group_name + subject = "repo:${var.runner_github_repo}:environment:${each.value}" +} + + +resource "azurerm_role_assignment" "management_group_owner" { + for_each = data.azurerm_management_group.managed_by_launchpad + + principal_id = azurerm_user_assigned_identity.this.principal_id + role_definition_name = "Owner" + scope = each.value.id +} + +resource "azurerm_role_assignment" "subscription_owner" { + for_each = toset(var.subscription_ids) + + principal_id = azurerm_user_assigned_identity.this.principal_id + role_definition_name = "Owner" + scope = data.azurerm_subscription.managed_by_launchpad[each.key].id +} + +resource "azurerm_role_assignment" "resource_specific" { + for_each = { + storage_blob_owner = { + role_definition_name = "Storage Blob Data Owner" + scope = "/subscriptions/${data.azurerm_client_config.current.subscription_id}/resourceGroups/${var.resource_group_name}" + }, + key_vault_admin = { + role_definition_name = "Key Vault Administrator" + scope = "/subscriptions/${data.azurerm_client_config.current.subscription_id}/resourceGroups/${var.resource_group_name}" + } + } + + principal_id = azurerm_user_assigned_identity.this.principal_id + scope = each.value.scope + role_definition_name = each.value.role_definition_name +} diff --git a/r-key-vault.tf b/r-key-vault.tf new file mode 100644 index 0000000..424c367 --- /dev/null +++ b/r-key-vault.tf @@ -0,0 +1,59 @@ +resource "random_string" "kvlaunchpadprd_suffix" { + length = 3 + special = false + upper = false +} + +resource "azurerm_key_vault" "this" { + name = "kvlaunchpadprd${local.location_short[var.location]}${random_string.kvlaunchpadprd_suffix.result}" + location = var.location + resource_group_name = var.resource_group_name + tags = var.tags + + tenant_id = data.azurerm_client_config.current.tenant_id + + enable_rbac_authorization = true + public_network_access_enabled = var.init ? true : false + purge_protection_enabled = true + sku_name = "standard" + soft_delete_retention_days = 30 + + network_acls { + default_action = "Deny" + bypass = "None" + ip_rules = var.init ? [var.init_access_ip_address] : [] + } +} + +resource "azurerm_private_endpoint" "key_vault" { + name = "pe-${azurerm_key_vault.this.name}-prd-${local.location_short[var.location]}" + location = var.location + resource_group_name = var.resource_group_name + tags = var.tags + + subnet_id = azurerm_subnet.this.id + + private_service_connection { + name = "vault" + private_connection_resource_id = azurerm_key_vault.this.id + subresource_names = ["vault"] + is_manual_connection = false + } + + dynamic "private_dns_zone_group" { + for_each = length(var.key_vault_private_dns_zone_ids) > 0 ? [1] : [] + content { + name = "default" + private_dns_zone_ids = var.key_vault_private_dns_zone_ids + } + } +} + +resource "azurerm_role_assignment" "key_vault_admin_current_user" { + count = var.init ? 1 : 0 + + description = "Temporary role assignment. Delete this assignment if unsure why it is still existing." + principal_id = local.init_access_azure_principal_id + role_definition_name = "Key Vault Administrator" + scope = azurerm_key_vault.this.id +} diff --git a/r-network.tf b/r-network.tf new file mode 100644 index 0000000..10b6c87 --- /dev/null +++ b/r-network.tf @@ -0,0 +1,28 @@ +resource "azurerm_virtual_network" "this" { + name = "vnet-launchpad-prd-${local.location_short[var.location]}" + resource_group_name = var.resource_group_name + location = var.location + tags = var.tags + + address_space = var.virtual_network_address_space +} + + +resource "azurerm_subnet" "this" { + name = "snet-launchpad-prd-${local.location_short[var.location]}" + resource_group_name = var.resource_group_name + virtual_network_name = azurerm_virtual_network.this.name + address_prefixes = var.subnet_address_prefixes +} + +resource "azurerm_network_security_group" "this" { + name = "nsg-launchpad-prd-${local.location_short[var.location]}" + location = var.location + resource_group_name = var.resource_group_name + tags = var.tags +} + +resource "azurerm_subnet_network_security_group_association" "this" { + subnet_id = azurerm_subnet.this.id + network_security_group_id = azurerm_network_security_group.this.id +} diff --git a/r-storage-account.tf b/r-storage-account.tf new file mode 100644 index 0000000..07da37d --- /dev/null +++ b/r-storage-account.tf @@ -0,0 +1,82 @@ +resource "random_string" "stlaunchpadprd_suffix" { + length = 3 + special = false + upper = false +} + +resource "azurerm_management_lock" "storage_account_lock" { + count = var.init ? 0 : 1 + name = "storage_account_lock" + scope = azurerm_storage_account.this.id + lock_level = "CanNotDelete" + notes = "For safety reasons, the Storage Account can not be deleted." +} + +resource "azurerm_storage_account" "this" { + name = "stlaunchpadprd${local.location_short[var.location]}${random_string.stlaunchpadprd_suffix.result}" + location = var.location + resource_group_name = var.resource_group_name + tags = var.tags + + account_kind = "StorageV2" + account_tier = "Standard" + account_replication_type = "RAGRS" + + cross_tenant_replication_enabled = false + default_to_oauth_authentication = true + https_traffic_only_enabled = true + infrastructure_encryption_enabled = true + is_hns_enabled = false + large_file_share_enabled = false + min_tls_version = "TLS1_2" + public_network_access_enabled = var.init ? true : false + shared_access_key_enabled = false + + dynamic "network_rules" { + for_each = var.init ? [true] : [] + content { + default_action = "Deny" + ip_rules = [var.init_access_ip_address] + bypass = ["AzureServices"] + } + } + + blob_properties { + versioning_enabled = true + } + + lifecycle { + ignore_changes = [network_rules[0].private_link_access] + } +} + +resource "azurerm_storage_container" "this" { + name = "tfstate" + storage_account_name = azurerm_storage_account.this.name + container_access_type = "private" +} + +resource "azurerm_private_endpoint" "storage_account" { + name = "pe-${azurerm_storage_account.this.name}-prd-${local.location_short[var.location]}" + location = var.location + resource_group_name = var.resource_group_name + tags = var.tags + + subnet_id = azurerm_subnet.this.id + + private_service_connection { + name = "blob" + private_connection_resource_id = azurerm_storage_account.this.id + subresource_names = ["blob"] + is_manual_connection = false + } +} + +resource "azurerm_role_assignment" "storage_account_blob_owner_current_user" { + count = var.init ? 1 : 0 + + description = "Temporary role assignment. Delete this assignment if unsure why it is still existing." + principal_id = local.init_access_azure_principal_id + role_definition_name = "Storage Blob Data Owner" + scope = azurerm_storage_account.this.id +} diff --git a/r-virtual-machine-scale-set.tf b/r-virtual-machine-scale-set.tf new file mode 100644 index 0000000..b93431a --- /dev/null +++ b/r-virtual-machine-scale-set.tf @@ -0,0 +1,144 @@ +locals { + admin_username = "azureadmin" + github_runner_script = base64gzip(templatefile("${path.module}/assets/install_github_actions_runner.sh.tftpl", { + key_vault_hostname = "${azurerm_key_vault.this.name}.vault.azure.net" + private_endpoint_key_vault_ip = one(azurerm_private_endpoint.key_vault.private_service_connection[*].private_ip_address) + private_endpoint_storage_account_ip = one(azurerm_private_endpoint.storage_account.private_service_connection[*].private_ip_address) + storage_account_hostname = azurerm_storage_account.this.primary_blob_host + + runner_arch = var.runner_arch + runner_count = var.runner_count + runner_github_pat = var.runner_github_pat + runner_github_repo = var.runner_github_repo + runner_user = var.runner_user + runner_version = var.runner_version + })) +} + +resource "azurerm_linux_virtual_machine_scale_set" "this" { + name = "vmss-launchpad-prd-${local.location_short[var.location]}" + location = var.location + resource_group_name = var.resource_group_name + tags = var.tags + + admin_password = random_password.virtual_machine_scale_set_admin_password.result + admin_username = local.admin_username + computer_name_prefix = "vm-launchpad" + disable_password_authentication = false + instances = var.runner_vm_instances + sku = "Standard_D2plds_v5" + encryption_at_host_enabled = false + + automatic_os_upgrade_policy { + disable_automatic_rollback = false + enable_automatic_os_upgrade = false + } + + automatic_instance_repair { + enabled = true + grace_period = "PT10M" + } + + boot_diagnostics { + storage_account_uri = null + } + + rolling_upgrade_policy { + max_batch_instance_percent = 100 + max_unhealthy_instance_percent = 100 + max_unhealthy_upgraded_instance_percent = 100 + pause_time_between_batches = "PT0M" + } + + extension_operations_enabled = true + extensions_time_budget = "PT15M" + provision_vm_agent = true + upgrade_mode = "Automatic" + secure_boot_enabled = false + vtpm_enabled = false + overprovision = true + + # trigger instance update + custom_data = base64encode("#cloud-config\n#${sha256(local.github_runner_script)}") + + source_image_reference { + publisher = "Canonical" + offer = "0001-com-ubuntu-server-jammy" + sku = "22_04-lts-arm64" + version = "latest" + } + + os_disk { + storage_account_type = "Standard_LRS" + caching = "ReadOnly" + disk_size_gb = 49 + + diff_disk_settings { + option = "Local" + placement = "CacheDisk" + } + } + + network_interface { + name = "primary" + primary = true + + ip_configuration { + name = "internal" + primary = true + subnet_id = azurerm_subnet.this.id + + dynamic "public_ip_address" { + for_each = var.runner_public_ip_address ? [true] : [] + content { + name = "public" + } + } + } + } + + extension { + name = "github-actions-runner" + publisher = "Microsoft.Azure.Extensions" + type = "CustomScript" + type_handler_version = "2.0" + + settings = jsonencode({ + "skipDos2Unix" : true + }) + + protected_settings = jsonencode({ + "script" = local.github_runner_script + }) + } + + extension { + name = "health" + publisher = "Microsoft.ManagedServices" + type = "ApplicationHealthLinux" + automatic_upgrade_enabled = true + type_handler_version = "1.0" + + settings = jsonencode({ + protocol = "tcp" + port = 22 + }) + } +} + +resource "random_password" "virtual_machine_scale_set_admin_password" { + length = 30 +} + +#trivy:ignore:avd-azu-0017 +#trivy:ignore:avd-azu-0013 +resource "azurerm_key_vault_secret" "virtual_machine_scale_set_admin_password" { + + name = "${azurerm_linux_virtual_machine_scale_set.this.name}-${azurerm_linux_virtual_machine_scale_set.this.admin_username}-password" + + content_type = "Password" + key_vault_id = azurerm_key_vault.this.id + value = random_password.virtual_machine_scale_set_admin_password.result + + depends_on = [azurerm_role_assignment.key_vault_admin_current_user] +} diff --git a/renovate.json b/renovate.json index 6b9be16..3c2a3f1 100644 --- a/renovate.json +++ b/renovate.json @@ -2,5 +2,8 @@ "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": [ "github>cloudeteer/terraform-governance:renovate-default.json" - ] + ], + "pre-commit": { + "enabled": true + } } diff --git a/terraform.tf b/terraform.tf new file mode 100644 index 0000000..4dd7f73 --- /dev/null +++ b/terraform.tf @@ -0,0 +1,13 @@ +terraform { + required_version = ">= 1.9" + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = ">= 3.114" + } + random = { + source = "hashicorp/random" + version = ">= 3.6" + } + } +} diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..1e9a4d1 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,33 @@ +# Terraform Tests + +This directory contains a set of Terraform tests categorized into `local`, `remote`, and `examples` to streamline the development and CI/CD processes. + +## `./examples` + +This directory contains tests for all Terraform examples in `../examples`. The following script modifies the `source` path in each example, initializes the test directory, runs the tests, and then reverts the changes. + +```shell +terraform init -test-directory=tests/examples +terraform test -test-directory=tests/examples +``` + +## `./local` + +This directory is for tests intended to run locally during development. Use the following commands to initialize and run the tests: + +```shell +terraform init -test-directory=tests/local +terraform test -test-directory=tests/local +``` + +## `./remote` + +This directory contains tests designed to be executed by CI/CD pipelines. +To test this locally, you need to set the `ARM_SUBSCRIPTION_ID` variable because the tests actually provision real resources on Azure. +Use the following commands to initialize and run the tests: + +```shell +export ARM_SUBSCRIPTION_ID=00000000-0000-0000-0000-000000000000 +terraform init -test-directory=tests/remote +terraform test -test-directory=tests/remote +``` diff --git a/tests/examples/mocks/main.tfmock.hcl b/tests/examples/mocks/main.tfmock.hcl new file mode 100644 index 0000000..0324f9b --- /dev/null +++ b/tests/examples/mocks/main.tfmock.hcl @@ -0,0 +1,49 @@ +mock_data "azurerm_client_config" { + defaults = { + tenant_id = "00000000-0000-0000-0000-000000000000" + object_id = "00000000-0000-0000-0000-000000000000" + } +} + +mock_data "azurerm_management_group" { + defaults = { + id = "/providers/Microsoft.Management/managementGroups/MG-MOCK" + name = "MG-MOCK" + } +} + +mock_resource "azurerm_user_assigned_identity" { + defaults = { + id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/RG-MOCK/providers/Microsoft.ManagedIdentity/userAssignedIdentities/ID-MOCK" + } +} + +mock_resource "azurerm_role_assignment" { + defaults = { + id = "/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Authorization/roleAssignments/00000000-0000-0000-0000-000000000000|00000000-0000-0000-0000-000000000000" + } +} + +mock_resource "azurerm_subnet" { + defaults = { + id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/RG-MOCK/providers/Microsoft.Network/virtualNetworks/virtualNetworksValue/subnets/SNET-MOCK" + } +} + +mock_resource "azurerm_key_vault" { + defaults = { + id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/RG-MOCK/providers/Microsoft.KeyVault/vaults/KV-MOCK" + } +} + +mock_resource "azurerm_storage_account" { + defaults = { + id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/RG-MOCK/providers/Microsoft.Storage/storageAccounts/STMOCK" + } +} + +mock_resource "azurerm_network_security_group" { + defaults = { + id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/RG-MOCK/providers/Microsoft.Network/networkSecurityGroups/NSG-MOCK" + } +} diff --git a/tests/examples/usage.tftest.hcl b/tests/examples/usage.tftest.hcl new file mode 100644 index 0000000..6c9f6e2 --- /dev/null +++ b/tests/examples/usage.tftest.hcl @@ -0,0 +1,10 @@ +mock_provider "azurerm" { source = "./tests/examples/mocks" } +mock_provider "random" { source = "./tests/examples/mocks" } + +run "test_example_usage" { + command = plan + + module { + source = "./examples/usage" + } +} diff --git a/tests/local/main.tftest.hcl b/tests/local/main.tftest.hcl new file mode 100644 index 0000000..268adc0 --- /dev/null +++ b/tests/local/main.tftest.hcl @@ -0,0 +1,62 @@ +variables { + location = "germanywestcentral" + management_group_names = ["mg-test-1", "mg-test-2"] + resource_group_name = "rg-test-1" + runner_github_pat = "github_pat_0000000000000000000000_00000000000000000000000000000000000000000000000000000000000" + runner_github_repo = "owner/repo" + subnet_address_prefixes = ["192.168.0.0/24"] + subscription_ids = ["00000000-0000-0000-0000-000000000000"] + virtual_network_address_space = ["192.168.0.0/24"] +} + +mock_provider "azurerm" { + source = "./tests/local/mocks" +} + +run "should_fail_with_wrong_runner_github_repo_format" { + variables { + runner_github_repo = "cloudeteer-squadTerraform" + } + command = plan + expect_failures = [var.runner_github_repo] +} + +run "should_fail_with_wrong_runner_github_repo_format_2" { + variables { + runner_github_repo = "cloudeteer/squadTerraform/customer" + } + command = plan + expect_failures = [var.runner_github_repo] +} + +run "should_fail_on_missing_init_access_ip_address" { + command = plan + variables { + init = true + } + expect_failures = [var.init_access_ip_address] +} + +run "should_pass_on_init_true" { + command = plan + variables { + init = true + init_access_ip_address = "127.0.0.1" + } +} + +run "should_fail_on_undefined_runner_arch" { + command = plan + variables { + runner_arch = "arm86" + } + expect_failures = [var.runner_arch] +} + +run "should_fail_on_invalid_subscription_ids_format" { + command = plan + variables { + subscription_ids = ["00000000-0000-0000-0000-000000000000", "00000000000000000000000000000000"] + } + expect_failures = [var.subscription_ids] +} diff --git a/tests/local/mocks/main.tfmock.hcl b/tests/local/mocks/main.tfmock.hcl new file mode 100644 index 0000000..7e69979 --- /dev/null +++ b/tests/local/mocks/main.tfmock.hcl @@ -0,0 +1,56 @@ +mock_data "azurerm_client_config" { + defaults = { + tenant_id = "00000000-0000-0000-0000-000000000000" + object_id = "00000000-0000-0000-0000-000000000000" + } +} + +mock_data "azurerm_management_group" { + defaults = { + id = "/providers/Microsoft.Management/managementGroups/MG-MOCK" + name = "MG-MOCK" + } +} + +mock_data "azurerm_subscription" { + defaults = { + id = "/subscriptions/00000000-0000-0000-0000-000000000000" + name = "SUB-MOCK-1" + } +} + +mock_resource "azurerm_user_assigned_identity" { + defaults = { + id = "/subscriptions/12345678-1234-9876-4563-123456789012/resourceGroups/RG-MOCK/providers/Microsoft.ManagedIdentity/userAssignedIdentities/ID-MOCK" + } +} + +mock_resource "azurerm_role_assignment" { + defaults = { + id = "/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Authorization/roleAssignments/00000000-0000-0000-0000-000000000000|00000000-0000-0000-0000-000000000000" + } +} + +mock_resource "azurerm_subnet" { + defaults = { + id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/RG-MOCK/providers/Microsoft.Network/virtualNetworks/virtualNetworksValue/subnets/SNET-MOCK" + } +} + +mock_resource "azurerm_key_vault" { + defaults = { + id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/RG-MOCK/providers/Microsoft.KeyVault/vaults/KV-MOCK" + } +} + +mock_resource "azurerm_storage_account" { + defaults = { + id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/RG-MOCK/providers/Microsoft.Storage/storageAccounts/STMOCK" + } +} + +mock_resource "azurerm_network_security_group" { + defaults = { + id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/RG-MOCK/providers/Microsoft.Network/networkSecurityGroups/NSG-MOCK" + } +} diff --git a/tests/main.tftest.hcl b/tests/main.tftest.hcl deleted file mode 100644 index d49fb5f..0000000 --- a/tests/main.tftest.hcl +++ /dev/null @@ -1,25 +0,0 @@ -variables { - example_variable = "example value" -} - -run "test_input_validation" { - command = plan - - variables { - example_variable = "ex" - } - - # https://developer.hashicorp.com/terraform/language/tests#expecting-failures - expect_failures = [ - var.example_variable, - ] -} - -run "test_output" { - command = plan - - assert { - condition = output.example_output == "example value" - error_message = "Output example_output not equal to expected value" - } -} diff --git a/tests/remote/main.tftest.hcl b/tests/remote/main.tftest.hcl new file mode 100644 index 0000000..e852263 --- /dev/null +++ b/tests/remote/main.tftest.hcl @@ -0,0 +1,44 @@ +provider "azurerm" { + # mandatory, because we use `default_to_oauth_authentication = true` on the storage account + storage_use_azuread = true + + features { + resource_group { + prevent_deletion_if_contains_resources = false + } + } +} + +run "setup_tests" { + command = apply + + variables { + location = "westeurope" + resource_group_name = "rg-tftest-dev-euw-01" + } + + module { + source = "./tests/remote" + } +} + +run "deploy_module" { + command = apply + + variables { + resource_group_name = run.setup_tests.resource_group_name + location = run.setup_tests.resource_group_location + management_group_names = [] + subnet_address_prefixes = ["10.0.0.0/28"] + virtual_network_address_space = ["10.0.0.0/27"] + + # Do not create VM instance + runner_vm_instances = 0 + runner_github_pat = "" + runner_github_repo = "cloudeteer/terraform-azurerm-launchpad" + + # Initial deployment + init = true + init_access_ip_address = run.setup_tests.init_access_ip_address + } +} diff --git a/tests/remote/resources.tf b/tests/remote/resources.tf new file mode 100644 index 0000000..06c13e4 --- /dev/null +++ b/tests/remote/resources.tf @@ -0,0 +1,34 @@ +variable "location" { + type = string +} + +variable "resource_group_name" { + type = string +} + +resource "random_string" "resource_group_suffix" { + length = 4 + special = false + upper = false +} + +resource "azurerm_resource_group" "tftest" { + name = "${var.resource_group_name}-${random_string.resource_group_suffix.result}" + location = var.location +} + +output "resource_group_name" { + value = azurerm_resource_group.tftest.name +} + +output "resource_group_location" { + value = azurerm_resource_group.tftest.location +} + +data "http" "init_access_ip_address" { + url = "https://ipv4.icanhazip.com" +} + +output "init_access_ip_address" { + value = trimspace(data.http.init_access_ip_address.response_body) +} diff --git a/tests/remote/terraform.tf b/tests/remote/terraform.tf new file mode 100644 index 0000000..a6e8475 --- /dev/null +++ b/tests/remote/terraform.tf @@ -0,0 +1,17 @@ +terraform { + required_version = "1.9.0" + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "3.114.0" + } + random = { + source = "hashicorp/random" + version = "3.6.0" + } + http = { + source = "hashicorp/http" + version = "3.4" + } + } +} diff --git a/variables.tf b/variables.tf index 326741c..d4c4c24 100644 --- a/variables.tf +++ b/variables.tf @@ -1,9 +1,136 @@ -variable "example_variable" { - description = "Example variable (between 3 and 13 characters)" +variable "init" { + type = bool + default = false + description = "Is used for initiating the module itself for the first time. For more information please go here https://github.com/cloudeteer/terraform-azurerm-launchpad/blob/main/INSTALL.md " +} + +variable "init_access_azure_principal_id" { + type = string + default = null +} + +variable "init_access_ip_address" { + type = string + default = null + description = "Set the IP Address of your current public IP in order to access the new created resources. For more information please go here https://github.com/cloudeteer/terraform-azurerm-launchpad/blob/main/INSTALL.md " + + validation { + condition = (var.init && var.init_access_ip_address != null) || (!var.init && var.init_access_ip_address == null) + error_message = "init_access_ip_address ERROR!" + } +} + +variable "key_vault_private_dns_zone_ids" { + type = list(string) + default = [] + description = "A list of ID´s of DNS Zones in order to add the Private Endpoint of the Keyvault into your DNS Zones." +} + +variable "location" { + type = string + description = "The geographic location where the resources will be deployed. This is must be a region name supported by Azure." +} + +variable "management_group_names" { + type = list(string) + description = "A list of management group in order the Launchpad gets Owner-permission in these management-groups." +} + +variable "resource_group_name" { + description = "The name of the resource group in which the virtual machine should exist. Changing this forces a new resource to be created." + type = string +} + +variable "runner_arch" { + type = string + default = "arm64" + description = "The CPU architecture to run the GitHub actions runner. Can be `x64` or `arm64`." + + validation { + condition = contains(["x64", "arm64"], var.runner_arch) + error_message = "This architecture is not allowed. Please use 'x64' or 'arm64'" + } +} + +variable "runner_count" { + type = string + default = "5" + description = "Specify the number of instances of a GitHub Action runner to install on a single virtual machine instance." +} + +variable "runner_github_environments" { + type = map(string) + default = { + prod-azure = "prod-azure" + prod-azure-plan = "prod-azure (plan)" + } + description = "List of Github environments used by federal identity." +} + +variable "runner_github_pat" { + type = string + sensitive = true + description = "GitHub PAT that will be used to register GitHub Action Runner tokens" +} + +variable "runner_github_repo" { type = string + description = "Specify the GitHub repository owner and name seperated by `/` to register the action runner. e.g. `cloudeteer/squad-customer`" validation { - condition = length(var.example_variable) >= 3 && length(var.example_variable) <= 13 - error_message = "Example variable must be between 3 and 13 characters" + error_message = "You must specify the GitHub organization e.g. cloudeteer/squad-customer." + condition = length(split("/", var.runner_github_repo)) == 2 } } + +variable "runner_public_ip_address" { + type = bool + default = false + description = "Set the value of this variable to `true` if you want to allocate a public IP address to each instance within the Virtual Machine Scale Set. Enabling this option may be necessary to establish internet access when a direct connection to a HUB is currently unavailable." +} + +variable "runner_user" { + type = string + default = "actions-runner" + description = "An unprivileged user to run the Runner application. If this user does not exist on the system, a new user will be created." +} + +variable "runner_version" { + type = string + default = "latest" + description = "Set a specific GitHub action runner version (without the `v` in the version string) or use `latest`." +} + +variable "runner_vm_instances" { + type = string + description = "Set the amount of VM´s in the Virtual Machine Sscale Set (VMSS). (Default '1')" + default = 1 +} + +variable "subnet_address_prefixes" { + type = list(string) + description = "A list of IP address prefixes (CIDR blocks) to be assigned to the subnet. Each entry in the list represents a CIDR block used to define the address space of the subnet within the virtual network." +} + +variable "subscription_ids" { + type = list(string) + description = "A list of subscription IDs, which the Launchpad will manage.Each must be exactly 36 characters long." + default = [] + + validation { + condition = alltrue([for id in var.subscription_ids : length(id) == 36]) + error_message = "Each subscription ID must be exactly 36 characters long." + } +} + +variable "tags" { + description = "A mapping of tags which should be assigned to all resources in this module." + + type = map(string) + default = {} +} + +variable "virtual_network_address_space" { + type = list(string) + description = "A list of IP address ranges to be assigned to the virtual network (VNet). Each entry in the list represents a CIDR block used to define the address space of the VNet." +} diff --git a/versions.tf b/versions.tf deleted file mode 100644 index 9c97253..0000000 --- a/versions.tf +++ /dev/null @@ -1,3 +0,0 @@ -terraform { - required_version = "~> 1.8" -}