diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml new file mode 100644 index 00000000..2c6c7b03 --- /dev/null +++ b/.github/workflows/terraform.yml @@ -0,0 +1,60 @@ +name: 'Terraform Azure Deployment' + +on: + workflow_dispatch: + inputs: + directory: + type: choice + description: Terraform directory to apply + required: true + options: + - terraform-init + - terraform + workspace: + type: choice + description: Terraform workspace used for staging + required: true + options: + - dev + - qa + - prod + +jobs: + terraform: + name: 'Terraform Apply' + runs-on: ubuntu-latest + + steps: + - name: 'Checkout Repository' + uses: actions/checkout@v4 + + - name: 'Setup Terraform' + uses: hashicorp/setup-terraform@v3 + with: + terraform_version: 1.9.5 + + - name: 'Configure Azure Credentials - az login' + run: | + az login --service-principal -u ${{ secrets.AZURE_CLIENT_ID }} -p ${{ secrets.AZURE_CLIENT_SECRET }} --tenant ${{ secrets.AZURE_TENANT_ID }} + az account set --subscription ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - name: 'Terraform Init' + run: | + cd ${{github.event.inputs.directory}} + + source scripts/helpers.sh + + export RESOURCE_GROUP_NAME=$(extract_value "resource_group_name" config.azurerm.tfbackend) + export STORAGE_ACCOUNT_NAME=$(extract_value "storage_account_name" config.azurerm.tfbackend) + export ARM_ACCESS_KEY=$(az storage account keys list --resource-group $RESOURCE_GROUP_NAME --account-name $STORAGE_ACCOUNT_NAME --query '[0].value' -o tsv) + + terraform workspace list + terraform workspace new ${{github.event.inputs.directory}} + terraform workspace select ${{github.event.inputs.directory}} + terrafor workspace show + + terraform init --backend-config=config.azurerm.tfbackend + + - name: 'Terraform Plan' + run: | + terraform plan -out main.tfplan \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..f545b03c --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.* +*.tfstate +*.tfstate.backup +*.tfplan +*temp*.txt +*.pem +ssh.key + +!.github +!.gitignore \ No newline at end of file diff --git a/README.md b/README.md index 6f690c0e..73a80c3d 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,65 @@ # wp10-image-factory CARIAD Frame Contract WP10 Image Factory + +# Contents +- `terraform-init` - terraform code for setting up an Azure Storage Account that can be used for saving the terraform state +- `terraform` - terraform modules for deploying the resource group, virtual network, subnet, VM + +# Requirements +- Access to Azure Resource Manager +- `terraform >=1.0.0`, tested with `1.9.5` +- Azure CLI, tested with `2.64.0` +- Azure credentials saved as environment variables: + ``` + export AZURE_CLIENT_ID= + export AZURE_CLIENT_SECRET= + export AZURE_TENANT_ID= + export AZURE_SUBSCRIPTION_ID= + ``` + +# Usage +Login to Azure and select subscription +``` +az login --service-principal -u $AZURE_CLIENT_ID -p AZURE_CLIENT_SECRET --tenant AZURE_TENANT_ID +az account set --subscription AZURE_SUBSCRIPTION_ID +``` + +Setting up a Storage Account for the terraform state backend using terraform-init +``` +# Change directory to +cd terraform-init + +terraform init +terraform plan +terraform apply +``` +Mark down the output containing Azure Storage Account details and create `config.azurerm.tfbackend` from `config.azurerm.tfbackend.tempalte` + +Deploying network and runner modules +``` +# Change into main terraform directory +cd terraform + +# Source helper functions +source scripts/helpers.sh + +# Retrieve Storage Account Access key +export RESOURCE_GROUP_NAME=$(extract_value "resource_group_name" config.azurerm.tfbackend) +export STORAGE_ACCOUNT_NAME=$(extract_value "storage_account_name" config.azurerm.tfbackend) +export ARM_ACCESS_KEY=$(az storage account keys list --resource-group $RESOURCE_GROUP_NAME --account-name $STORAGE_ACCOUNT_NAME --query '[0].value' -o tsv) + +# Decide on a terraform workspace +terraform workspace list +terraform workspace new # Create a terraform workspace, can be used for staging +terraform workspace select # Or select an existing workspace + +# Initialize terraform with Azure Storage Account backend +terraform init --backend-config=config.azurerm.tfbackend + +# Plan, review and apply +terraform plan -out main.tfplan + +terraform apply main.tfplan +``` + +The SSH key associated with the VM will be saved in the current working directory as `private_key.pem` \ No newline at end of file diff --git a/terraform-init/main.tf b/terraform-init/main.tf new file mode 100644 index 00000000..303089ed --- /dev/null +++ b/terraform-init/main.tf @@ -0,0 +1,18 @@ +terraform { + required_version = ">=1.0" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~>3.0" + } + random = { + source = "hashicorp/random" + version = "~>3.0" + } + } +} + +provider "azurerm" { + features {} +} \ No newline at end of file diff --git a/terraform-init/modules.tf b/terraform-init/modules.tf new file mode 100644 index 00000000..0faba4b3 --- /dev/null +++ b/terraform-init/modules.tf @@ -0,0 +1,6 @@ +module "state_storage" { + source = "./modules/state-storage" + + prefix = var.prefix + resource_group_location = var.state_rg_location +} \ No newline at end of file diff --git a/terraform-init/modules/state-storage/README.md b/terraform-init/modules/state-storage/README.md new file mode 100644 index 00000000..30404ce4 --- /dev/null +++ b/terraform-init/modules/state-storage/README.md @@ -0,0 +1 @@ +TODO \ No newline at end of file diff --git a/terraform-init/modules/state-storage/output.tf b/terraform-init/modules/state-storage/output.tf new file mode 100644 index 00000000..d7c2c51b --- /dev/null +++ b/terraform-init/modules/state-storage/output.tf @@ -0,0 +1,11 @@ +output "resource_group_name" { + value = azurerm_resource_group.state_rg.name +} + +output "state_storage_account_name" { + value = azurerm_storage_account.tfstate.name +} + +output "state_container_name" { + value = azurerm_storage_container.tfstate.name +} diff --git a/terraform-init/modules/state-storage/storage.tf b/terraform-init/modules/state-storage/storage.tf new file mode 100644 index 00000000..0c6b4512 --- /dev/null +++ b/terraform-init/modules/state-storage/storage.tf @@ -0,0 +1,29 @@ +resource "azurerm_resource_group" "state_rg" { + location = var.resource_group_location + name = "${var.prefix}-state-rg" +} + +resource "azurerm_storage_account" "tfstate" { + name = "tfstate${random_string.resource_code.result}" + resource_group_name = azurerm_resource_group.state_rg.name + location = azurerm_resource_group.state_rg.location + account_tier = "Standard" + account_replication_type = "LRS" + allow_nested_items_to_be_public = false + + tags = { + environment = "staging" + } +} + +resource "azurerm_storage_container" "tfstate" { + name = "tfstate" + storage_account_name = azurerm_storage_account.tfstate.name + container_access_type = "private" +} + +resource "random_string" "resource_code" { + length = 5 + special = false + upper = false +} diff --git a/terraform-init/modules/state-storage/variables.tf b/terraform-init/modules/state-storage/variables.tf new file mode 100644 index 00000000..74041fd1 --- /dev/null +++ b/terraform-init/modules/state-storage/variables.tf @@ -0,0 +1,9 @@ +variable "prefix" { + type = string + description = "Prefix of the resource name" +} + +variable "resource_group_location" { + type = string + description = "Location of the resource group." +} \ No newline at end of file diff --git a/terraform-init/outputs.tf b/terraform-init/outputs.tf new file mode 100644 index 00000000..d5326650 --- /dev/null +++ b/terraform-init/outputs.tf @@ -0,0 +1,11 @@ +output "resource_group_name" { + value = module.state_storage.resource_group_name +} + +output "state_storage_account_name" { + value = module.state_storage.state_storage_account_name +} + +output "state_container_name" { + value = module.state_storage.state_container_name +} \ No newline at end of file diff --git a/terraform-init/variables.tf b/terraform-init/variables.tf new file mode 100644 index 00000000..16c87dac --- /dev/null +++ b/terraform-init/variables.tf @@ -0,0 +1,16 @@ +variable "state_rg_location" { + default = "westeurope" + description = "Location of the resource group." +} + +variable "resource_group_location" { + default = "westeurope" + description = "Location of the resource group." +} + +variable "prefix" { + type = string + default = "cariad-wp10" + description = "Prefix of the resource name" +} + diff --git a/terraform/config.azurerm.tfbackend b/terraform/config.azurerm.tfbackend new file mode 100644 index 00000000..0f1fa8fc --- /dev/null +++ b/terraform/config.azurerm.tfbackend @@ -0,0 +1,4 @@ +resource_group_name = "cariad-wp10-state-rg" +storage_account_name = "tfstateodd4r" +container_name = "tfstate" +key = "imagefactory.tfstate" diff --git a/terraform/config.azurerm.tfbackend.template b/terraform/config.azurerm.tfbackend.template new file mode 100644 index 00000000..db3dbc15 --- /dev/null +++ b/terraform/config.azurerm.tfbackend.template @@ -0,0 +1,9 @@ +# az login +# ACCOUNT_KEY=$(az storage account keys list --resource-group $RESOURCE_GROUP_NAME --account-name $STORAGE_ACCOUNT_NAME --query '[0].value' -o tsv) +# where $RESOURCE_GROUP_NAME and $STORAGE_ACCOUNT_NAME correspond to ones set below +# export ARM_ACCESS_KEY=$ACCOUNT_KEY + +resource_group_name = "example-rg" +storage_account_name = "examplestorage" +container_name = "examplecontainer" +key = "example.tfstate" diff --git a/terraform/main.tf b/terraform/main.tf new file mode 100644 index 00000000..b69e6fd0 --- /dev/null +++ b/terraform/main.tf @@ -0,0 +1,27 @@ +terraform { + required_version = ">=1.0" + + required_providers { + azapi = { + source = "Azure/azapi" + version = "~>1.15" + } + azurerm = { + source = "hashicorp/azurerm" + version = "~>3.0" + } + random = { + source = "hashicorp/random" + version = "~>3.0" + } + } + + backend "azurerm" {} +} + +provider "azurerm" { + features {} +} + +provider "azapi" { +} \ No newline at end of file diff --git a/terraform/modules.tf b/terraform/modules.tf new file mode 100644 index 00000000..e8be6bf0 --- /dev/null +++ b/terraform/modules.tf @@ -0,0 +1,17 @@ +module "network" { + source = "./modules/network" + + prefix = var.prefix + resource_group_location = var.resource_group_location +} + +module "runner" { + source = "./modules/runner" + + prefix = var.prefix + resource_group_location = var.resource_group_location + resource_group_name = module.network.resource_group.name + resource_group_id = module.network.resource_group.id + subnet_id = module.network.azurerm_subnet.id +} + diff --git a/terraform/modules/network/README.md b/terraform/modules/network/README.md new file mode 100644 index 00000000..e69de29b diff --git a/terraform/modules/network/network.tf b/terraform/modules/network/network.tf new file mode 100644 index 00000000..db48a6ce --- /dev/null +++ b/terraform/modules/network/network.tf @@ -0,0 +1,20 @@ +resource "azurerm_resource_group" "wp10_rg" { + location = var.resource_group_location + name = "${var.prefix}-rg" +} + +# Create virtual network +resource "azurerm_virtual_network" "wp10_vnet" { + name = "${var.prefix}-vnet" + address_space = ["10.0.0.0/16"] + location = azurerm_resource_group.wp10_rg.location + resource_group_name = azurerm_resource_group.wp10_rg.name +} + +# Create subnet +resource "azurerm_subnet" "wp10_subnet" { + name = "${var.prefix}-subnet" + resource_group_name = azurerm_resource_group.wp10_rg.name + virtual_network_name = azurerm_virtual_network.wp10_vnet.name + address_prefixes = ["10.0.1.0/24"] +} diff --git a/terraform/modules/network/outputs.tf b/terraform/modules/network/outputs.tf new file mode 100644 index 00000000..fdee9018 --- /dev/null +++ b/terraform/modules/network/outputs.tf @@ -0,0 +1,9 @@ +output "resource_group" { + value = azurerm_resource_group.wp10_rg +} +output "azurerm_virtual_network" { + value = azurerm_virtual_network.wp10_vnet +} +output "azurerm_subnet" { + value = azurerm_subnet.wp10_subnet +} diff --git a/terraform/modules/network/variables.tf b/terraform/modules/network/variables.tf new file mode 100644 index 00000000..cb0f02b8 --- /dev/null +++ b/terraform/modules/network/variables.tf @@ -0,0 +1,12 @@ +variable "prefix" { + type = string + description = "Prefix of the resource name" +} + +variable "resource_group_location" { + type = string + description = "Location of the resource group." +} + + + diff --git a/terraform/modules/runner/README.md b/terraform/modules/runner/README.md new file mode 100644 index 00000000..f87f5c14 --- /dev/null +++ b/terraform/modules/runner/README.md @@ -0,0 +1 @@ +# TODO \ No newline at end of file diff --git a/terraform/modules/runner/outputs.tf b/terraform/modules/runner/outputs.tf new file mode 100644 index 00000000..bd346953 --- /dev/null +++ b/terraform/modules/runner/outputs.tf @@ -0,0 +1,8 @@ +output "public_ip_address" { + value = azurerm_linux_virtual_machine.main.public_ip_address +} + +output "key_data" { + value = azapi_resource_action.ssh_public_key_gen.output.publicKey +} + diff --git a/terraform/modules/runner/provider.tf b/terraform/modules/runner/provider.tf new file mode 100644 index 00000000..eaa86199 --- /dev/null +++ b/terraform/modules/runner/provider.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">=1.0" + + required_providers { + azapi = { + source = "Azure/azapi" + version = "~>1.15" + } + } +} \ No newline at end of file diff --git a/terraform/modules/runner/runner.tf b/terraform/modules/runner/runner.tf new file mode 100644 index 00000000..9a8cf603 --- /dev/null +++ b/terraform/modules/runner/runner.tf @@ -0,0 +1,101 @@ +# Create public IPs - development purposes only +resource "azurerm_public_ip" "my_terraform_public_ip" { + name = "${var.prefix}-public-ip" + location = var.resource_group_location + resource_group_name = var.resource_group_name + allocation_method = "Dynamic" +} + +# Create network interface +resource "azurerm_network_interface" "runner_nic" { + name = "${var.prefix}-nic" + location = var.resource_group_location + resource_group_name = var.resource_group_name + + ip_configuration { + name = "my_nic_configuration" + subnet_id = var.subnet_id + private_ip_address_allocation = "Dynamic" + public_ip_address_id = azurerm_public_ip.my_terraform_public_ip.id + } +} + +# Create Network Security Group and rules +resource "azurerm_network_security_group" "ssh_nsg" { + name = "${var.prefix}-nsg" + location = var.resource_group_location + resource_group_name = var.resource_group_name + + security_rule { + name = "SSH" + priority = 1000 + direction = "Inbound" + access = "Allow" + protocol = "*" + source_port_range = "*" + destination_port_range = "22" + source_address_prefix = "*" + destination_address_prefix = "*" + } +} + +# Connect the security group to the network interface +resource "azurerm_network_interface_security_group_association" "example" { + network_interface_id = azurerm_network_interface.runner_nic.id + network_security_group_id = azurerm_network_security_group.ssh_nsg.id +} + + +# Create virtual machine +resource "azurerm_linux_virtual_machine" "main" { + name = "${var.prefix}-vm" + admin_username = var.username + location = var.resource_group_location + resource_group_name = var.resource_group_name + network_interface_ids = [azurerm_network_interface.runner_nic.id] + size = "Standard_B2ms" + + computer_name = "hostname" + + + os_disk { + name = "runnerOsDisk" + caching = "ReadWrite" + storage_account_type = "Premium_LRS" + } + + source_image_reference { + publisher = "Canonical" + offer = "ubuntu-24_04-lts" + sku = "server" + version = "latest" + } + + admin_ssh_key { + username = var.username + public_key = azapi_resource_action.ssh_public_key_gen.output.publicKey + } + + boot_diagnostics { + storage_account_uri = azurerm_storage_account.boot_diagnostics_storage_account.primary_blob_endpoint + } +} + +# Create storage account for boot diagnostics +resource "azurerm_storage_account" "boot_diagnostics_storage_account" { + name = "diag${random_id.random_id.hex}" + location = var.resource_group_location + resource_group_name = var.resource_group_name + account_tier = "Standard" + account_replication_type = "LRS" +} + +# Generate random text for a unique storage account name +resource "random_id" "random_id" { + keepers = { + # Generate a new ID only when a new resource group is defined + resource_group_name = var.resource_group_name + } + + byte_length = 8 +} \ No newline at end of file diff --git a/terraform/modules/runner/ssh.tf b/terraform/modules/runner/ssh.tf new file mode 100644 index 00000000..ecbe27ef --- /dev/null +++ b/terraform/modules/runner/ssh.tf @@ -0,0 +1,20 @@ +resource "azapi_resource_action" "ssh_public_key_gen" { + type = "Microsoft.Compute/sshPublicKeys@2024-07-01" + resource_id = azapi_resource.ssh_public_key.id + action = "generateKeyPair" + method = "POST" + + response_export_values = ["publicKey", "privateKey"] +} + +resource "azapi_resource" "ssh_public_key" { + type = "Microsoft.Compute/sshPublicKeys@2024-07-01" + name = var.ssh_key_name + location = var.resource_group_location + parent_id = var.resource_group_id +} + +resource "local_sensitive_file" "private_key" { + content = azapi_resource_action.ssh_public_key_gen.output.privateKey + filename = "private_key.pem" +} \ No newline at end of file diff --git a/terraform/modules/runner/variables.tf b/terraform/modules/runner/variables.tf new file mode 100644 index 00000000..6a720271 --- /dev/null +++ b/terraform/modules/runner/variables.tf @@ -0,0 +1,36 @@ +variable "prefix" { + type = string + description = "Prefix of the resource name" +} + +variable "resource_group_location" { + type = string + description = "Location of the resource group." +} + +variable "resource_group_name" { + type = string + description = "Name of the resource group." +} + +variable "resource_group_id" { + type = string + description = "Id of the resource group." +} + +variable "subnet_id" { + type = string + description = "Id of the subnet." +} + +variable "username" { + type = string + description = "The username for the local account that will be created on the new VM." + default = "azureadmin" +} + +variable "ssh_key_name" { + type = string + description = "Name of the generated SSH key for the VM" + default = "runnersshkey" +} \ No newline at end of file diff --git a/terraform/outputs.tf b/terraform/outputs.tf new file mode 100644 index 00000000..9c92c03a --- /dev/null +++ b/terraform/outputs.tf @@ -0,0 +1,7 @@ +output "resource_group_name" { + value = module.network +} + +output "key_data" { + value = module.runner.key_data +} \ No newline at end of file diff --git a/terraform/scripts/helper.sh b/terraform/scripts/helper.sh new file mode 100644 index 00000000..974459f6 --- /dev/null +++ b/terraform/scripts/helper.sh @@ -0,0 +1,9 @@ +#!/bin/bash +# Helper functions + +# Extract value by key from file +function extract_value() { + key=$1 + filename=$2 + grep "^$key" $filename | cut -d '"' -f2 +} \ No newline at end of file diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 00000000..c1a52c46 --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,10 @@ +variable "resource_group_location" { + default = "westeurope" + description = "Location of the resource group." +} + +variable "prefix" { + type = string + default = "cariad-wp10" + description = "Prefix of the resource name" +} \ No newline at end of file