├── .github ├── CODE_OF_CONDUCT.md ├── ISSUE_TEMPLATE.md └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .tflint.hcl ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── extras ├── README.md ├── configurations │ ├── rg-devops │ │ ├── 010-common.tf │ │ ├── 020-storage.tf │ │ ├── 030-vm-jumpbox-linux.tf │ │ ├── README.md │ │ ├── bootstrap.sh │ │ ├── configure-powershell.ps1 │ │ ├── configure-vm-jumpbox-linux.yaml │ │ ├── samples │ │ │ ├── README.md │ │ │ └── workspaces │ │ │ │ ├── 010-common.tf │ │ │ │ ├── 020-resources.tf │ │ │ │ ├── README.md │ │ │ │ └── variables.tf │ │ └── variables.tf │ └── vm-devops │ │ ├── 010-common.tf │ │ ├── 020-vm-devops-win.tf │ │ ├── DevopsVmWin.ps1 │ │ ├── README.md │ │ ├── aadsc-register-node.ps1 │ │ ├── bootstrap.ps1 │ │ ├── configure-vm-devops-win.ps1 │ │ ├── variables.tf │ │ └── vm-devops-diagram.drawio.svg └── modules │ ├── aistudio │ ├── 010-common.tf │ ├── 020-aistudio.tf │ ├── README.md │ ├── aistudio-diagram.drawio.svg │ ├── bootstrap.sh │ ├── documents │ │ ├── CallScriptAudio.mp3 │ │ ├── Claim-Reporting-Script-Prompts.PropertyMgmt.pdf │ │ ├── OmniServe_Agent_Performance.pdf │ │ ├── OmniServe_Agent_Training.pdf │ │ ├── OmniServe_CSAT_Guidelines.pdf │ │ └── OmniServe_Compliance_Policy.pdf │ └── variables.tf │ └── vnet-onprem │ ├── README.md │ ├── compute.tf │ ├── images │ └── vnet-onprem-diagram.drawio.svg │ ├── locals.tf │ ├── main.tf │ ├── network.tf │ ├── outputs.tf │ ├── scripts │ ├── DomainControllerConfiguration2.ps1 │ ├── JumpBoxConfiguration2.ps1 │ ├── Register-DscNode.ps1 │ └── Set-AutomationAccountConfiguration.ps1 │ ├── terraform.tf │ └── variables.tf ├── images └── azuresandbox.drawio.svg ├── locals.tf ├── main.tf ├── modules ├── README.md ├── mssql │ ├── README.md │ ├── images │ │ └── mssql-diagram.drawio.svg │ ├── main.tf │ ├── network.tf │ ├── outputs.tf │ ├── terraform.tf │ └── variables.tf ├── mysql │ ├── README.md │ ├── images │ │ └── mysql-diagram.drawio.svg │ ├── main.tf │ ├── network.tf │ ├── outputs.tf │ ├── terraform.tf │ └── variables.tf ├── vm-jumpbox-linux │ ├── README.md │ ├── compute.tf │ ├── images │ │ └── vm-jumpbox-linux-diagram.drawio.svg │ ├── main.tf │ ├── network.tf │ ├── ouputs.tf │ ├── scripts │ │ ├── configure-vm-jumpbox-linux.sh │ │ └── configure-vm-jumpbox-linux.yaml │ ├── terraform.tf │ └── variables.tf ├── vm-mssql-win │ ├── README.md │ ├── compute.tf │ ├── images │ │ └── vm-mssql-diagram.drawio.svg │ ├── locals.tf │ ├── main.tf │ ├── network.tf │ ├── outputs.tf │ ├── scripts │ │ ├── Invoke-MssqlConfiguration.ps1 │ │ ├── MssqlVmConfiguration.ps1 │ │ ├── Register-DscNode.ps1 │ │ ├── Set-AutomationAccountConfiguration.ps1 │ │ ├── Set-MssqlConfiguration.ps1 │ │ └── Set-MssqlStartupConfiguration.ps1 │ ├── storage.tf │ ├── terraform.tf │ └── variables.tf ├── vnet-app │ ├── README.md │ ├── compute.tf │ ├── images │ │ └── vnet-app-diagram.drawio.svg │ ├── locals.tf │ ├── main.tf │ ├── network.tf │ ├── outputs.tf │ ├── scripts │ │ ├── Invoke-AzureFilesConfiguration.ps1 │ │ ├── JumpBoxConfiguration.ps1 │ │ ├── Register-DscNode.ps1 │ │ ├── Set-AutomationAccountConfiguration.ps1 │ │ └── Set-AzureFilesConfiguration.ps1 │ ├── storage.tf │ ├── terraform.tf │ └── variables.tf ├── vnet-shared │ ├── README.md │ ├── compute.tf │ ├── images │ │ └── vnet-shared-diagram.drawio.svg │ ├── locals.tf │ ├── main.tf │ ├── network.tf │ ├── outputs.tf │ ├── scripts │ │ ├── DomainControllerConfiguration.ps1 │ │ ├── Register-DscNode.ps1 │ │ └── Set-AutomationAccountConfiguration.ps1 │ ├── terraform.tf │ └── variables.tf └── vwan │ ├── README.md │ ├── images │ └── vwan-diagram.drawio.svg │ ├── locals.tf │ ├── main.tf │ ├── network.tf │ ├── outputs.tf │ ├── scripts │ ├── Export-Certificates.ps1 │ └── export-certificates.sh │ ├── terraform.tf │ └── variables.tf ├── outputs.tf ├── providers.tf ├── scripts ├── bootstrap.ps1 ├── bootstrap.sh ├── cleanterraformtemp.sh └── configure-powershell.ps1 ├── terraform.tf └── variables.tf /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Microsoft Open Source Code of Conduct 2 | 3 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 4 | 5 | Resources: 6 | 7 | - [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/) 8 | - [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) 9 | - Contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with questions or concerns 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 4 | > Please provide us with the following information: 5 | > --------------------------------------------------------------- 6 | 7 | ### This issue is for a: (mark with an `x`) 8 | ``` 9 | - [ ] bug report -> please search issues before submitting 10 | - [ ] feature request 11 | - [ ] documentation issue or request 12 | - [ ] regression (a behavior that used to work and stopped in a new release) 13 | ``` 14 | 15 | ### Minimal steps to reproduce 16 | > 17 | 18 | ### Any log messages given by the failure 19 | > 20 | 21 | ### Expected/desired behavior 22 | > 23 | 24 | ### OS and Version? 25 | > Windows 7, 8 or 10. Linux (which distribution). macOS (Yosemite? El Capitan? Sierra?) 26 | 27 | ### Versions 28 | > 29 | 30 | ### Mention any other details that might be useful 31 | 32 | > --------------------------------------------------------------- 33 | > Thanks! We'll be in touch soon. 34 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Purpose 2 | 3 | * ... 4 | 5 | ## Does this introduce a breaking change? 6 | 7 | ``` 8 | [ ] Yes 9 | [ ] No 10 | ``` 11 | 12 | ## Pull Request Type 13 | What kind of change does this Pull Request introduce? 14 | 15 | 16 | ``` 17 | [ ] Bugfix 18 | [ ] Feature 19 | [ ] Code style update (formatting, local variables) 20 | [ ] Refactoring (no functional changes, no api changes) 21 | [ ] Documentation content changes 22 | [ ] Other... Please describe: 23 | ``` 24 | 25 | ## How to Test 26 | * Get the code 27 | 28 | ``` 29 | git clone [repo-address] 30 | cd [repo-name] 31 | git checkout [branch-name] 32 | npm install 33 | ``` 34 | 35 | * Test the code 36 | 37 | ``` 38 | ``` 39 | 40 | ## What to Check 41 | Verify that the following are valid 42 | * ... 43 | 44 | ## Other Information 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # File created using '.gitignore Generator' for Visual Studio Code: https://bit.ly/vscode-gig 2 | 3 | # Created by https://www.toptal.com/developers/gitignore/api/visualstudiocode,linux,powershell,terraform 4 | # Edit at https://www.toptal.com/developers/gitignore?templates=visualstudiocode,linux,powershell,terraform 5 | 6 | ### Linux ### 7 | *~ 8 | 9 | # temporary files which can be created if a process still has a handle open of a deleted file 10 | .fuse_hidden* 11 | 12 | # KDE directory preferences 13 | .directory 14 | 15 | # Linux trash folder which might appear on any partition or disk 16 | .Trash-* 17 | 18 | # .nfs files are created when an open file is removed but is still being accessed 19 | .nfs* 20 | 21 | ### PowerShell ### 22 | # Exclude packaged modules 23 | *.zip 24 | 25 | # Exclude .NET assemblies from source 26 | *.dll 27 | 28 | ### Terraform ### 29 | # Local .terraform directories 30 | **/.terraform/* 31 | 32 | # .tfstate files 33 | *.tfstate 34 | *.tfstate.* 35 | 36 | # .lock.hcl files 37 | .terraform.lock.hcl 38 | 39 | # Crash log files 40 | crash.log 41 | 42 | # Ignore any .tfvars files that are generated automatically for each Terraform run. Most 43 | # .tfvars files are managed as part of configuration and so should be included in 44 | # version control. 45 | # 46 | *.tfvars 47 | 48 | # Trace files 49 | terraform.log 50 | 51 | # Ignore override files as they are usually used to override resources locally and so 52 | # are not checked in 53 | override.tf 54 | override.tf.json 55 | *_override.tf 56 | *_override.tf.json 57 | 58 | # Include override files you do wish to add to version control using negated pattern 59 | # !example_override.tf 60 | 61 | # Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan 62 | # example: *tfplan* 63 | 64 | ### VisualStudioCode ### 65 | .vscode/* 66 | !.vscode/tasks.json 67 | # !.vscode/launch.json 68 | *.code-workspace 69 | 70 | ### VisualStudioCode Patch ### 71 | # Ignore all local history of files 72 | .history 73 | .ionide 74 | 75 | # End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,linux,powershell,terraform 76 | 77 | # Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option) 78 | 79 | # Ignore ssh key temp files 80 | sshkeytemp 81 | sshkeytemp.pub 82 | 83 | # Ignore certificate files 84 | *.pem 85 | *.pfx 86 | -------------------------------------------------------------------------------- /.tflint.hcl: -------------------------------------------------------------------------------- 1 | plugin "terraform" { 2 | enabled = true 3 | preset = "recommended" 4 | } 5 | 6 | plugin "azurerm" { 7 | enabled = true 8 | version = "0.28.0" 9 | source = "github.com/terraform-linters/tflint-ruleset-azurerm" 10 | } 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # #AzureSandbox Changelog 2 | 3 | Please see Releases for Changelog information. 4 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to #AzureSandbox 2 | 3 | This project welcomes contributions and suggestions. Most contributions require you to agree to a 4 | Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us 5 | the rights to use your contribution. For details, visit [Contributor License Agreements](https://cla.opensource.microsoft.com). 6 | 7 | When you submit a pull request, a CLA bot will automatically determine whether you need to provide 8 | a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions 9 | provided by the bot. You will only need to do this once across all repos using our CLA. 10 | 11 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 12 | For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or 13 | contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. 14 | 15 | - [Code of Conduct](#code-of-conduct) 16 | - [Issues and Bugs](#found-an-issue) 17 | - [Feature Requests](#want-a-feature) 18 | - [Submission Guidelines](#submission-guidelines) 19 | 20 | ## Code of Conduct 21 | 22 | Help us keep this project open and inclusive. Please read and follow our [Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 23 | 24 | ## Found an Issue? 25 | 26 | If you find a bug in the source code or a mistake in the documentation, you can help us by 27 | [submitting an issue](#submitting-an-issue) to the GitHub Repository. Even better, you can 28 | [submit a Pull Request](#submitting-a-pull-request-pr) with a fix. 29 | 30 | ## Want a Feature? 31 | 32 | You can *request* a new feature by [submitting an issue](#submitting-an-issue) to the GitHub 33 | Repository. If you would like to *implement* a new feature, please submit an issue with 34 | a proposal for your work first, to be sure that we can use it. 35 | 36 | - **Small Features** can be crafted and directly [submitted as a Pull Request](#submitting-a-pull-request-pr). 37 | 38 | ## Submission Guidelines 39 | 40 | ### Submitting an Issue 41 | 42 | Before you submit an issue, search the archive, maybe your question was already answered. 43 | 44 | If your issue appears to be a bug, and hasn't been reported, open a new issue. 45 | Help us to maximize the effort we can spend fixing issues and adding new 46 | features, by not reporting duplicate issues. Providing the following information will increase the 47 | chances of your issue being dealt with quickly: 48 | 49 | - **Overview of the Issue** - if an error is being thrown a non-minified stack trace helps 50 | - **Version** - what version is affected (e.g. 0.1.2) 51 | - **Motivation for or Use Case** - explain what are you trying to do and why the current behavior is a bug for you 52 | - **Browsers and Operating System** - is this a problem with all browsers? 53 | - **Reproduce the Error** - provide a live example or a unambiguous set of steps 54 | - **Related Issues** - has a similar issue been reported before? 55 | - **Suggest a Fix** - if you can't fix the bug yourself, perhaps you can point to what might be 56 | causing the problem (line of code or commit) 57 | 58 | You can file new issues by providing the above information at the corresponding repository's issues link: [#AzureSandbox](https://github.com/Azure-Samples/azuresandbox/issues/new). 59 | 60 | ### Submitting a Pull Request (PR) 61 | 62 | Before you submit your Pull Request (PR) consider the following guidelines: 63 | 64 | - Search the repository [#AzureSandbox](https://github.com/Azure-Samples/azuresandbox/pulls) for an open or closed PR 65 | that relates to your submission. You don't want to duplicate effort. 66 | 67 | - Make your changes in a new git fork: 68 | 69 | - Commit your changes using a descriptive commit message 70 | - Push your fork to GitHub: 71 | - In GitHub, create a pull request 72 | - If we suggest changes then: 73 | - Make the required updates. 74 | - Rebase your fork and force push to your GitHub repository (this will update your Pull Request): 75 | 76 | ```shell 77 | git rebase master -i 78 | git push -f 79 | ``` 80 | 81 | That's it! Thank you for your contribution! 82 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Microsoft Corporation. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE -------------------------------------------------------------------------------- /extras/README.md: -------------------------------------------------------------------------------- 1 | # Extras 2 | 3 | ## Contents 4 | 5 | * [Overview](#overview) 6 | * [Configurations](#configurations) 7 | * [Modules](#modules) 8 | * [Demo videos](#demo-videos) 9 | 10 | ## Overview 11 | 12 | Contains additional Terraform modules, configurations and supporting resources. 13 | 14 | ## Disclaimer 15 | 16 | Code and content in this section may be incomplete, outdated or not fully functional. 17 | 18 | ## Configurations 19 | 20 | This section describes additional Terraform configurations that can be added to Azure Sandbox. These configurations are not required to use Azure Sandbox, but may be useful for learning or testing purposes. 21 | 22 | * [rg-devops](./configurations/rg-devops/) includes the following: 23 | * A [resource group](https://learn.microsoft.com/azure/azure-glossary-cloud-terminology#resource-group) which contains DevOps environment resources. 24 | * A [key vault](https://learn.microsoft.com/azure/key-vault/general/overview) for managing secrets. 25 | * A [storage account](https://learn.microsoft.com/azure/azure-glossary-cloud-terminology#storage-account) for use as a [Terraform azurerm backend](https://developer.hashicorp.com/terraform/language/settings/backends/azurerm). 26 | * A Linux [virtual machine](https://learn.microsoft.com/azure/azure-glossary-cloud-terminology#vm) for use as a DevOps agent. 27 | * [vm-devops](./configurations/vm-devops/) implements a collection of identical [IaaS](https://azure.microsoft.com/overview/what-is-iaas/) [virtual machines](https://learn.microsoft.com/azure/azure-glossary-cloud-terminology#vm) designed to be used as Windows Developer Workstations. 28 | 29 | ## Modules 30 | 31 | This section describes additional Terraform modules that can be added to Azure Sandbox. These modules are not required to use Azure Sandbox, but may be useful for learning or testing purposes. 32 | 33 | * [aistudio](./modules/aistudio/) enables the use of [Azure AI Studio](https://learn.microsoft.com/en-us/azure/ai-studio/what-is-ai-studio) in Azure Sandbox, including: 34 | * An [Azure AI Studio hub](https://learn.microsoft.com/en-us/azure/ai-studio/concepts/ai-resources) configured for [network isolation](https://learn.microsoft.com/azure/ai-studio/how-to/configure-managed-network). The hub is connected to the shared services storage account and key vault. 35 | * An [Application Insights](https://learn.microsoft.com/azure/azure-monitor/app/app-insights-overview) workspace connected to the hub. 36 | * An [Azure Container Registry](https://learn.microsoft.com/azure/container-registry/container-registry-intro) connected to the hub. 37 | * Additional AI services which can be [connected](https://learn.microsoft.com/azure/ai-studio/concepts/connections) to the hub, including: 38 | * An [Azure AI Services](https://learn.microsoft.com/azure/ai-services/what-are-ai-services) resource. 39 | * An [Azure AI Search](https://learn.microsoft.com/en-us/azure/search/search-what-is-azure-search) resource. 40 | * [vnet-onprem](./modules/vnet-onprem/) simulates connectivity to an on-premises network using a site-to-site VPN connection and Azure DNS private resolver. 41 | 42 | ## Demo videos 43 | 44 | This section contains an index of demo videos that were built using aspects of Azure Sandbox. 45 | 46 | * [Improving your security posture with Azure Update Manager (June 2024)](https://youtu.be/QjDE-JdbRD8) 47 | * Improving your security posture with Microsoft Defender for Cloud (May 2024) 48 | 49 | Video | Description 50 | --- | --- 51 | [Defender for Cloud (Part 1)](https://youtu.be/G4QPSFIV6qQ) | This video provides an introduction to Microsoft Defender for Cloud. 52 | [Defender for Cloud (Part 2)](https://youtu.be/buXWnMrkXGE) | This video covers free Foundational Cloud Security Posture Management capabilities. 53 | [Defender for Cloud (Part 3)](https://youtu.be/rbtH9FyDrP8) | This video covers how to enable paid Defender for Cloud CSPM plans. 54 | [Defender for Cloud (Part 4)](https://youtu.be/Qynm6h7Yp6k) | This video covers remediating security recommendations for Azure Storage. 55 | [Defender for Cloud (Part 5)](https://youtu.be/mcdDRLBlLEg) | This video covers remediating security recommendations for Azure SQL Database. 56 | [Defender for Cloud (Part 6)](https://youtu.be/GA9ts3pSsvg) | This video covers remediating security recommendations for Windows Server. 57 | [Defender for Cloud (Part 7)](https://youtu.be/AxfKPxXkzA4) | This video covers remediating security recommendations for Ubuntu Server. 58 | [Defender for Cloud (Part 8)](https://youtu.be/h9AAFFdvCX4) | This video covers remediating additional security recommendations for Ubuntu Server. 59 | [Defender for Cloud (Part 9)](https://youtu.be/BzZxv4i9SK8) | This video covers remediating security recommendations for Key Vault. 60 | [Defender for Cloud (Part 10)](https://youtu.be/kYDhGpeM04Y) | This video covers remediating security recommendations for Azure Backup. 61 | [Defender for Cloud (Part 11)](https://youtu.be/O4mNKNuwN44) | This video covers miscellaneous low risk security recommendations. 62 | 63 | * [Accessing Azure Files over HTTPS](https://youtu.be/6ft5rxET8Pc) (October 2023) 64 | * [Fixing a PowerShell script bug with GitHub Copilot](https://youtu.be/xRgdzc_Rl9w) (August 2023) 65 | -------------------------------------------------------------------------------- /extras/configurations/rg-devops/010-common.tf: -------------------------------------------------------------------------------- 1 | # Backend configuration 2 | terraform { 3 | required_version = "~> 1.11" 4 | required_providers { 5 | azurerm = { 6 | source = "hashicorp/azurerm" 7 | version = "~>4.26" 8 | } 9 | 10 | cloudinit = { 11 | source = "hashicorp/cloudinit" 12 | version = "~>2.3" 13 | } 14 | 15 | random = { 16 | source = "hashicorp/random" 17 | version = "~>3.7" 18 | } 19 | } 20 | } 21 | 22 | provider "azurerm" { 23 | subscription_id = var.subscription_id 24 | # client_id = "REPLACE-WITH-YOUR-CLIENT-ID" 25 | # client_secret = "REPLACE-WITH-YOUR-CLIENT-SECRET" 26 | # tenant_id = "REPLACE-WITH-YOUR-TENANT-ID" 27 | 28 | features {} 29 | } 30 | 31 | provider "random" {} 32 | 33 | data "azurerm_key_vault_secret" "adminpassword" { 34 | name = var.admin_password_secret 35 | key_vault_id = var.key_vault_id 36 | } 37 | 38 | data "azurerm_key_vault_secret" "adminuser" { 39 | name = var.admin_username_secret 40 | key_vault_id = var.key_vault_id 41 | } 42 | -------------------------------------------------------------------------------- /extras/configurations/rg-devops/020-storage.tf: -------------------------------------------------------------------------------- 1 | # Storage account 2 | resource "random_id" "random_id_st_tfm_name" { 3 | byte_length = 8 4 | } 5 | 6 | resource "azurerm_storage_account" "st_tfm" { 7 | name = "st${random_id.random_id_st_tfm_name.hex}" 8 | resource_group_name = var.resource_group_name 9 | location = var.location 10 | account_kind = "StorageV2" 11 | account_tier = "Standard" 12 | access_tier = var.storage_access_tier 13 | account_replication_type = var.storage_replication_type 14 | tags = var.tags 15 | } 16 | 17 | # Container for terraform state backend storage 18 | resource "azurerm_storage_container" "container_tfstate" { 19 | name = "tfstate" 20 | storage_account_id = azurerm_storage_account.st_tfm.id 21 | container_access_type = "private" 22 | } 23 | 24 | resource "azurerm_key_vault_secret" "storage_account_key" { 25 | name = azurerm_storage_account.st_tfm.name 26 | value = azurerm_storage_account.st_tfm.primary_access_key 27 | key_vault_id = var.key_vault_id 28 | } 29 | -------------------------------------------------------------------------------- /extras/configurations/rg-devops/030-vm-jumpbox-linux.tf: -------------------------------------------------------------------------------- 1 | data "cloudinit_config" "vm_jumpbox_linux" { 2 | gzip = true 3 | base64_encode = true 4 | 5 | part { 6 | content_type = "text/cloud-config" 7 | content = file("${path.root}/configure-vm-jumpbox-linux.yaml") 8 | filename = "cloud-init.yaml" 9 | } 10 | 11 | part { 12 | content_type = "text/x-shellscript" 13 | content = file("${path.root}/scripts/configure-powershell.ps1") 14 | filename = "configure-powershell.ps1" 15 | } 16 | } 17 | 18 | # Linux virtual machine 19 | resource "azurerm_linux_virtual_machine" "vm_jumpbox_linux" { 20 | name = var.vm_name 21 | resource_group_name = azurerm_network_interface.vm_jumpbox_linux_nic_01.resource_group_name 22 | location = azurerm_network_interface.vm_jumpbox_linux_nic_01.location 23 | size = var.vm_size 24 | admin_username = data.azurerm_key_vault_secret.adminuser.value 25 | network_interface_ids = [azurerm_network_interface.vm_jumpbox_linux_nic_01.id] 26 | encryption_at_host_enabled = true 27 | patch_assessment_mode = "AutomaticByPlatform" 28 | provision_vm_agent = true 29 | tags = merge(var.tags, { keyvault = var.key_vault_name }) 30 | 31 | admin_ssh_key { 32 | username = data.azurerm_key_vault_secret.adminuser.value 33 | public_key = var.ssh_public_key 34 | } 35 | 36 | os_disk { 37 | caching = "ReadWrite" 38 | storage_account_type = var.vm_storage_account_type 39 | } 40 | 41 | source_image_reference { 42 | publisher = var.vm_image_publisher 43 | offer = var.vm_image_offer 44 | sku = var.vm_image_sku 45 | version = var.vm_image_version 46 | } 47 | 48 | identity { 49 | type = "SystemAssigned" 50 | } 51 | 52 | custom_data = data.cloudinit_config.vm_jumpbox_linux.rendered 53 | } 54 | 55 | # Nics 56 | resource "azurerm_network_interface" "vm_jumpbox_linux_nic_01" { 57 | name = "nic-${var.vm_name}" 58 | location = var.location 59 | resource_group_name = var.resource_group_name 60 | tags = var.tags 61 | 62 | ip_configuration { 63 | name = "ipc-${var.vm_name}" 64 | subnet_id = var.subnet_id 65 | private_ip_address_allocation = "Dynamic" 66 | } 67 | } 68 | 69 | resource "azurerm_key_vault_access_policy" "vm_jumpbox_linux_secrets_reader" { 70 | key_vault_id = var.key_vault_id 71 | tenant_id = azurerm_linux_virtual_machine.vm_jumpbox_linux.identity[0].tenant_id 72 | object_id = azurerm_linux_virtual_machine.vm_jumpbox_linux.identity[0].principal_id 73 | 74 | secret_permissions = [ 75 | "Get" 76 | ] 77 | } 78 | -------------------------------------------------------------------------------- /extras/configurations/rg-devops/configure-powershell.ps1: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env pwsh 2 | 3 | #region functions 4 | function Write-Log { 5 | param( [string] $msg) 6 | "$(Get-Date -Format FileDateTimeUniversal) : $msg" | Write-Host 7 | } 8 | function Exit-WithError { 9 | param( [string]$msg ) 10 | Write-Log "There was an exception during the process, please review..." 11 | Write-Log $msg 12 | Exit 2 13 | } 14 | #endregion 15 | 16 | #region main 17 | # Install PowerShell prerequisites 18 | $nugetPackage = Get-PackageProvider | Where-Object Name -eq 'NuGet' 19 | 20 | if ($null -eq $nugetPackage) { 21 | Write-Log "Installing NuGet PowerShell package provider..." 22 | 23 | try { 24 | Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force 25 | } 26 | catch { 27 | Exit-WithError $_ 28 | } 29 | } 30 | 31 | $nugetPackage = Get-PackageProvider | Where-Object Name -eq 'NuGet' 32 | Write-Log "NuGet Powershell Package Provider version $($nugetPackage.Version.Major).$($nugetPackage.Version.Minor).$($nugetPackage.Version.Build).$($nugetPackage.Version.Revision) is already installed..." 33 | 34 | $repo = Get-PSRepository -Name PSGallery 35 | if ( $repo.InstallationPolicy -eq 'Trusted' ) { 36 | Write-Log "PSGallery installation policy is already set to 'Trusted'..." 37 | } 38 | else { 39 | Write-Log "Setting PSGallery installation policy to 'Trusted'..." 40 | 41 | try { 42 | Set-PSRepository -Name PSGallery -InstallationPolicy Trusted 43 | } 44 | catch { 45 | Exit-WithError $_ 46 | } 47 | } 48 | 49 | $azModule = Get-Module -ListAvailable -Name Az* 50 | if ($null -eq $azModule ) { 51 | Write-Log "Installing PowerShell Az module..." 52 | 53 | try { 54 | Install-Module -Name Az -AllowClobber -Scope AllUsers 55 | } 56 | catch { 57 | Exit-WithError $_ 58 | } 59 | } 60 | else { 61 | Write-Log "PowerShell Az module is already installed..." 62 | } 63 | 64 | $azAutomationModule = Get-Module -ListAvailable -Name Az.Automation 65 | Write-Log "PowerShell Az.Automation version $($azAutomationModule.Version) is installed..." 66 | 67 | Exit 0 68 | #endregion 69 | -------------------------------------------------------------------------------- /extras/configurations/rg-devops/samples/README.md: -------------------------------------------------------------------------------- 1 | # \#AzureSandbox extras - terraform-azurerm-rg-devops samples 2 | 3 | This folder includes configurations that can be used as a starting point for your own DevOps projects.. 4 | 5 | Sample | Description 6 | --- | --- 7 | [workspaces](./workspaces) | A sample configuration that demonstrates how to use terraform workspaces to manage multiple environments (dev, test, prod) within a single configuration. 8 | -------------------------------------------------------------------------------- /extras/configurations/rg-devops/samples/workspaces/010-common.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | backend "azurerm" { 3 | use_azuread_auth = true 4 | tenant_id = "YOUR-TENANT-ID-HERE" 5 | storage_account_name = "YOUR-STORAGE-ACCOUNT-FOR-TFSTATE-HERE" 6 | container_name = "workspaces-tfstate" 7 | key = "terraform.tfstate" 8 | } 9 | 10 | required_providers { 11 | azurerm = { 12 | source = "hashicorp/azurerm" 13 | version = "~>4.26" 14 | } 15 | } 16 | } 17 | 18 | # Providers 19 | provider "azurerm" { 20 | features {} 21 | subscription_id = var.subscription_id 22 | } 23 | -------------------------------------------------------------------------------- /extras/configurations/rg-devops/samples/workspaces/020-resources.tf: -------------------------------------------------------------------------------- 1 | resource "azurerm_resource_group" "resource_group_01" { 2 | name = var.resource_group_name 3 | location = var.location 4 | } 5 | 6 | resource "azurerm_virtual_network" "vnet_spoke_01" { 7 | name = var.vnet_name 8 | location = var.location 9 | resource_group_name = azurerm_resource_group.resource_group_01.name 10 | address_space = [var.vnet_address_space] 11 | dns_servers = ["168.63.129.16"] 12 | tags = { 13 | costcenter = "IT" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /extras/configurations/rg-devops/samples/workspaces/README.md: -------------------------------------------------------------------------------- 1 | # \#AzureSandbox extras - workspaces sample 2 | 3 | ## Contents 4 | 5 | * [Overview](#overview) 6 | * [Before you start](#before-you-start) 7 | * [Getting started](#getting-started) 8 | 9 | ## Overview 10 | 11 | This configuration demonstrates how to use Terraform [workspaces](https://developer.hashicorp.com/terraform/cli/workspaces) to manage multiple environments (e.g. `dev`, `stage`, `prod`) from a single configuration. 12 | 13 | ## Before you start 14 | 15 | * Create a dedicated resource group for your DevOps infrastructure, e.g. `rg-mydevopsinfra`. 16 | * Create a storage account in the `rg-mydevopsinfra` resource group to be used as Terraform state [backend](https://developer.hashicorp.com/terraform/language/backend/azurerm), e.g. `stmystatebackendxxx`. 17 | * Create a container in the storage account for Terraform state files, e.g. `workspaces-tfstate`. 18 | * Add a `Storage Blob Data Contributor` role assignment for the Terraform user scoped to the new storage account. 19 | 20 | ## Getting started 21 | 22 | * Update line 4 of [010-common.tf](010-common.tf) with the your Microsoft Entra Tenant ID. 23 | * Update line 5 of [010-common.tf](010-common.tf) with the name of the storage account for storing the Terraform state, e.g. `stmystatebackendxxx`. 24 | * Create three `terraform.tfvars` files for each environment in the same directory as the main configuration files. The files should be named `terraform.dev.tfvars`, `terraform.stage.tfvars`, and `terraform prod.tfvars` respectively. Initialize the variables defined in [variables.tf](./variables.tf) in each of the files with different values for each environment. For example: 25 | 26 | ```hcl 27 | # terraform.dev.tfvars 28 | 29 | location = "centralus" 30 | resource_group_name = "rg-workspaces-dev" 31 | subscription_id = "MY-DEV-SUBSCRIPTION-ID" 32 | vnet_address_space = "10.1.0.0/16" 33 | vnet_name = "vnet-dev" 34 | workspace = "dev" 35 | 36 | ``` 37 | 38 | ```hcl 39 | # terraform.stage.tfvars 40 | 41 | location = "centralus" 42 | resource_group_name = "rg-workspaces-stage" 43 | subscription_id = "MY-STAGE-SUBSCRIPTION-ID" 44 | vnet_address_space = "10.2.0.0/16" 45 | vnet_name = "vnet-stage" 46 | workspace = "stage" 47 | ``` 48 | 49 | ```hcl 50 | # terraform.prod.tfvars 51 | 52 | location = "centralus" 53 | resource_group_name = "rg-workspaces-prod" 54 | subscription_id = "MY-PROD-SUBSCRIPTION-ID" 55 | vnet_address_space = "10.3.0.0/16" 56 | vnet_name = "vnet-prod" 57 | workspace = "prod" 58 | ``` 59 | 60 | * Run the following commands to initialize the Terraform backend and create the workspaces: 61 | 62 | ```bash 63 | terraform init 64 | terraform workspace new dev 65 | terraform workspace new stage 66 | terraform workspace new prod 67 | ``` 68 | 69 | * Examine the container with your tfstate files, there should be four files there: 70 | 71 | File | Environment 72 | --- | --- 73 | terraform.tfstate | default environment (empty state file that will not be used) 74 | terraform.tfstateenv:dev | dev environment 75 | terraform.tfstateenv:stage | stage environment 76 | terraform.tfstateenv:prod | prod environment 77 | 78 | * Set the current workspace to `dev` and run the following command to create the resources in the `dev` environment: 79 | 80 | ```bash 81 | terraform workspace select dev 82 | terraform apply -var-file=terraform.dev.tfvars 83 | ``` 84 | 85 | * Set the current workspace to `stage` and run the following command to create the resources in the `stage` environment: 86 | 87 | ```bash 88 | terraform workspace select stage 89 | terraform apply -var-file=terraform.stage.tfvars 90 | ``` 91 | 92 | * Set the current workspace to `prod` and run the following command to create the resources in the `prod` environment: 93 | 94 | ```bash 95 | terraform workspace select prod 96 | terraform apply -var-file=terraform.prod.tfvars 97 | ``` 98 | 99 | * Examine the resources in the portal. You should see a separate resource group for each environment that contains a single virtual network specific to that environment. 100 | 101 | * To clean up the resources, run the following command for each environment: 102 | 103 | ```bash 104 | terraform workspace select dev 105 | terraform destroy -var-file=terraform.dev.tfvars 106 | 107 | terraform workspace select stage 108 | terraform destroy -var-file=terraform.stage.tfvars 109 | 110 | terraform workspace select prod 111 | terraform destroy -var-file=terraform.prod.tfvars 112 | ``` 113 | -------------------------------------------------------------------------------- /extras/configurations/rg-devops/samples/workspaces/variables.tf: -------------------------------------------------------------------------------- 1 | variable "location" { 2 | description = "The Azure region where the resources will be created." 3 | type = string 4 | } 5 | 6 | variable "resource_group_name" { 7 | description = "The name of the resource group to be created." 8 | type = string 9 | } 10 | 11 | variable "subscription_id" { 12 | description = "The ID of the Azure subscription where the resources will be created." 13 | type = string 14 | } 15 | 16 | variable "vnet_address_space" { 17 | description = "The address space for the virtual network." 18 | type = string 19 | } 20 | 21 | variable "vnet_name" { 22 | description = "The name of the virtual network to be created." 23 | type = string 24 | } 25 | 26 | variable "workspace" { 27 | description = "The name of the workspace (environment) to be created." 28 | type = string 29 | 30 | validation { 31 | condition = contains(["dev", "stage", "prod"], var.workspace) 32 | error_message = "The workspace must be one of the following: dev, stage, prod." 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /extras/configurations/rg-devops/variables.tf: -------------------------------------------------------------------------------- 1 | variable "admin_password_secret" { 2 | type = string 3 | description = "The name of the key vault secret containing the admin password" 4 | } 5 | 6 | variable "admin_username_secret" { 7 | type = string 8 | description = "The name of the key vault secret containing the admin username" 9 | } 10 | 11 | variable "key_vault_id" { 12 | type = string 13 | description = "The existing key vault where secrets are stored" 14 | } 15 | 16 | variable "key_vault_name" { 17 | type = string 18 | description = "The existing key vault where secrets are stored" 19 | } 20 | 21 | variable "location" { 22 | type = string 23 | description = "The Azure region where the VM will be provisioned" 24 | } 25 | 26 | variable "resource_group_name" { 27 | type = string 28 | description = "The existing resource group where the VM will be provisioned" 29 | } 30 | 31 | variable "ssh_public_key" { 32 | type = string 33 | description = "The SSH public key used for the admin account" 34 | } 35 | 36 | variable "storage_access_tier" { 37 | type = string 38 | description = "The acccess tier for the new storage account." 39 | default = "Hot" 40 | } 41 | 42 | variable "storage_replication_type" { 43 | type = string 44 | description = "The type of replication for the new storage account." 45 | default = "LRS" 46 | } 47 | 48 | variable "subnet_id" { 49 | type = string 50 | description = "The existing subnet which will be used by the VM" 51 | } 52 | 53 | variable "subscription_id" { 54 | type = string 55 | description = "The Azure subscription id used to provision resources." 56 | } 57 | 58 | variable "tags" { 59 | type = map(any) 60 | description = "The ARM tags to be applied to all new resources created." 61 | } 62 | 63 | variable "vm_image_offer" { 64 | type = string 65 | description = "The offer type of the virtual machine image used to create the VM" 66 | default = "ubuntu-24_04-lts" 67 | } 68 | 69 | variable "vm_image_publisher" { 70 | type = string 71 | description = "The publisher for the virtual machine image used to create the VM" 72 | default = "Canonical" 73 | } 74 | 75 | variable "vm_image_sku" { 76 | type = string 77 | description = "The sku of the virtual machine image used to create the VM" 78 | default = "server" 79 | } 80 | 81 | variable "vm_image_version" { 82 | type = string 83 | description = "The version of the virtual machine image used to create the VM" 84 | default = "Latest" 85 | } 86 | 87 | variable "vm_name" { 88 | type = string 89 | description = "The name of the VM" 90 | } 91 | 92 | variable "vm_size" { 93 | type = string 94 | description = "The size of the virtual machine" 95 | default = "Standard_B2ls_v2" 96 | } 97 | 98 | variable "vm_storage_account_type" { 99 | type = string 100 | description = "The storage replication type to be used for the VMs OS and data disks" 101 | default = "Standard_LRS" 102 | } 103 | -------------------------------------------------------------------------------- /extras/configurations/vm-devops/010-common.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_version = "~> 1.11" 3 | required_providers { 4 | azurerm = { 5 | source = "hashicorp/azurerm" 6 | version = "~>4.26" 7 | } 8 | } 9 | } 10 | 11 | # Providers 12 | provider "azurerm" { 13 | subscription_id = var.subscription_id 14 | client_id = var.arm_client_id 15 | client_secret = var.arm_client_secret 16 | tenant_id = var.aad_tenant_id 17 | 18 | features {} 19 | } 20 | 21 | # Secrets 22 | data "azurerm_key_vault_secret" "adminpassword" { 23 | name = var.admin_password_secret 24 | key_vault_id = var.key_vault_id 25 | } 26 | 27 | data "azurerm_key_vault_secret" "adminuser" { 28 | name = var.admin_username_secret 29 | key_vault_id = var.key_vault_id 30 | } 31 | 32 | data "azurerm_key_vault_secret" "storage_account_key" { 33 | name = var.storage_account_name 34 | key_vault_id = var.key_vault_id 35 | } 36 | -------------------------------------------------------------------------------- /extras/configurations/vm-devops/020-vm-devops-win.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | automation_account_name = split("/", var.automation_account_id)[8] 3 | automation_account_resource_group_name = split("/", var.automation_account_id)[4] 4 | storage_account_id = "/subscriptions/${var.subscription_id}/resourceGroups/${var.resource_group_name}/providers/Microsoft.Storage/storageAccounts/${var.storage_account_name}" 5 | vm_devops_win_config_script_uri = "https://${var.storage_account_name}.blob.core.windows.net/${var.storage_container_name}/${var.vm_devops_win_config_script}" 6 | vm_devops_win_data_disk_count = var.vm_devops_win_data_disk_size_gb == 0 ? 0 : 1 7 | vm_devops_win_names = formatlist("${var.vm_devops_win_name}%03d", range(var.vm_devops_win_instances_start, (var.vm_devops_win_instances_start + var.vm_devops_win_instances))) 8 | } 9 | 10 | resource "azurerm_windows_virtual_machine" "vm_devops_win" { 11 | for_each = toset(local.vm_devops_win_names) 12 | name = each.key 13 | resource_group_name = var.resource_group_name 14 | location = var.location 15 | size = var.vm_devops_win_size 16 | admin_username = data.azurerm_key_vault_secret.adminuser.value 17 | admin_password = data.azurerm_key_vault_secret.adminpassword.value 18 | network_interface_ids = [azurerm_network_interface.vm_devops_win[each.key].id] 19 | encryption_at_host_enabled = true 20 | enable_automatic_updates = true 21 | patch_mode = var.vm_devops_win_patch_mode 22 | license_type = var.vm_devops_win_license_type 23 | tags = var.tags 24 | 25 | os_disk { 26 | caching = "ReadWrite" 27 | storage_account_type = var.vm_devops_win_storage_account_type 28 | disk_size_gb = var.vm_devops_win_os_disk_size_gb 29 | } 30 | 31 | source_image_reference { 32 | publisher = var.vm_devops_win_image_publisher 33 | offer = var.vm_devops_win_image_offer 34 | sku = var.vm_devops_win_image_sku 35 | version = var.vm_devops_win_image_version 36 | } 37 | 38 | identity { 39 | type = "SystemAssigned" 40 | } 41 | 42 | # Note: To view provisioner output, use the Terraform nonsensitive() function when referencing key vault secrets or variables marked 'sensitive' 43 | provisioner "local-exec" { 44 | command = <&2 8 | exit 1 9 | } 10 | 11 | # Initialize runtime defaults 12 | default_owner_object_id=$(az account get-access-token --query accessToken --output tsv | tr -d '\n' | python3 -c "import jwt, sys; print(jwt.decode(sys.stdin.read(), algorithms=['RS256'], options={'verify_signature': False})['oid'])") 13 | 14 | state_file="../../terraform-azurerm-vnet-shared/terraform.tfstate" 15 | 16 | printf "Retrieving runtime defaults from state file '$state_file'...\n" 17 | 18 | if [ ! -f $state_file ] 19 | then 20 | printf "Unable to locate \"$state_file\"...\n" 21 | printf "See README.md for configurations that must be deployed first...\n" 22 | usage 23 | fi 24 | 25 | aad_tenant_id=$(terraform output -state=$state_file aad_tenant_id) 26 | arm_client_id=$(terraform output -state=$state_file arm_client_id) 27 | key_vault_id=$(terraform output -state=$state_file key_vault_id) 28 | key_vault_name=$(terraform output -state=$state_file key_vault_name) 29 | location=$(terraform output -state=$state_file location) 30 | resource_group_name=$(terraform output -state=$state_file resource_group_name) 31 | storage_account_name=$(terraform output -state=$state_file storage_account_name) 32 | subscription_id=$(terraform output -state=$state_file subscription_id) 33 | tags=$(terraform output -json -state=$state_file tags) 34 | 35 | state_file="../../terraform-azurerm-vnet-app/terraform.tfstate" 36 | 37 | printf "Retrieving runtime defaults from state file '$state_file'...\n" 38 | 39 | if [ ! -f $state_file ] 40 | then 41 | printf "Unable to locate \"$state_file\"...\n" 42 | printf "See README.md for configurations that must be deployed first...\n" 43 | usage 44 | fi 45 | 46 | private_dns_zones=$(terraform output -json -state=$state_file private_dns_zones) 47 | storage_share_name=$(terraform output -state=$state_file storage_share_name) 48 | vnet_app_01_subnets=$(terraform output -json -state=$state_file vnet_app_01_subnets) 49 | 50 | # Get user input 51 | read -e -i $default_owner_object_id -p "Object id for Azure CLI signed in user (owner_object_id) -: " owner_object_id 52 | 53 | # Validate user input 54 | owner_object_id=${owner_object_id:-$default_owner_object_id} 55 | 56 | # Validate TF_VAR_arm_client_secret 57 | if [ -z "$TF_VAR_arm_client_secret" ] 58 | then 59 | printf "Environment variable 'TF_VAR_arm_client_secret' must be set.\n" 60 | usage 61 | fi 62 | 63 | # Upload documents 64 | printf "Temporarily enabling public internet access and shared key access on storage account '${storage_account_name:1:-1}'...\n" 65 | az storage account update \ 66 | --subscription ${subscription_id:1:-1} \ 67 | --name ${storage_account_name:1:-1} \ 68 | --resource-group ${resource_group_name:1:-1} \ 69 | --public-network-access Enabled \ 70 | --allow-shared-key-access true 71 | 72 | printf "Sleeping for 15 seconds to allow storage account settings to propagate...\n" 73 | sleep 15 74 | 75 | printf "Getting storage account key for storage account '${storage_account_name:1:-1}' from key vault '${key_vault_name:1:-1}'...\n" 76 | storage_account_key=$(az keyvault secret show --name ${storage_account_name:1:-1} --vault-name ${key_vault_name:1:-1} --query value --output tsv) 77 | 78 | for i in {1..12} 79 | do 80 | printf "Attempt $i: Uploading documents to share '${storage_share_name:1:-1}' in storage account '${storage_account_name:1:-1}'...\n" 81 | az storage file upload-batch \ 82 | --account-name ${storage_account_name:1:-1} \ 83 | --account-key "$storage_account_key" \ 84 | --destination ${storage_share_name:1:-1} \ 85 | --destination-path 'documents' \ 86 | --source './documents/' \ 87 | --pattern '*.*' && break || sleep 15 88 | done 89 | 90 | printf "Disabling public internet access and shared key access on storage account '${storage_account_name:1:-1}'...\n" 91 | az storage account update \ 92 | --subscription ${subscription_id:1:-1} \ 93 | --name ${storage_account_name:1:-1} \ 94 | --resource-group ${resource_group_name:1:-1} \ 95 | --public-network-access Disabled \ 96 | --allow-shared-key-access false 97 | 98 | # Generate terraform.tfvars file 99 | printf "\nGenerating terraform.tfvars file...\n\n" 100 | 101 | printf "aad_tenant_id = $aad_tenant_id\n" > ./terraform.tfvars 102 | printf "arm_client_id = $arm_client_id\n" >> ./terraform.tfvars 103 | printf "key_vault_id = $key_vault_id\n" >> ./terraform.tfvars 104 | printf "key_vault_name = $key_vault_name\n" >> ./terraform.tfvars 105 | printf "location = $location\n" >> ./terraform.tfvars 106 | printf "owner_object_id = \"$owner_object_id\"\n" >> ./terraform.tfvars 107 | printf "private_dns_zones = $private_dns_zones\n" >> ./terraform.tfvars 108 | printf "resource_group_name = $resource_group_name\n" >> ./terraform.tfvars 109 | printf "storage_account_name = $storage_account_name\n" >> ./terraform.tfvars 110 | printf "subscription_id = $subscription_id\n" >> ./terraform.tfvars 111 | printf "tags = $tags\n" >> ./terraform.tfvars 112 | printf "vnet_app_01_subnets = $vnet_app_01_subnets\n" >> ./terraform.tfvars 113 | 114 | cat ./terraform.tfvars 115 | 116 | printf "\nReview defaults in \"variables.tf\" prior to applying Terraform configurations...\n" 117 | printf "\nBootstrapping complete...\n" 118 | 119 | exit 0 120 | -------------------------------------------------------------------------------- /extras/modules/aistudio/documents/CallScriptAudio.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/azuresandbox/f09637232a3d53930e13b1cd76225f1372224a15/extras/modules/aistudio/documents/CallScriptAudio.mp3 -------------------------------------------------------------------------------- /extras/modules/aistudio/documents/Claim-Reporting-Script-Prompts.PropertyMgmt.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/azuresandbox/f09637232a3d53930e13b1cd76225f1372224a15/extras/modules/aistudio/documents/Claim-Reporting-Script-Prompts.PropertyMgmt.pdf -------------------------------------------------------------------------------- /extras/modules/aistudio/documents/OmniServe_Agent_Performance.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/azuresandbox/f09637232a3d53930e13b1cd76225f1372224a15/extras/modules/aistudio/documents/OmniServe_Agent_Performance.pdf -------------------------------------------------------------------------------- /extras/modules/aistudio/documents/OmniServe_Agent_Training.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/azuresandbox/f09637232a3d53930e13b1cd76225f1372224a15/extras/modules/aistudio/documents/OmniServe_Agent_Training.pdf -------------------------------------------------------------------------------- /extras/modules/aistudio/documents/OmniServe_CSAT_Guidelines.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/azuresandbox/f09637232a3d53930e13b1cd76225f1372224a15/extras/modules/aistudio/documents/OmniServe_CSAT_Guidelines.pdf -------------------------------------------------------------------------------- /extras/modules/aistudio/documents/OmniServe_Compliance_Policy.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/azuresandbox/f09637232a3d53930e13b1cd76225f1372224a15/extras/modules/aistudio/documents/OmniServe_Compliance_Policy.pdf -------------------------------------------------------------------------------- /extras/modules/aistudio/variables.tf: -------------------------------------------------------------------------------- 1 | variable "aad_tenant_id" { 2 | type = string 3 | description = "The Microsoft Entra tenant id." 4 | } 5 | 6 | variable "ai_search_sku" { 7 | type = string 8 | description = "The sku name of the Azure AI Search service to create. Choose from: Free, Basic, Standard, StorageOptimized. See https://docs.microsoft.com/en-us/azure/search/search-sku-tier" 9 | default = "basic" 10 | } 11 | 12 | variable "ai_services_sku" { 13 | type = string 14 | description = "The sku name of the AI Services sku. Choose from: S0, S1, S2, S3, S4, S5, S6, S7, S8, S9, S10. See https://docs.microsoft.com/en-us/azure/cognitive-services/cognitive-services-apis-create-account-cli?tabs=multiservice%2Cwindows" 15 | default = "S0" 16 | } 17 | 18 | variable "arm_client_id" { 19 | type = string 20 | description = "The AppId of the service principal used for authenticating with Azure. Must have a 'Contributor' role assignment." 21 | } 22 | 23 | variable "arm_client_secret" { 24 | type = string 25 | description = "The password for the service principal used for authenticating with Azure. Set interactively or using an environment variable 'TF_VAR_arm_client_secret'." 26 | sensitive = true 27 | } 28 | 29 | variable "container_registry_sku" { 30 | type = string 31 | description = "The sku name of the Azure Container Registry to create. Choose from: Basic, Standard, Premium. Premium is required for use with AI Studio hubs." 32 | default = "Premium" 33 | } 34 | 35 | variable "key_vault_id" { 36 | type = string 37 | description = "The existing key vault where secrets are stored" 38 | } 39 | 40 | variable "key_vault_name" { 41 | type = string 42 | description = "The existing key vault where secrets are stored" 43 | } 44 | 45 | variable "location" { 46 | type = string 47 | description = "The name of the Azure Region where resources will be provisioned." 48 | } 49 | 50 | variable "owner_object_id" { 51 | type = string 52 | description = "The object id of the owner of the resources." 53 | } 54 | 55 | variable "private_dns_zones" { 56 | type = map(any) 57 | description = "The existing private dns zones defined in the application virtual network." 58 | } 59 | 60 | variable "resource_group_name" { 61 | type = string 62 | description = "The name of the existing resource group for provisioning resources." 63 | } 64 | 65 | variable "storage_account_name" { 66 | type = string 67 | description = "The name of the shared storage account." 68 | } 69 | 70 | variable "subscription_id" { 71 | type = string 72 | description = "The Azure subscription id used to provision resources." 73 | } 74 | 75 | variable "tags" { 76 | type = map(any) 77 | description = "The tags in map format to be used when creating new resources." 78 | } 79 | 80 | variable "vnet_app_01_subnets" { 81 | type = map(any) 82 | description = "The existing subnets defined in the application virtual network." 83 | } 84 | -------------------------------------------------------------------------------- /extras/modules/vnet-onprem/compute.tf: -------------------------------------------------------------------------------- 1 | #region domain controller vm 2 | resource "azurerm_windows_virtual_machine" "vm_adds" { 3 | name = var.vm_adds_name 4 | resource_group_name = var.resource_group_name 5 | location = var.location 6 | size = var.vm_adds_size 7 | admin_username = data.azurerm_key_vault_secret.adminuser.value 8 | admin_password = data.azurerm_key_vault_secret.adminpassword.value 9 | network_interface_ids = [azurerm_network_interface.vm_adds.id] 10 | patch_assessment_mode = "AutomaticByPlatform" 11 | patch_mode = "AutomaticByPlatform" 12 | provision_vm_agent = true 13 | encryption_at_host_enabled = true 14 | 15 | os_disk { 16 | caching = "ReadWrite" 17 | storage_account_type = var.vm_adds_storage_account_type 18 | } 19 | 20 | source_image_reference { 21 | publisher = var.vm_adds_image_publisher 22 | offer = var.vm_adds_image_offer 23 | sku = var.vm_adds_image_sku 24 | version = var.vm_adds_image_version 25 | } 26 | 27 | provisioner "local-exec" { 28 | command = "$params = @{ ${join(" ", local.local_scripts["provisioner_vm_adds"].parameters)}}; ./${path.module}/scripts/${local.local_scripts["provisioner_vm_adds"].name} @params" 29 | interpreter = ["pwsh", "-Command"] 30 | } 31 | } 32 | #endregion 33 | 34 | #region jumpbox VM 35 | resource "azurerm_windows_virtual_machine" "vm_jumpbox_win" { 36 | name = var.vm_jumpbox_win_name 37 | resource_group_name = var.resource_group_name 38 | location = var.location 39 | size = var.vm_jumpbox_win_size 40 | admin_username = data.azurerm_key_vault_secret.adminuser.value 41 | admin_password = data.azurerm_key_vault_secret.adminpassword.value 42 | network_interface_ids = [azurerm_network_interface.vm_jumpbox_win.id] 43 | patch_assessment_mode = "AutomaticByPlatform" 44 | patch_mode = "AutomaticByPlatform" 45 | provision_vm_agent = true 46 | encryption_at_host_enabled = true 47 | 48 | os_disk { 49 | caching = "ReadWrite" 50 | storage_account_type = var.vm_jumpbox_win_storage_account_type 51 | } 52 | 53 | source_image_reference { 54 | publisher = var.vm_jumpbox_win_image_publisher 55 | offer = var.vm_jumpbox_win_image_offer 56 | sku = var.vm_jumpbox_win_image_sku 57 | version = var.vm_jumpbox_win_image_version 58 | } 59 | 60 | depends_on = [azurerm_windows_virtual_machine.vm_adds] 61 | 62 | provisioner "local-exec" { 63 | command = "$params = @{ ${join(" ", local.local_scripts["provisioner_vm_jumpbox_win"].parameters)}}; ./${path.module}/scripts/${local.local_scripts["provisioner_vm_jumpbox_win"].name} @params" 64 | interpreter = ["pwsh", "-Command"] 65 | } 66 | } 67 | #endregion 68 | -------------------------------------------------------------------------------- /extras/modules/vnet-onprem/locals.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | subnets = { 3 | GatewaySubnet = { 4 | address_prefix = var.subnet_GatewaySubnet_address_prefix 5 | associate_nat_gateway = false 6 | } 7 | 8 | snet-adds-02 = { 9 | address_prefix = var.subnet_adds_address_prefix 10 | associate_nat_gateway = true 11 | } 12 | 13 | snet-misc-04 = { 14 | address_prefix = var.subnet_misc_address_prefix 15 | associate_nat_gateway = true 16 | } 17 | } 18 | 19 | local_scripts = { 20 | provisioner_vm_adds = { 21 | name = "Register-DscNode.ps1" 22 | parameters = [ 23 | "TenantId = '${data.azurerm_client_config.current.tenant_id}';", 24 | "SubscriptionId = '${data.azurerm_client_config.current.subscription_id}';", 25 | "ResourceGroupName = '${var.resource_group_name}';", 26 | "Location = '${var.location}';", 27 | "AutomationAccountName = '${var.automation_account_name}';", 28 | "VmAddsName = '${var.vm_adds_name}';", 29 | "VmJumpboxWinName = '${var.vm_jumpbox_win_name}';", 30 | "AdminUsername = '${data.azurerm_key_vault_secret.adminuser.value}';", 31 | "AdminPwd = '${data.azurerm_key_vault_secret.adminpassword.value}';", 32 | "AppId = '${data.azurerm_client_config.current.client_id}';", 33 | "AppSecret = '${data.azurerm_key_vault_secret.arm_client_secret.value}';", 34 | "DscConfigurationName = 'DomainControllerConfiguration2';", 35 | "Domain = '${var.adds_domain_name}';", 36 | "DnsResolverCloud = '${cidrhost(var.subnets_cloud["snet-misc-01"].address_prefixes[0], 4)}';", 37 | "SkipAzureAutomationConfiguration = $false" 38 | ] 39 | } 40 | 41 | provisioner_vm_jumpbox_win = { 42 | name = "Register-DscNode.ps1" 43 | parameters = [ 44 | "TenantId = '${data.azurerm_client_config.current.tenant_id}';", 45 | "SubscriptionId = '${data.azurerm_client_config.current.subscription_id}';", 46 | "ResourceGroupName = '${var.resource_group_name}';", 47 | "Location = '${var.location}';", 48 | "AutomationAccountName = '${var.automation_account_name}';", 49 | "VmAddsName = '${var.vm_adds_name}';", 50 | "VmJumpboxWinName = '${var.vm_jumpbox_win_name}';", 51 | "AdminUsername = '${data.azurerm_key_vault_secret.adminuser.value}';", 52 | "AdminPwd = '${data.azurerm_key_vault_secret.adminpassword.value}';", 53 | "AppId = '${data.azurerm_client_config.current.client_id}';", 54 | "AppSecret = '${data.azurerm_key_vault_secret.arm_client_secret.value}';", 55 | "DscConfigurationName = 'JumpBoxConfiguration2';", 56 | "Domain = '${var.adds_domain_name}';", 57 | "DnsResolverCloud = '${cidrhost(var.subnets_cloud["snet-misc-01"].address_prefixes[0], 4)}';", 58 | "SkipAzureAutomationConfiguration = $true" 59 | ] 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /extras/modules/vnet-onprem/main.tf: -------------------------------------------------------------------------------- 1 | #region data 2 | data "azurerm_client_config" "current" {} 3 | 4 | data "azurerm_key_vault_secret" "adminpassword" { 5 | name = var.admin_password_secret 6 | key_vault_id = var.key_vault_id 7 | } 8 | 9 | data "azurerm_key_vault_secret" "adminuser" { 10 | name = var.admin_username_secret 11 | key_vault_id = var.key_vault_id 12 | } 13 | 14 | data "azurerm_key_vault_secret" "arm_client_secret" { 15 | name = data.azurerm_client_config.current.client_id 16 | key_vault_id = var.key_vault_id 17 | } 18 | #endregion 19 | 20 | #region modules 21 | module "naming" { 22 | source = "Azure/naming/azurerm" 23 | version = "~> 0.4.2" 24 | suffix = [var.tags["project"], var.tags["environment"]] 25 | } 26 | #endregion 27 | -------------------------------------------------------------------------------- /extras/modules/vnet-onprem/outputs.tf: -------------------------------------------------------------------------------- 1 | output "resource_ids" { 2 | value = { 3 | private_dns_resolver = azurerm_private_dns_resolver.this.id 4 | virtual_network_onprem = azurerm_virtual_network.this.id 5 | virtual_machine_adds2 = azurerm_windows_virtual_machine.vm_adds.id 6 | virtual_machine_jumpwin2 = azurerm_windows_virtual_machine.vm_jumpbox_win.id 7 | } 8 | } 9 | 10 | output "resource_names" { 11 | value = { 12 | private_dns_resolver = azurerm_private_dns_resolver.this.name 13 | virtual_network_onprem = azurerm_virtual_network.this.name 14 | virtual_machine_adds2 = azurerm_windows_virtual_machine.vm_adds.name 15 | virtual_machine_jumpwin2 = azurerm_windows_virtual_machine.vm_jumpbox_win.name 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /extras/modules/vnet-onprem/scripts/DomainControllerConfiguration2.ps1: -------------------------------------------------------------------------------- 1 | configuration DomainControllerConfiguration2 { 2 | param ( 3 | [Parameter(Mandatory = $true)] 4 | [String]$ComputerName 5 | ) 6 | 7 | Import-DscResource -ModuleName PSDscResources 8 | Import-DscResource -ModuleName ActiveDirectoryDsc 9 | Import-DscResource -ModuleName DnsServerDsc 10 | 11 | $adminCredential = Get-AutomationPSCredential 'bootstrapadmin' 12 | $domain = Get-AutomationVariable -Name 'adds2_domain_name' 13 | $dnsResolverCloud = Get-AutomationVariable -Name 'dns_resolver_cloud' 14 | 15 | node $ComputerName { 16 | WindowsFeature 'AD-Domain-Services' { 17 | Name = 'AD-Domain-Services' 18 | Ensure = 'Present' 19 | } 20 | 21 | ADDomain 'Domain' { 22 | DomainName = $domain 23 | Credential = $adminCredential 24 | SafemodeAdministratorPassword = $adminCredential 25 | ForestMode = 'WinThreshold' 26 | DependsOn = '[WindowsFeature]AD-Domain-Services' 27 | } 28 | 29 | DnsServerForwarder 'SetForwarders' { 30 | IsSingleInstance = 'Yes' 31 | IPAddresses = @('168.63.129.16') 32 | UseRootHint = $false 33 | DependsOn = '[ADDomain]Domain' 34 | } 35 | 36 | DnsServerConditionalForwarder 'SandboxDomainForwarder' { 37 | Name = 'mysandbox.local' 38 | MasterServers = @("$dnsResolverCloud") 39 | DependsOn = '[ADDomain]Domain' 40 | } 41 | 42 | DnsServerConditionalForwarder 'AzureFiles' { 43 | Name = 'file.core.windows.net' 44 | MasterServers = @("$dnsResolverCloud") 45 | DependsOn = '[ADDomain]Domain' 46 | } 47 | 48 | DnsServerConditionalForwarder 'AzureSqlDb' { 49 | Name = 'database.windows.net' 50 | MasterServers = @("$dnsResolverCloud") 51 | DependsOn = '[ADDomain]Domain' 52 | } 53 | 54 | DnsServerConditionalForwarder 'AzureMySQLFlexServer' { 55 | Name = 'mysql.database.azure.com' 56 | MasterServers = @("$dnsResolverCloud") 57 | DependsOn = '[ADDomain]Domain' 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /extras/modules/vnet-onprem/scripts/JumpBoxConfiguration2.ps1: -------------------------------------------------------------------------------- 1 | configuration JumpBoxConfiguration2 { 2 | param ( 3 | [Parameter(Mandatory = $true)] 4 | [String]$ComputerName 5 | ) 6 | 7 | Import-DscResource -ModuleName 'PSDscResources' 8 | Import-DscResource -ModuleName 'ComputerManagementDsc' 9 | Import-DscResource -ModuleName 'cChoco' 10 | 11 | $domain = Get-AutomationVariable -Name 'adds2_domain_name' 12 | $domainAdminCredential = Get-AutomationPSCredential 'domainadmin' 13 | 14 | node $ComputerName { 15 | WindowsFeature RsatAdds { 16 | Name = 'RSAT-ADDS' 17 | Ensure = 'Present' 18 | } 19 | 20 | WindowsFeature RsatDns { 21 | Name = 'RSAT-DNS-Server' 22 | Ensure = 'Present' 23 | } 24 | 25 | cChocoInstaller InstallChoco { 26 | InstallDir = 'c:\choco' 27 | } 28 | 29 | cChocoPackageInstallerSet JumpboxSoftware { 30 | Ensure = 'Present' 31 | Name = @( 32 | "az.powershell" 33 | "mysql.workbench" 34 | "sql-server-management-studio" 35 | "vscode" 36 | ) 37 | DependsOn = '[cChocoInstaller]InstallChoco' 38 | } 39 | 40 | # Custom Script to Wait for Software Installation 41 | Script WaitForSoftware { 42 | GetScript = { 43 | @{ 44 | Result = (Get-Module -Name Az -ListAvailable | Where-Object { $_.Name -eq 'Az' }) 45 | } 46 | } 47 | TestScript = { 48 | (Get-Module -Name Az -ListAvailable | Where-Object { $_.Name -eq 'Az' }) -ne $null 49 | } 50 | SetScript = { 51 | Write-Verbose "Waiting for the Az PowerShell module to be installed on Windows PowerShell..." 52 | } 53 | DependsOn = "[cChocoPackageInstallerSet]JumpboxSoftware" 54 | } 55 | 56 | Computer JoinDomain { 57 | Name = $ComputerName 58 | DomainName = $domain 59 | Credential = $domainAdminCredential 60 | DependsOn = '[Script]WaitForSoftware' 61 | } 62 | 63 | # Force a reboot if required 64 | PendingReboot RebootAfterDomainJoin { 65 | Name = 'DomainJoin' 66 | DependsOn = '[Computer]JoinDomain' 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /extras/modules/vnet-onprem/terraform.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_version = "~> 1.12.1" 3 | 4 | required_providers { 5 | azurerm = { 6 | source = "hashicorp/azurerm" 7 | version = "~> 4.31.0" 8 | } 9 | 10 | random = { 11 | source = "hashicorp/random" 12 | version = "~> 3.7.2" 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /locals.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | key_vault_roles = { 3 | kv_secrets_officer_spn = { 4 | principal_id = data.azurerm_client_config.current.object_id 5 | principal_type = "ServicePrincipal" 6 | role_definition_name = "Key Vault Secrets Officer" 7 | } 8 | kv_secrets_officer_user = { 9 | principal_id = var.user_object_id 10 | principal_type = "User" 11 | role_definition_name = "Key Vault Secrets Officer" 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /modules/README.md: -------------------------------------------------------------------------------- 1 | # Azure Sandbox Modules 2 | 3 | The following modules are included in this configuration: 4 | 5 | Name | Required | Description 6 | --- | --- | --- 7 | [vnet-shared](./vnet-shared/) | Yes | Includes a shared services virtual network including a Bastion Host, Azure Firewall and an AD domain controller/DNS server VM. 8 | [vnet-app](./vnet-app/) | No | Includes an application virtual network, a network isolated Azure Files share and a preconfigured Windows jumpbox VM. 9 | [vm-jumpbox-linux](./vm-jumpbox-linux/) | No | Includes a preconfigured Linux jumpbox VM in the application virtual network. 10 | [vm-mssql-win](./vm-mssql-win/) | No | Includes a preconfigured Windows VM with SQL Server in the application virtual network. 11 | [mssql](./mssql/) | No | Includes a network isolated Azure SQL Database in the application virtual network. 12 | [mysql](./mysql/) | No | Creates a network isolated Azure MySQL Database in the application virtual network. 13 | [vwan](./vwan/) | No | Creates a Point-to-Site VPN gateway to securely connect to your sandbox environment from your local machine. 14 | -------------------------------------------------------------------------------- /modules/mssql/README.md: -------------------------------------------------------------------------------- 1 | # Azure SQL Database Module (mssql) 2 | 3 | ## Contents 4 | 5 | * [Architecture](#architecture) 6 | * [Overview](#overview) 7 | * [Smoke Testing](#smoke-testing) 8 | * [Documentation](#documentation) 9 | 10 | ## Architecture 11 | 12 | ![mssql-diagram](./images/mssql-diagram.drawio.svg) 13 | 14 | ## Overview 15 | 16 | This configuration implements a network isolated Azure SQL Database using private endpoints. 17 | 18 | ## Smoke Testing 19 | 20 | This section describes how to test the module after deployment. 21 | 22 | * Test DNS queries for Azure SQL database private endpoint 23 | * From *jumpwin1*, run the Windows PowerShell command: 24 | 25 | ```powershell 26 | Resolve-DnsName YOUR-AZURE-SQL-SERVER-NAME-HERE.database.windows.net 27 | ``` 28 | 29 | * Verify the *IP4Address* returned is within the subnet IP address prefix for *vnet_app[0].subnets["snet-privatelink-01"]*, e.g. `10.2.2.*`. 30 | * From *jumpwin1*, test SQL Server Connectivity with SQL Server Management Studio (SSMS) 31 | * Navigate to *Start* > *Microsoft SQL Server Tools 20* > *Microsoft SQL Server Management Studio 20* 32 | * Connect to the network isolated Azure SQL Database server 33 | * Server properties: 34 | * Server name: *YOUR-AZURE-SQL-SERVER-NAME-HERE.database.windows.net* 35 | * Authentication: *SQL Server Authentication* 36 | * Login: *bootstrapadmin* 37 | * Password: Use the value stored in the *adminpassword* key vault secret 38 | * Connection security properties: 39 | * Encryption: *Strict (SQL Server 2022 and Azure SQL)* 40 | * Expand the *Databases* tab and verify you can see *testdb* 41 | 42 | ## Documentation 43 | 44 | This section provides additional information on various aspects of this module. 45 | 46 | * [Dependencies](#dependencies) 47 | * [Module Structure](#module-structure) 48 | * [Input Variables](#input-variables) 49 | * [Module Resources](#module-resources) 50 | * [Output Variables](#output-variables) 51 | 52 | ### Dependencies 53 | 54 | This module depends upon resources provisioned in the following modules: 55 | 56 | * Root module 57 | * vnet-shared module 58 | * vnet-app module 59 | 60 | ### Module Structure 61 | 62 | The module is organized as follows: 63 | 64 | ```plaintext 65 | ├── images/ 66 | | └── mssql-diagram.drawio.svg # Architecture diagram 67 | ├── main.tf # Resource configurations 68 | ├── network.tf # Network resource configurations 69 | ├── outputs.tf # Output variables 70 | ├── terraform.tf # Terraform configuration block 71 | └── variables.tf # Input variables 72 | ``` 73 | 74 | ### Input Variables 75 | 76 | This section lists input variables used in this module. Defaults can be overridden by specifying a different value in the root module. 77 | 78 | Variable | Default | Description 79 | --- | --- | --- 80 | admin_password_secret | adminpassword | The name of the key vault secret that contains the password for the admin account. Defined in the vnet-shared module. 81 | admin_username_secret | adminuser | The name of the key vault secret that contains the user name for the admin account. Defined in the vnet-shared module. 82 | key_vault_id | | The ID of the key vault defined in the root module. 83 | location | | The name of the Azure Region where resources will be provisioned. 84 | mssql_database_name | testdb | The name of the Azure SQL Database to be provisioned. 85 | resource_group_name | | The name of the resource group defined in the root module. 86 | subnet_id | | The subnet ID defined in the vnet-app module. 87 | tags | | The tags from the root module. 88 | unique_seed | | The unique seed used to generate unique names for resources. Defined in the root module. 89 | 90 | ### Module Resources 91 | 92 | This section lists the resources included in this module. 93 | 94 | Address | Name | Notes 95 | --- | --- | --- 96 | module.mssql[0].azurerm_mssql_database.this | testdb | The Azure SQL Database. 97 | module.mssql[0].azurerm_mssql_server.this | sql‑sand‑dev‑xxxxxxxx | The Azure SQL logical server. 98 | module.mssql[0].azurerm_private_dns_a_record.this | | The A record for the Azure SQL logical server. 99 | module.mssql[0].azurerm_private_endpoint.this | pe‑sand‑dev‑mssql‑server | The private endpoint for the Azure SQL logical server. 100 | 101 | ### Output Variables 102 | 103 | This section includes a list of output variables returned by the module. 104 | 105 | Name | Default | Comments 106 | --- | --- | --- 107 | resource_ids | | A map of resource IDs for key resources in the module. 108 | resource_names | | A map of resource names for key resources in the module. 109 | -------------------------------------------------------------------------------- /modules/mssql/main.tf: -------------------------------------------------------------------------------- 1 | #region data 2 | data "azurerm_key_vault_secret" "adminpassword" { 3 | name = var.admin_password_secret 4 | key_vault_id = var.key_vault_id 5 | } 6 | 7 | data "azurerm_key_vault_secret" "adminuser" { 8 | name = var.admin_username_secret 9 | key_vault_id = var.key_vault_id 10 | } 11 | #endregion 12 | 13 | #region resources 14 | resource "azurerm_mssql_server" "this" { 15 | name = module.naming.sql_server.name_unique 16 | resource_group_name = var.resource_group_name 17 | location = var.location 18 | version = "12.0" 19 | administrator_login = data.azurerm_key_vault_secret.adminuser.value 20 | administrator_login_password = data.azurerm_key_vault_secret.adminpassword.value 21 | minimum_tls_version = "1.2" 22 | public_network_access_enabled = false 23 | 24 | lifecycle { 25 | ignore_changes = [ 26 | express_vulnerability_assessment_enabled 27 | ] 28 | } 29 | } 30 | 31 | resource "azurerm_mssql_database" "this" { 32 | name = var.mssql_database_name 33 | server_id = azurerm_mssql_server.this.id 34 | license_type = "BasePrice" 35 | } 36 | #endregion 37 | 38 | #region modules 39 | module "naming" { 40 | source = "Azure/naming/azurerm" 41 | version = "~> 0.4.2" 42 | suffix = [var.tags["project"], var.tags["environment"]] 43 | unique-seed = var.unique_seed 44 | unique-include-numbers = true 45 | unique-length = 8 46 | } 47 | #endregion 48 | -------------------------------------------------------------------------------- /modules/mssql/network.tf: -------------------------------------------------------------------------------- 1 | resource "azurerm_private_endpoint" "this" { 2 | name = "${module.naming.private_endpoint.name}-mssql-server" 3 | resource_group_name = var.resource_group_name 4 | location = var.location 5 | subnet_id = var.subnet_id 6 | 7 | private_service_connection { 8 | name = "azure_sql_database_logical_server" 9 | private_connection_resource_id = azurerm_mssql_server.this.id 10 | is_manual_connection = false 11 | subresource_names = ["sqlServer"] 12 | } 13 | } 14 | 15 | resource "azurerm_private_dns_a_record" "this" { 16 | name = azurerm_mssql_server.this.name 17 | zone_name = "privatelink.database.windows.net" 18 | resource_group_name = var.resource_group_name 19 | ttl = 300 20 | records = [azurerm_private_endpoint.this.private_service_connection[0].private_ip_address] 21 | } 22 | -------------------------------------------------------------------------------- /modules/mssql/outputs.tf: -------------------------------------------------------------------------------- 1 | output "resource_ids" { 2 | value = { 3 | mssql_server = azurerm_mssql_server.this.id 4 | mssql_db = azurerm_mssql_database.this.id 5 | } 6 | } 7 | 8 | output "resource_names" { 9 | value = { 10 | mssql_server = azurerm_mssql_server.this.name 11 | mssql_db = azurerm_mssql_database.this.name 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /modules/mssql/terraform.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_version = "~> 1.12.1" 3 | 4 | required_providers { 5 | azurerm = { 6 | source = "hashicorp/azurerm" 7 | version = "~> 4.31.0" 8 | } 9 | 10 | random = { 11 | source = "hashicorp/random" 12 | version = "~> 3.7.2" 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /modules/mssql/variables.tf: -------------------------------------------------------------------------------- 1 | variable "admin_password_secret" { 2 | type = string 3 | description = "The name of the key vault secret containing the admin password" 4 | 5 | validation { 6 | condition = can(regex("^[a-zA-Z0-9-]{1,127}$", var.admin_password_secret)) 7 | error_message = "Must conform to Azure Key Vault secret naming requirements: it can only contain alphanumeric characters and hyphens, and must be between 1 and 127 characters long." 8 | } 9 | } 10 | 11 | variable "admin_username_secret" { 12 | type = string 13 | description = "The name of the key vault secret containing the admin username" 14 | 15 | validation { 16 | condition = can(regex("^[a-zA-Z0-9-]{1,127}$", var.admin_username_secret)) 17 | error_message = "Must conform to Azure Key Vault secret naming requirements: it can only contain alphanumeric characters and hyphens, and must be between 1 and 127 characters long." 18 | } 19 | } 20 | 21 | variable "key_vault_id" { 22 | type = string 23 | description = "The existing key vault where secrets are stored" 24 | 25 | validation { 26 | condition = can(regex("^/subscriptions/[0-9a-fA-F-]+/resourceGroups/[a-zA-Z0-9-_()]+/providers/Microsoft.KeyVault/vaults/[a-zA-Z0-9-]+$", var.key_vault_id)) 27 | error_message = "Must be a valid Azure Resource ID for a Key Vault. It should follow the format '/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.KeyVault/vaults/{keyVaultName}'." 28 | } 29 | } 30 | 31 | variable "location" { 32 | type = string 33 | description = "The name of the Azure Region where resources will be provisioned." 34 | 35 | validation { 36 | condition = can(regex("^[a-z0-9-]+$", var.location)) 37 | error_message = "The 'location' must be a valid Azure region name. It should only contain lowercase letters, numbers, and dashes (e.g., 'eastus', 'westus2', 'centralus')." 38 | } 39 | } 40 | 41 | variable "mssql_database_name" { 42 | type = string 43 | description = "The name of the Azure SQL Database to be provisioned." 44 | default = "testdb" 45 | 46 | validation { 47 | condition = can(regex("^[a-zA-Z0-9_-]{1,128}$", var.mssql_database_name)) 48 | error_message = "The 'mssql_database_name' must conform to Azure SQL Database naming requirements: it can only contain alphanumeric characters, underscores (_), and hyphens (-), and must be between 1 and 128 characters long." 49 | } 50 | } 51 | 52 | variable "resource_group_name" { 53 | type = string 54 | description = "The name of the existing resource group for provisioning resources." 55 | 56 | validation { 57 | condition = can(regex("^[a-zA-Z0-9._()-]{1,90}$", var.resource_group_name)) 58 | error_message = "Must conform to Azure resource group naming requirements: it can only contain alphanumeric characters, periods (.), underscores (_), parentheses (()), and hyphens (-), and must be between 1 and 90 characters long." 59 | } 60 | } 61 | 62 | variable "subnet_id" { 63 | type = string 64 | description = "The ID of the existing subnet where the nic will be provisioned." 65 | 66 | validation { 67 | condition = can(regex("^/subscriptions/[0-9a-fA-F-]+/resourceGroups/[a-zA-Z0-9-_()]+/providers/Microsoft.Network/virtualNetworks/[a-zA-Z0-9-_()]+/subnets/[a-zA-Z0-9-_()]+$", var.subnet_id)) 68 | error_message = "Must be a valid Azure Resource ID for a subnet. It should follow the format '/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Network/virtualNetworks/{vnetName}/subnets/{subnetName}'." 69 | } 70 | } 71 | 72 | variable "tags" { 73 | type = map(any) 74 | description = "The tags in map format to be used when creating new resources." 75 | 76 | validation { 77 | condition = alltrue([ 78 | for key, value in var.tags : 79 | can(regex("^[a-zA-Z0-9._-]{1,512}$", key)) && 80 | can(regex("^[a-zA-Z0-9._ -]{0,256}$", value)) 81 | ]) 82 | error_message = "Each tag key must be 1-512 characters long and consist of alphanumeric characters, periods (.), underscores (_), or hyphens (-). Each tag value must be 0-256 characters long and consist of alphanumeric characters, periods (.), underscores (_), spaces, or hyphens (-)." 83 | } 84 | } 85 | 86 | variable "unique_seed" { 87 | type = string 88 | description = "A unique seed to be used for generating unique names for resources. This should be a string that is unique to the environment or deployment." 89 | 90 | validation { 91 | condition = can(regex("^[a-zA-Z0-9-]{1,64}$", var.unique_seed)) 92 | error_message = "Must only contain alphanumeric characters and hyphens (-), and must be between 1 and 32 characters long." 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /modules/mysql/README.md: -------------------------------------------------------------------------------- 1 | # Azure MySQL Database Module (mysql) 2 | 3 | ## Contents 4 | 5 | * [Architecture](#architecture) 6 | * [Overview](#overview) 7 | * [Smoke Testing](#smoke-testing) 8 | * [Documentation](#documentation) 9 | 10 | ## Architecture 11 | 12 | ![mysql-diagram](./images/mysql-diagram.drawio.svg) 13 | 14 | ## Overview 15 | 16 | This configuration implements a network isolated Azure Database for MySQL using private endpoints. 17 | 18 | ## Smoke Testing 19 | 20 | This section describes how to test the module after deployment. 21 | 22 | * Test DNS queries for Azure Database for MySQL private endpoint 23 | * From *jumpwin1*, execute the following PowerShell command: 24 | 25 | ```powershell 26 | Resolve-DnsName YOUR-MYSQL-SERVER-NAME-HERE.mysql.database.azure.com 27 | ``` 28 | 29 | * Verify the *IP4Address* returned is within the subnet IP address prefix for *vnet_app[0].subnets["snet-privatelink-01"]*, e.g. `10.2.2.*`. 30 | * From *jumpwin1*, test network isolated MySQL connectivity using MySQL Workbench. 31 | * Navigate to *Start* > *MySQL Workbench* 32 | * Navigate to *Database* > *Connect to Database* and connect using the following values: 33 | * Connection method: Standard (TCP/IP) 34 | * Hostname: *YOUR-MYSQL-SERVER-NAME-HERE.mysql.database.azure.com* 35 | * Port: *3306* 36 | * Username: *bootstrapadmin* 37 | * Schema: *testdb* 38 | * Click *OK* and when prompted for *password* use the value of the *adminpassword* secret in the sandbox environment key vault. 39 | * Create a table, insert some data and run some sample queries to verify functionality. 40 | 41 | ## Documentation 42 | 43 | This section provides additional information on various aspects of this module. 44 | 45 | * [Dependencies](#dependencies) 46 | * [Module Structure](#module-structure) 47 | * [Input Variables](#input-variables) 48 | * [Module Resources](#module-resources) 49 | * [Output Variables](#output-variables) 50 | 51 | ### Dependencies 52 | 53 | This module depends upon resources provisioned in the following modules: 54 | 55 | * Root module 56 | * vnet-shared module 57 | * vnet-app module 58 | 59 | ### Module Structure 60 | 61 | The module is organized as follows: 62 | 63 | ```plaintext 64 | ├── images/ 65 | | └── mysql-diagram.drawio.svg # Architecture diagram 66 | ├── main.tf # Resource configurations 67 | ├── network.tf # Network resource configurations 68 | ├── outputs.tf # Output variables 69 | ├── terraform.tf # Terraform configuration block 70 | └── variables.tf # Input variables 71 | ``` 72 | 73 | ### Input Variables 74 | 75 | This section lists input variables used in this module. Defaults can be overridden by specifying a different value in the root module. 76 | 77 | Variable | Default | Description 78 | --- | --- | --- 79 | admin_password_secret | adminpassword | The name of the key vault secret that contains the password for the admin account. Defined in the vnet-shared module. 80 | admin_username_secret | adminuser | The name of the key vault secret that contains the user name for the admin account. Defined in the vnet-shared module. 81 | key_vault_id | | The ID of the key vault defined in the root module. 82 | location | | The name of the Azure Region where resources will be provisioned. Defined in the root module. 83 | mysql_database_name | testdb | The name of the Azure MySQL Database to be provisioned. 84 | resource_group_name | | The name of the resource group defined in the root module. 85 | subnet_id | | The subnet ID defined in the vnet-app module. 86 | tags | | The tags from the root module. 87 | unique_seed | | The unique seed used to generate unique names for resources. Defined in the root module. 88 | 89 | ### Module Resources 90 | 91 | This section lists the resources included in this module. 92 | 93 | Address | Name | Notes 94 | --- | --- | --- 95 | module.mysql[0].azurerm_mysql_flexible_database.this | testdb | The Azure MySQL Database. 96 | module.mysql[0].azurerm_mysql_flexible_server.this | mysql‑sand‑dev‑xxxxxxxx | The Azure MySQL flexible server. 97 | module.mysql[0].azurerm_private_dns_a_record.this | | The A record for the Azure MySQL flexible server. 98 | module.mysql[0].azurerm_private_endpoint.this | pe‑sand‑dev‑mysql‑server | The private endpoint for the Azure MySQL flexible server. 99 | 100 | ### Output Variables 101 | 102 | This section includes a list of output variables returned by the module. 103 | 104 | Name | Default | Comments 105 | --- | --- | --- 106 | resource_ids | | A map of resource IDs for key resources in the module. 107 | resource_names | | A map of resource names for key resources in the module. 108 | -------------------------------------------------------------------------------- /modules/mysql/main.tf: -------------------------------------------------------------------------------- 1 | #region data 2 | data "azurerm_key_vault_secret" "adminpassword" { 3 | name = var.admin_password_secret 4 | key_vault_id = var.key_vault_id 5 | } 6 | 7 | data "azurerm_key_vault_secret" "adminuser" { 8 | name = var.admin_username_secret 9 | key_vault_id = var.key_vault_id 10 | } 11 | #endregion 12 | 13 | #region resources 14 | resource "azurerm_mysql_flexible_server" "this" { 15 | name = module.naming.mysql_server.name_unique 16 | resource_group_name = var.resource_group_name 17 | location = var.location 18 | administrator_login = data.azurerm_key_vault_secret.adminuser.value 19 | administrator_password = data.azurerm_key_vault_secret.adminpassword.value 20 | sku_name = "B_Standard_B1ms" 21 | } 22 | 23 | resource "azurerm_mysql_flexible_database" "this" { 24 | name = var.mysql_database_name 25 | resource_group_name = var.resource_group_name 26 | server_name = azurerm_mysql_flexible_server.this.name 27 | charset = "utf8" 28 | collation = "utf8_unicode_ci" 29 | } 30 | #endregion 31 | 32 | #region modules 33 | module "naming" { 34 | source = "Azure/naming/azurerm" 35 | version = "~> 0.4.2" 36 | suffix = [var.tags["project"], var.tags["environment"]] 37 | unique-seed = var.unique_seed 38 | unique-include-numbers = true 39 | unique-length = 8 40 | } 41 | #endregion 42 | -------------------------------------------------------------------------------- /modules/mysql/network.tf: -------------------------------------------------------------------------------- 1 | resource "azurerm_private_endpoint" "this" { 2 | name = "${module.naming.private_endpoint.name}-mysql-server" 3 | resource_group_name = var.resource_group_name 4 | location = var.location 5 | subnet_id = var.subnet_id 6 | 7 | depends_on = [azurerm_mysql_flexible_database.this] 8 | 9 | private_service_connection { 10 | name = "azure_mysql_flexible_server" 11 | private_connection_resource_id = azurerm_mysql_flexible_server.this.id 12 | is_manual_connection = false 13 | subresource_names = ["mysqlServer"] 14 | } 15 | } 16 | 17 | resource "azurerm_private_dns_a_record" "this" { 18 | name = azurerm_mysql_flexible_server.this.name 19 | zone_name = "privatelink.mysql.database.azure.com" 20 | resource_group_name = var.resource_group_name 21 | ttl = 300 22 | records = [azurerm_private_endpoint.this.private_service_connection[0].private_ip_address] 23 | } 24 | -------------------------------------------------------------------------------- /modules/mysql/outputs.tf: -------------------------------------------------------------------------------- 1 | output "resource_ids" { 2 | value = { 3 | mysql_server = azurerm_mysql_flexible_server.this.id 4 | mysql_db = azurerm_mysql_flexible_database.this.id 5 | } 6 | } 7 | 8 | output "resource_names" { 9 | value = { 10 | mysql_server = azurerm_mysql_flexible_server.this.name 11 | mysql_db = azurerm_mysql_flexible_database.this.name 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /modules/mysql/terraform.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_version = "~> 1.12.1" 3 | 4 | required_providers { 5 | azurerm = { 6 | source = "hashicorp/azurerm" 7 | version = "~> 4.31.0" 8 | } 9 | 10 | random = { 11 | source = "hashicorp/random" 12 | version = "~> 3.7.2" 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /modules/mysql/variables.tf: -------------------------------------------------------------------------------- 1 | variable "admin_password_secret" { 2 | type = string 3 | description = "The name of the key vault secret containing the admin password" 4 | 5 | validation { 6 | condition = can(regex("^[a-zA-Z0-9-]{1,127}$", var.admin_password_secret)) 7 | error_message = "Must conform to Azure Key Vault secret naming requirements: it can only contain alphanumeric characters and hyphens, and must be between 1 and 127 characters long." 8 | } 9 | } 10 | 11 | variable "admin_username_secret" { 12 | type = string 13 | description = "The name of the key vault secret containing the admin username" 14 | 15 | validation { 16 | condition = can(regex("^[a-zA-Z0-9-]{1,127}$", var.admin_username_secret)) 17 | error_message = "Must conform to Azure Key Vault secret naming requirements: it can only contain alphanumeric characters and hyphens, and must be between 1 and 127 characters long." 18 | } 19 | } 20 | 21 | variable "key_vault_id" { 22 | type = string 23 | description = "The existing key vault where secrets are stored" 24 | 25 | validation { 26 | condition = can(regex("^/subscriptions/[0-9a-fA-F-]+/resourceGroups/[a-zA-Z0-9-_()]+/providers/Microsoft.KeyVault/vaults/[a-zA-Z0-9-]+$", var.key_vault_id)) 27 | error_message = "Must be a valid Azure Resource ID for a Key Vault. It should follow the format '/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.KeyVault/vaults/{keyVaultName}'." 28 | } 29 | } 30 | 31 | variable "location" { 32 | type = string 33 | description = "The name of the Azure Region where resources will be provisioned." 34 | 35 | validation { 36 | condition = can(regex("^[a-z0-9-]+$", var.location)) 37 | error_message = "Must be a valid Azure region name. It should only contain lowercase letters, numbers, and dashes (e.g., 'eastus', 'westus2', 'centralus')." 38 | } 39 | } 40 | 41 | variable "mysql_database_name" { 42 | type = string 43 | description = "The name of the Azure MySQL Database to be provisioned." 44 | default = "testdb" 45 | 46 | validation { 47 | condition = can(regex("^[a-zA-Z0-9_]{1,64}$", var.mysql_database_name)) 48 | error_message = "Must conform to Azure MySQL Flexible Server database naming requirements: it can only contain alphanumeric characters and underscores (_), and must be between 1 and 64 characters long." 49 | } 50 | } 51 | 52 | variable "resource_group_name" { 53 | type = string 54 | description = "The name of the existing resource group for provisioning resources." 55 | 56 | validation { 57 | condition = can(regex("^[a-zA-Z0-9._()-]{1,90}$", var.resource_group_name)) 58 | error_message = "Must conform to Azure resource group naming requirements: it can only contain alphanumeric characters, periods (.), underscores (_), parentheses (()), and hyphens (-), and must be between 1 and 90 characters long." 59 | } 60 | } 61 | 62 | variable "subnet_id" { 63 | type = string 64 | description = "The ID of the existing subnet where the nic will be provisioned." 65 | 66 | validation { 67 | condition = can(regex("^/subscriptions/[0-9a-fA-F-]+/resourceGroups/[a-zA-Z0-9-_()]+/providers/Microsoft.Network/virtualNetworks/[a-zA-Z0-9-_()]+/subnets/[a-zA-Z0-9-_()]+$", var.subnet_id)) 68 | error_message = "Must be a valid Azure Resource ID for a subnet. It should follow the format '/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Network/virtualNetworks/{vnetName}/subnets/{subnetName}'." 69 | } 70 | } 71 | 72 | variable "tags" { 73 | type = map(any) 74 | description = "The tags in map format to be used when creating new resources." 75 | 76 | validation { 77 | condition = alltrue([ 78 | for key, value in var.tags : 79 | can(regex("^[a-zA-Z0-9._-]{1,512}$", key)) && 80 | can(regex("^[a-zA-Z0-9._ -]{0,256}$", value)) 81 | ]) 82 | error_message = "Each tag key must be 1-512 characters long and consist of alphanumeric characters, periods (.), underscores (_), or hyphens (-). Each tag value must be 0-256 characters long and consist of alphanumeric characters, periods (.), underscores (_), spaces, or hyphens (-)." 83 | } 84 | } 85 | 86 | variable "unique_seed" { 87 | type = string 88 | description = "A unique seed to be used for generating unique names for resources. This should be a string that is unique to the environment or deployment." 89 | 90 | validation { 91 | condition = can(regex("^[a-zA-Z0-9-]{1,64}$", var.unique_seed)) 92 | error_message = "Must only contain alphanumeric characters and hyphens (-), and must be between 1 and 32 characters long." 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /modules/vm-jumpbox-linux/compute.tf: -------------------------------------------------------------------------------- 1 | resource "azurerm_linux_virtual_machine" "this" { 2 | name = var.vm_jumpbox_linux_name 3 | resource_group_name = var.resource_group_name 4 | location = var.location 5 | size = var.vm_jumpbox_linux_size 6 | admin_username = "${data.azurerm_key_vault_secret.adminuser.value}local" 7 | network_interface_ids = [azurerm_network_interface.this.id] 8 | encryption_at_host_enabled = true 9 | patch_assessment_mode = "AutomaticByPlatform" 10 | provision_vm_agent = true 11 | 12 | admin_ssh_key { 13 | username = "${data.azurerm_key_vault_secret.adminuser.value}local" 14 | public_key = tls_private_key.ssh_key.public_key_openssh 15 | } 16 | 17 | os_disk { 18 | caching = "ReadWrite" 19 | storage_account_type = var.vm_jumpbox_linux_storage_account_type 20 | } 21 | 22 | source_image_reference { 23 | publisher = var.vm_jumpbox_linux_image_publisher 24 | offer = var.vm_jumpbox_linux_image_offer 25 | sku = var.vm_jumpbox_linux_image_sku 26 | version = var.vm_jumpbox_linux_image_version 27 | } 28 | 29 | identity { 30 | type = "SystemAssigned" 31 | } 32 | 33 | custom_data = data.cloudinit_config.vm_jumpbox_linux.rendered 34 | } 35 | 36 | resource "azurerm_role_assignment" "kv_secrets_user_vm_linux" { 37 | scope = var.key_vault_id 38 | role_definition_name = "Key Vault Secrets User" 39 | principal_id = azurerm_linux_virtual_machine.this.identity[0].principal_id 40 | } 41 | -------------------------------------------------------------------------------- /modules/vm-jumpbox-linux/main.tf: -------------------------------------------------------------------------------- 1 | #region data 2 | data "azurerm_key_vault_secret" "adminuser" { 3 | name = var.admin_username_secret 4 | key_vault_id = var.key_vault_id 5 | } 6 | 7 | data "cloudinit_config" "vm_jumpbox_linux" { 8 | gzip = true 9 | base64_encode = true 10 | 11 | part { 12 | content_type = "text/cloud-config" 13 | content = templatefile( 14 | "./${path.module}/scripts/configure-vm-jumpbox-linux.yaml", { 15 | adds_domain_name = var.adds_domain_name, 16 | dns_server = var.dns_server, 17 | key_vault_name = var.key_vault_name, 18 | storage_account_name = var.storage_account_name, 19 | storage_share_name = var.storage_share_name 20 | } 21 | ) 22 | filename = "configure-vm-jumpbox-linux.yaml" 23 | } 24 | 25 | part { 26 | content_type = "text/x-shellscript" 27 | content = file("./${path.module}/scripts/configure-vm-jumpbox-linux.sh") 28 | filename = "configure-vm-jumpbox-linux.sh" 29 | } 30 | } 31 | #endregion 32 | 33 | #region secrets 34 | resource "tls_private_key" "ssh_key" { 35 | algorithm = "RSA" 36 | rsa_bits = 4096 37 | } 38 | 39 | resource "azurerm_key_vault_secret" "ssh_private_key" { 40 | name = "${var.vm_jumpbox_linux_name}-ssh-private-key" 41 | value = tls_private_key.ssh_key.private_key_pem 42 | key_vault_id = var.key_vault_id 43 | expiration_date = timeadd(timestamp(), "8760h") 44 | 45 | lifecycle { 46 | ignore_changes = [expiration_date] 47 | } 48 | } 49 | #endregion 50 | 51 | #region modules 52 | module "naming" { 53 | source = "Azure/naming/azurerm" 54 | version = "~> 0.4.2" 55 | suffix = [var.tags["project"], var.tags["environment"]] 56 | } 57 | #endregion 58 | -------------------------------------------------------------------------------- /modules/vm-jumpbox-linux/network.tf: -------------------------------------------------------------------------------- 1 | # Nics 2 | resource "azurerm_network_interface" "this" { 3 | name = "${module.naming.network_interface.name}-${var.vm_jumpbox_linux_name}" 4 | location = var.location 5 | resource_group_name = var.resource_group_name 6 | 7 | ip_configuration { 8 | name = "Primary" 9 | subnet_id = var.subnet_id 10 | private_ip_address_allocation = "Dynamic" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /modules/vm-jumpbox-linux/ouputs.tf: -------------------------------------------------------------------------------- 1 | output "resource_ids" { 2 | value = { 3 | virtual_machine_jumplinux1 = azurerm_linux_virtual_machine.this.id 4 | } 5 | } 6 | 7 | output "resource_names" { 8 | value = { 9 | virtual_machine_jumplinux1 = azurerm_linux_virtual_machine.this.name 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /modules/vm-jumpbox-linux/terraform.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_version = "~> 1.12.1" 3 | 4 | required_providers { 5 | azurerm = { 6 | source = "hashicorp/azurerm" 7 | version = "~> 4.31.0" 8 | } 9 | 10 | cloudinit = { 11 | source = "hashicorp/cloudinit" 12 | version = "~> 2.3.7" 13 | } 14 | 15 | random = { 16 | source = "hashicorp/random" 17 | version = "~> 3.7.2" 18 | } 19 | 20 | tls = { 21 | source = "hashicorp/tls" 22 | version = "~> 4.1.0" 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /modules/vm-mssql-win/compute.tf: -------------------------------------------------------------------------------- 1 | resource "azurerm_windows_virtual_machine" "this" { 2 | name = var.vm_mssql_win_name 3 | resource_group_name = var.resource_group_name 4 | location = var.location 5 | size = var.vm_mssql_win_size 6 | admin_username = data.azurerm_key_vault_secret.adminuser.value 7 | admin_password = data.azurerm_key_vault_secret.adminpassword.value 8 | network_interface_ids = [azurerm_network_interface.this.id] 9 | patch_assessment_mode = "AutomaticByPlatform" 10 | provision_vm_agent = true 11 | encryption_at_host_enabled = true 12 | 13 | os_disk { 14 | caching = "ReadWrite" 15 | storage_account_type = var.vm_mssql_win_storage_account_type 16 | } 17 | 18 | source_image_reference { 19 | publisher = var.vm_mssql_win_image_publisher 20 | offer = var.vm_mssql_win_image_offer 21 | sku = var.vm_mssql_win_image_sku 22 | version = var.vm_mssql_win_image_version 23 | } 24 | 25 | identity { 26 | type = "SystemAssigned" 27 | } 28 | 29 | provisioner "local-exec" { 30 | command = "$params = @{ ${join(" ", local.local_scripts["provisioner"].parameters)}}; ./${path.module}/scripts/${local.local_scripts["provisioner"].name} @params" 31 | interpreter = ["pwsh", "-Command"] 32 | } 33 | } 34 | 35 | resource "azurerm_managed_disk" "disks" { 36 | for_each = local.disks 37 | 38 | name = "${module.naming.managed_disk.name}-${each.value.name}" 39 | location = var.location 40 | resource_group_name = var.resource_group_name 41 | storage_account_type = var.vm_mssql_win_storage_account_type 42 | create_option = "Empty" 43 | disk_size_gb = each.value.disk_size_gb 44 | } 45 | 46 | resource "azurerm_virtual_machine_data_disk_attachment" "attachments" { 47 | for_each = local.disks 48 | 49 | managed_disk_id = azurerm_managed_disk.disks[each.key].id 50 | virtual_machine_id = azurerm_windows_virtual_machine.this.id 51 | lun = each.value.lun 52 | caching = each.value.caching 53 | } 54 | 55 | resource "azurerm_role_assignment" "assignments" { 56 | for_each = local.roles 57 | 58 | principal_id = each.value.principal_id 59 | principal_type = each.value.principal_type 60 | role_definition_name = each.value.role_definition_name 61 | scope = each.value.scope 62 | } 63 | 64 | resource "azurerm_virtual_machine_extension" "this" { 65 | name = "${module.naming.virtual_machine_extension.name}-${var.vm_mssql_win_name}-CustomScriptExtension" 66 | virtual_machine_id = azurerm_windows_virtual_machine.this.id 67 | publisher = "Microsoft.Compute" 68 | type = "CustomScriptExtension" 69 | type_handler_version = "1.10" 70 | auto_upgrade_minor_version = true 71 | depends_on = [ 72 | azurerm_virtual_machine_data_disk_attachment.attachments, 73 | time_sleep.wait_for_roles, 74 | azurerm_storage_blob.remote_scripts 75 | ] 76 | 77 | settings = jsonencode({ 78 | fileUris = [ 79 | for script_key, script in local.remote_scripts : "${var.storage_blob_endpoint}${var.storage_container_name}/${script.name}" 80 | ] 81 | }) 82 | 83 | protected_settings = jsonencode({ 84 | commandToExecute = "powershell.exe -ExecutionPolicy Unrestricted -Command \"$params = @{ ${join(" ", local.remote_scripts["orchestrator"].parameters)}}; .\\${local.remote_scripts["orchestrator"].name} @params\"" 85 | managedIdentity = {} 86 | }) 87 | } 88 | 89 | resource "time_sleep" "wait_for_roles" { 90 | create_duration = "2m" 91 | depends_on = [ 92 | azurerm_role_assignment.assignments 93 | ] 94 | } 95 | -------------------------------------------------------------------------------- /modules/vm-mssql-win/locals.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | local_scripts = { 3 | provisioner = { 4 | name = "Register-DscNode.ps1" 5 | parameters = [ 6 | "TenantId = '${data.azurerm_client_config.current.tenant_id}';", 7 | "SubscriptionId = '${data.azurerm_client_config.current.subscription_id}';", 8 | "ResourceGroupName = '${var.resource_group_name}';", 9 | "Location = '${var.location}';", 10 | "AutomationAccountName = '${var.automation_account_name}';", 11 | "VirtualMachineName = '${var.vm_mssql_win_name}';", 12 | "AppId = '${data.azurerm_client_config.current.client_id}';", 13 | "AppSecret = '${data.azurerm_key_vault_secret.arm_client_secret.value}';", 14 | "DscConfigurationName = 'MssqlVmConfiguration'" 15 | ] 16 | } 17 | } 18 | 19 | remote_scripts = { 20 | orchestrator = { 21 | name = "Invoke-MssqlConfiguration.ps1" 22 | parameters = [ 23 | "TenantId = '${data.azurerm_client_config.current.tenant_id}';", 24 | "SubscriptionId = '${data.azurerm_client_config.current.subscription_id}';", 25 | "AppId = '${data.azurerm_client_config.current.client_id}';", 26 | "ResourceGroupName = '${var.resource_group_name}';", 27 | "KeyVaultName = '${var.key_vault_name}';", 28 | "Domain = '${var.adds_domain_name}';", 29 | "AdminUsernameSecret = '${var.admin_username_secret}';", 30 | "AdminPwdSecret = '${var.admin_password_secret}';", 31 | "TempDiskSizeMb = '${var.temp_disk_size_mb}'" 32 | ] 33 | } 34 | 35 | 36 | startup = { 37 | name = "Set-MssqlStartupConfiguration.ps1" 38 | parameters = null 39 | } 40 | 41 | worker = { 42 | name = "Set-MssqlConfiguration.ps1" 43 | parameters = null 44 | } 45 | } 46 | 47 | disks = { 48 | sqldata = { 49 | name = "vol_sqldata_M", 50 | disk_size_gb = "128", 51 | lun = "0", 52 | caching = "ReadOnly" 53 | }, 54 | sqllog = { 55 | name = "vol_sqllog_L", 56 | disk_size_gb = "32", 57 | lun = "1", 58 | caching = "None" 59 | } 60 | } 61 | 62 | roles = { 63 | kv_secrets_user_vm_mssql_win = { 64 | principal_id = azurerm_windows_virtual_machine.this.identity[0].principal_id 65 | principal_type = "ServicePrincipal" 66 | role_definition_name = "Key Vault Secrets User" 67 | scope = var.key_vault_id 68 | } 69 | st_blob_reader_vm_mssql_win = { 70 | principal_id = azurerm_windows_virtual_machine.this.identity[0].principal_id 71 | principal_type = "ServicePrincipal" 72 | role_definition_name = "Storage Blob Data Reader" 73 | scope = var.storage_account_id 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /modules/vm-mssql-win/main.tf: -------------------------------------------------------------------------------- 1 | #region data 2 | data "azurerm_client_config" "current" {} 3 | 4 | data "azurerm_key_vault_secret" "adminpassword" { 5 | name = var.admin_password_secret 6 | key_vault_id = var.key_vault_id 7 | } 8 | 9 | data "azurerm_key_vault_secret" "adminuser" { 10 | name = var.admin_username_secret 11 | key_vault_id = var.key_vault_id 12 | } 13 | 14 | data "azurerm_key_vault_secret" "arm_client_secret" { 15 | name = data.azurerm_client_config.current.client_id 16 | key_vault_id = var.key_vault_id 17 | } 18 | #endregion 19 | 20 | #region modules 21 | module "naming" { 22 | source = "Azure/naming/azurerm" 23 | version = "~> 0.4.2" 24 | suffix = [var.tags["project"], var.tags["environment"]] 25 | } 26 | #endregion 27 | -------------------------------------------------------------------------------- /modules/vm-mssql-win/network.tf: -------------------------------------------------------------------------------- 1 | resource "azurerm_network_interface" "this" { 2 | name = "${module.naming.network_interface.name}-${var.vm_mssql_win_name}" 3 | location = var.location 4 | resource_group_name = var.resource_group_name 5 | tags = var.tags 6 | 7 | ip_configuration { 8 | name = "Primary" 9 | subnet_id = var.subnet_id 10 | private_ip_address_allocation = "Dynamic" 11 | } 12 | } 13 | 14 | -------------------------------------------------------------------------------- /modules/vm-mssql-win/outputs.tf: -------------------------------------------------------------------------------- 1 | output "resource_ids" { 2 | value = { 3 | virtual_machine_mssqlwin1 = azurerm_windows_virtual_machine.this.id 4 | } 5 | } 6 | 7 | output "resource_names" { 8 | value = { 9 | virtual_machine_mssqlwin1 = azurerm_windows_virtual_machine.this.name 10 | } 11 | } 12 | 13 | -------------------------------------------------------------------------------- /modules/vm-mssql-win/scripts/Invoke-MssqlConfiguration.ps1: -------------------------------------------------------------------------------- 1 | #region parameters 2 | param ( 3 | [Parameter(Mandatory = $true)] 4 | [String]$TenantId, 5 | 6 | [Parameter(Mandatory = $true)] 7 | [String]$SubscriptionId, 8 | 9 | [Parameter(Mandatory = $true)] 10 | [String]$AppId, 11 | 12 | [Parameter(Mandatory = $true)] 13 | [String]$ResourceGroupName, 14 | 15 | [Parameter(Mandatory = $true)] 16 | [string]$KeyVaultName, 17 | 18 | [Parameter(Mandatory = $true)] 19 | [string]$Domain, 20 | 21 | [Parameter(Mandatory = $true)] 22 | [string]$AdminUsernameSecret, 23 | 24 | [Parameter(Mandatory = $true)] 25 | [string]$AdminPwdSecret, 26 | 27 | [Parameter(Mandatory = $true)] 28 | [int]$TempDiskSizeMb 29 | ) 30 | #endregion 31 | 32 | #region constants 33 | $TaskName = 'Set-MssqlConfiguration' 34 | $MaxTaskAttempts = 10 35 | $SCHED_S_TASK_RUNNING = 0x00041301 36 | #endregion 37 | 38 | #region functions 39 | function Write-Log { 40 | param( [string] $msg) 41 | "$(Get-Date -Format FileDateTimeUniversal) : $msg" | Out-File -FilePath $logpath -Append -Force 42 | } 43 | 44 | function Exit-WithError { 45 | param( [string]$msg ) 46 | Write-Log "There was an exception during the process, please review..." 47 | Write-Log $msg 48 | Exit 2 49 | } 50 | #endregion 51 | 52 | #region main 53 | $logpath = $PSCommandPath + '.log' 54 | Write-Log "Running '$PSCommandPath'..." 55 | 56 | # Install Powershell Az module 57 | Write-Log "Installing NuGet package provider..." 58 | Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force 59 | 60 | Write-Log "installing PowerShellGet..." 61 | Install-Module -Name PowerShellGet -MinimumVersion 2.2.4.1 -Scope AllUsers -Force 62 | 63 | Write-Log "Installing PowerShell Az module..." 64 | Install-Module -Name Az -Repository PSGallery -Scope AllUsers -Force 65 | 66 | # Log into Azure 67 | Write-Log "Logging into Azure using managed identity..." 68 | 69 | try { 70 | Connect-AzAccount -Identity 71 | } 72 | catch { 73 | Exit-WithError $_ 74 | } 75 | 76 | # Get Secrets from key vault 77 | Write-Log "Getting secret '$AdminUsernameSecret' from key vault '$KeyVaultName'..." 78 | 79 | try { 80 | $adminUsername = Get-AzKeyVaultSecret -VaultName $KeyVaultName -Name $AdminUsernameSecret -AsPlainText 81 | } 82 | catch { 83 | Exit-WithError $_ 84 | } 85 | 86 | if ([string]::IsNullOrEmpty($adminUsername)) { 87 | Exit-WithError "Secret '$AdminUsernameSecret' not found in key vault '$KeyVaultName'..." 88 | } 89 | 90 | Write-Log "The value of secret '$AdminUsernameSecret' is '$adminUsername'..." 91 | 92 | Write-Log "Getting secret '$AdminPwdSecret' from key vault '$KeyVaultName'..." 93 | 94 | try { 95 | $adminPwd = Get-AzKeyVaultSecret -VaultName $KeyVaultName -Name $AdminPwdSecret -AsPlainText 96 | } 97 | catch { 98 | Exit-WithError $_ 99 | } 100 | 101 | if ([string]::IsNullOrEmpty($adminPwd)) { 102 | Exit-WithError "Secret '$AdminPwdSecret' not found in key vault '$KeyVaultName'..." 103 | } 104 | 105 | Write-Log "The length of secret '$AdminPwdSecret' is '$($adminPwd.Length)'..." 106 | 107 | # Disconnect from Azure 108 | Disconnect-AzAccount 109 | 110 | # Register scheduled task to configure SQL Server 111 | $scriptPath = "$((Get-Item $PSCommandPath).DirectoryName)\$TaskName.ps1" 112 | $domainAdminUser = "$($Domain.Split('.')[0].ToUpper())\$adminUsername" 113 | 114 | if ( -not (Test-Path $scriptPath) ) { 115 | Exit-WithError "Unable to locate '$scriptPath'..." 116 | } 117 | 118 | Write-Log "Registering scheduled task '$TaskName' to run '$scriptPath' as '$domainAdminUser'..." 119 | 120 | $commandParamParts = @( 121 | '$params = @{', 122 | "KeyVaultName = '$KeyVaultName'; ", 123 | "DomainAdminUser = '$domainAdminUser'; ", 124 | "AdminPwdSecret = '$AdminPwdSecret'; ", 125 | "TempDiskSizeMb = '$TempDiskSizeMb'", 126 | '}' 127 | ) 128 | 129 | $taskAction = New-ScheduledTaskAction ` 130 | -Execute 'powershell.exe' ` 131 | -Argument "-ExecutionPolicy Unrestricted -Command `"$($commandParamParts -join ''); . $scriptPath @params`"" 132 | 133 | try { 134 | Register-ScheduledTask ` 135 | -Force ` 136 | -Password $adminPwd ` 137 | -User $domainAdminUser ` 138 | -TaskName $TaskName ` 139 | -Action $taskAction ` 140 | -RunLevel 'Highest' ` 141 | -Description "Configure SQL Server." ` 142 | -ErrorAction Stop 143 | } 144 | catch { 145 | Exit-WithError $_ 146 | } 147 | 148 | Write-Log "Starting scheduled task '$TaskName'..." 149 | 150 | try { 151 | Start-ScheduledTask -TaskName $TaskName -ErrorAction Stop 152 | } 153 | catch { 154 | Exit-WithError $_ 155 | } 156 | 157 | $i = 0 158 | do { 159 | $i++ 160 | 161 | Write-Log "Getting information for scheduled task '$TaskName' (attempt '$i' of '$MaxTaskAttempts')..." 162 | 163 | try { 164 | $taskInfo = Get-ScheduledTaskInfo -TaskName $TaskName 165 | } 166 | catch { 167 | Exit-WithError $_ 168 | } 169 | 170 | # Note: LastTaskResult values are documented here: https://docs.microsoft.com/en-us/windows/win32/taskschd/task-scheduler-error-and-success-constants 171 | $lastTaskResult = $taskInfo.LastTaskResult 172 | 173 | Write-Log "LastTaskResult for task '$TaskName' is '$lastTaskResult'..." 174 | 175 | if ($lastTaskResult -eq 0) { 176 | break 177 | } 178 | 179 | if ($lastTaskResult -eq $SCHED_S_TASK_RUNNING) { 180 | Start-Sleep 10 181 | continue 182 | } 183 | 184 | if ($i -eq $MaxTaskAttempts) { 185 | Exit-WithError "Task '$taskName' is taking too long to complete..." 186 | } 187 | 188 | Exit-WithError "Scheduled task '$taskName' returned unexpected LastTaskResult '$lastTaskResult'..." 189 | } while ($true) 190 | 191 | Write-Log "Unregistering scheduled task '$TaskName'..." 192 | 193 | try { 194 | Unregister-ScheduledTask ` 195 | -TaskName $TaskName ` 196 | -Confirm:$false ` 197 | -ErrorAction Stop 198 | } 199 | catch { 200 | Exit-WithError $_ 201 | } 202 | 203 | Write-Log "'$PSCommandPath' exiting normally..." 204 | Exit 0 205 | #endregion 206 | -------------------------------------------------------------------------------- /modules/vm-mssql-win/scripts/MssqlVmConfiguration.ps1: -------------------------------------------------------------------------------- 1 | configuration MssqlVmConfiguration { 2 | param ( 3 | [Parameter(Mandatory = $true)] 4 | [String]$ComputerName 5 | ) 6 | 7 | Import-DscResource -ModuleName 'PSDscResources' 8 | Import-DscResource -ModuleName 'ComputerManagementDsc' 9 | Import-DscResource -ModuleName 'NetworkingDsc' 10 | Import-DscResource -ModuleName 'SqlServerDsc' 11 | 12 | $domain = Get-AutomationVariable -Name 'adds_domain_name' 13 | $localAdminCredential = Get-AutomationPSCredential 'bootstrapadmin' 14 | $domainAdminCredential = Get-AutomationPSCredential 'domainadmin' 15 | $domainAdminShortCredential = Get-AutomationPSCredential 'domainadminshort' 16 | 17 | node $ComputerName { 18 | Computer JoinDomain { 19 | Name = $ComputerName 20 | DomainName = $domain 21 | Credential = $domainAdminCredential 22 | } 23 | 24 | Firewall MssqlFirewallRule { 25 | Name = 'MssqlFirewallRule' 26 | DisplayName = 'Microsoft SQL Server database engine.' 27 | Ensure = 'Present' 28 | Enabled = 'True' 29 | Profile = ('Domain', 'Private') 30 | Direction = 'InBound' 31 | LocalPort = ('1433') 32 | Protocol = 'TCP' 33 | DependsOn = '[Computer]JoinDomain' 34 | } 35 | 36 | SqlLogin DomainAdmin { 37 | Name = $domainAdminShortCredential.UserName 38 | LoginType = 'WindowsUser' 39 | InstanceName = 'MSSQLSERVER' 40 | Ensure = 'Present' 41 | DependsOn = '[Computer]JoinDomain' 42 | PSDscRunAsCredential = $localAdminCredential 43 | } 44 | 45 | SqlRole SysAdminRole { 46 | ServerRoleName = 'sysadmin' 47 | MembersToInclude = $domainAdminShortCredential.UserName 48 | InstanceName = 'MSSQLSERVER' 49 | Ensure = 'Present' 50 | DependsOn = '[SqlLogin]DomainAdmin' 51 | PSDscRunAsCredential = $localAdminCredential 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /modules/vm-mssql-win/scripts/Set-MssqlStartupConfiguration.ps1: -------------------------------------------------------------------------------- 1 | $path = "D:\SQLTEMP" 2 | 3 | if ( -not ( Test-Path $path ) ) { 4 | New-Item -ItemType Directory -Path $path -Force 5 | } 6 | 7 | Start-Service -Name MSSQLSERVER 8 | Start-Service -Name SQLSERVERAGENT 9 | -------------------------------------------------------------------------------- /modules/vm-mssql-win/storage.tf: -------------------------------------------------------------------------------- 1 | #region storage-container 2 | resource "azurerm_storage_blob" "remote_scripts" { 3 | for_each = local.remote_scripts 4 | 5 | name = each.value.name 6 | storage_account_name = var.storage_account_name 7 | storage_container_name = var.storage_container_name 8 | type = "Block" 9 | source = "./${path.module}/scripts/${each.value.name}" 10 | 11 | depends_on = [time_sleep.wait_for_public_access] 12 | } 13 | #endregion 14 | 15 | #region utility-resources 16 | resource "azapi_update_resource" "enable_public_access" { 17 | type = "Microsoft.Storage/storageAccounts@2023-05-01" 18 | resource_id = var.storage_account_id 19 | 20 | body = { properties = { publicNetworkAccess = "Enabled" } } 21 | 22 | lifecycle { ignore_changes = all } 23 | } 24 | 25 | resource "azapi_update_resource" "disable_public_access" { 26 | type = "Microsoft.Storage/storageAccounts@2023-05-01" 27 | resource_id = var.storage_account_id 28 | 29 | depends_on = [azurerm_storage_blob.remote_scripts] 30 | 31 | body = { properties = { publicNetworkAccess = "Disabled" } } 32 | 33 | lifecycle { ignore_changes = all } 34 | } 35 | 36 | resource "time_sleep" "wait_for_public_access" { 37 | create_duration = "2m" 38 | depends_on = [azapi_update_resource.enable_public_access] 39 | } 40 | #endregion 41 | -------------------------------------------------------------------------------- /modules/vm-mssql-win/terraform.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_version = "~> 1.12.1" 3 | 4 | required_providers { 5 | azapi = { 6 | source = "Azure/azapi" 7 | version = "~> 2.4.0" 8 | } 9 | 10 | azurerm = { 11 | source = "hashicorp/azurerm" 12 | version = "~> 4.31.0" 13 | } 14 | 15 | random = { 16 | source = "hashicorp/random" 17 | version = "~> 3.7.2" 18 | } 19 | 20 | 21 | time = { 22 | source = "hashicorp/time" 23 | version = "~> 0.13" 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /modules/vnet-app/compute.tf: -------------------------------------------------------------------------------- 1 | resource "azurerm_windows_virtual_machine" "this" { 2 | name = var.vm_jumpbox_win_name 3 | resource_group_name = var.resource_group_name 4 | location = var.location 5 | size = var.vm_jumpbox_win_size 6 | admin_username = data.azurerm_key_vault_secret.adminuser.value 7 | admin_password = data.azurerm_key_vault_secret.adminpassword.value 8 | network_interface_ids = [azurerm_network_interface.this.id] 9 | patch_assessment_mode = "AutomaticByPlatform" 10 | patch_mode = "AutomaticByPlatform" 11 | provision_vm_agent = true 12 | encryption_at_host_enabled = true 13 | 14 | os_disk { 15 | caching = "ReadWrite" 16 | storage_account_type = var.vm_jumpbox_win_storage_account_type 17 | } 18 | 19 | source_image_reference { 20 | publisher = var.vm_jumpbox_win_image_publisher 21 | offer = var.vm_jumpbox_win_image_offer 22 | sku = var.vm_jumpbox_win_image_sku 23 | version = var.vm_jumpbox_win_image_version 24 | } 25 | 26 | identity { 27 | type = "SystemAssigned" 28 | } 29 | 30 | depends_on = [azurerm_private_dns_zone_virtual_network_link.vnet_app_links] 31 | 32 | provisioner "local-exec" { 33 | command = "$params = @{ ${join(" ", local.local_scripts["provisioner_vm_windows"].parameters)}}; ./${path.module}/scripts/${local.local_scripts["provisioner_vm_windows"].name} @params" 34 | interpreter = ["pwsh", "-Command"] 35 | } 36 | } 37 | 38 | resource "azurerm_role_assignment" "assignments_vm_win" { 39 | for_each = local.vm_win_roles 40 | 41 | principal_id = each.value.principal_id 42 | principal_type = each.value.principal_type 43 | role_definition_name = each.value.role_definition_name 44 | scope = each.value.scope 45 | } 46 | 47 | resource "azurerm_virtual_machine_extension" "this" { 48 | name = "${module.naming.virtual_machine_extension.name}-${var.vm_jumpbox_win_name}-CustomScriptExtension" 49 | virtual_machine_id = azurerm_windows_virtual_machine.this.id 50 | publisher = "Microsoft.Compute" 51 | type = "CustomScriptExtension" 52 | type_handler_version = "1.10" 53 | auto_upgrade_minor_version = true 54 | depends_on = [time_sleep.wait_for_vm_win_roles] 55 | 56 | settings = jsonencode({ 57 | fileUris = [ 58 | for script_key, script in local.remote_scripts : "${azurerm_storage_account.this.primary_blob_endpoint}${azurerm_storage_container.this.name}/${script.name}" 59 | ] 60 | }) 61 | 62 | protected_settings = jsonencode({ 63 | commandToExecute = "powershell.exe -ExecutionPolicy Unrestricted -Command \"$params = @{ ${join(" ", local.remote_scripts["orchestrator"].parameters)}}; .\\${local.remote_scripts["orchestrator"].name} @params\"" 64 | managedIdentity = {} 65 | }) 66 | } 67 | 68 | resource "time_sleep" "wait_for_vm_win_roles" { 69 | create_duration = "2m" 70 | depends_on = [azurerm_role_assignment.assignments_vm_win] 71 | } 72 | -------------------------------------------------------------------------------- /modules/vnet-app/main.tf: -------------------------------------------------------------------------------- 1 | #region data 2 | data "azurerm_client_config" "current" {} 3 | 4 | data "azurerm_key_vault_secret" "adminpassword" { 5 | name = var.admin_password_secret 6 | key_vault_id = var.key_vault_id 7 | } 8 | 9 | data "azurerm_key_vault_secret" "adminuser" { 10 | name = var.admin_username_secret 11 | key_vault_id = var.key_vault_id 12 | } 13 | 14 | data "azurerm_key_vault_secret" "arm_client_secret" { 15 | name = data.azurerm_client_config.current.client_id 16 | key_vault_id = var.key_vault_id 17 | } 18 | #endregion 19 | 20 | #region modules 21 | module "naming" { 22 | source = "Azure/naming/azurerm" 23 | version = "~> 0.4.2" 24 | suffix = [var.tags["project"], var.tags["environment"]] 25 | unique-seed = var.unique_seed 26 | unique-include-numbers = true 27 | unique-length = 8 28 | } 29 | #endregion 30 | -------------------------------------------------------------------------------- /modules/vnet-app/outputs.tf: -------------------------------------------------------------------------------- 1 | output "azure_files_config_vm_extension_id" { 2 | value = azurerm_virtual_machine_extension.this.id 3 | description = "Dependent modules can reference this output to determine if Azure Files configuration is complete." 4 | } 5 | 6 | output "private_dns_zones" { 7 | value = azurerm_private_dns_zone.zones 8 | } 9 | 10 | output "resource_ids" { 11 | value = { 12 | storage_account = azurerm_storage_account.this.id 13 | virtual_machine_jumpwin1 = azurerm_windows_virtual_machine.this.id 14 | virtual_network_app = azurerm_virtual_network.this.id 15 | } 16 | } 17 | 18 | output "resource_names" { 19 | value = { 20 | storage_account = azurerm_storage_account.this.name 21 | virtual_machine_jumpwin1 = azurerm_windows_virtual_machine.this.name 22 | virtual_network_app = azurerm_virtual_network.this.name 23 | } 24 | } 25 | 26 | output "storage_container_name" { 27 | value = azurerm_storage_container.this.name 28 | } 29 | 30 | output "storage_endpoints" { 31 | value = { 32 | blob = azurerm_storage_account.this.primary_blob_endpoint 33 | file = azurerm_storage_account.this.primary_file_endpoint 34 | } 35 | } 36 | 37 | output "storage_share_name" { 38 | value = azurerm_storage_share.this.name 39 | } 40 | 41 | output "subnets" { 42 | value = azurerm_subnet.subnets 43 | } 44 | -------------------------------------------------------------------------------- /modules/vnet-app/scripts/Invoke-AzureFilesConfiguration.ps1: -------------------------------------------------------------------------------- 1 | #region parameters 2 | param ( 3 | [Parameter(Mandatory = $true)] 4 | [String]$TenantId, 5 | 6 | [Parameter(Mandatory = $true)] 7 | [String]$SubscriptionId, 8 | 9 | [Parameter(Mandatory = $true)] 10 | [String]$AppId, 11 | 12 | [Parameter(Mandatory = $true)] 13 | [String]$ResourceGroupName, 14 | 15 | [Parameter(Mandatory = $true)] 16 | [string]$KeyVaultName, 17 | 18 | [Parameter(Mandatory = $true)] 19 | [string]$StorageAccountName, 20 | 21 | [Parameter(Mandatory = $true)] 22 | [string]$Domain, 23 | 24 | [Parameter(Mandatory = $true)] 25 | [string]$AdminUsernameSecret, 26 | 27 | [Parameter(Mandatory = $true)] 28 | [string]$AdminPwdSecret 29 | ) 30 | #endregion 31 | 32 | #region constants 33 | $TaskName = 'Set-AzureFilesConfiguration' 34 | $MaxTaskAttempts = 10 35 | $SCHED_S_TASK_RUNNING = 0x00041301 36 | $ERROR_SHUTDOWN_IN_PROGRESS = 0x8007045B 37 | #endregion 38 | 39 | #region functions 40 | function Write-Log { 41 | param( [string] $msg) 42 | "$(Get-Date -Format FileDateTimeUniversal) : $msg" | Out-File -FilePath $logpath -Append -Force 43 | } 44 | 45 | function Exit-WithError { 46 | param( [string]$msg ) 47 | Write-Log "There was an exception during the process, please review..." 48 | Write-Log $msg 49 | Exit 2 50 | } 51 | #endregion 52 | 53 | #region main 54 | $logpath = $PSCommandPath + '.log' 55 | Write-Log "Running '$PSCommandPath'..." 56 | 57 | # Log into Azure with retry logic 58 | Write-Log "Logging into Azure using managed identity..." 59 | 60 | $maxRetries = 40 61 | $retryCount = 0 62 | $success = $false 63 | 64 | while (-not $success -and $retryCount -lt $maxRetries) { 65 | try { 66 | Connect-AzAccount -Identity -ErrorAction Stop 67 | $success = $true 68 | Write-Log "Successfully logged into Azure." 69 | } 70 | catch { 71 | $retryCount++ 72 | Write-Log "Failed to log into Azure. Attempt $retryCount of $maxRetries. Retrying in 1 minute..." 73 | if ($retryCount -ge $maxRetries) { 74 | Exit-WithError "Failed to log into Azure after $maxRetries attempts." 75 | } 76 | Start-Sleep -Seconds 60 77 | } 78 | } 79 | 80 | # Get Secrets from key vault 81 | Write-Log "Getting secret '$AdminUsernameSecret' from key vault '$KeyVaultName'..." 82 | 83 | try { 84 | $adminUsername = Get-AzKeyVaultSecret -VaultName $KeyVaultName -Name $AdminUsernameSecret -AsPlainText 85 | } 86 | catch { 87 | Exit-WithError $_ 88 | } 89 | 90 | if ([string]::IsNullOrEmpty($adminUsername)) { 91 | Exit-WithError "Secret '$AdminUsernameSecret' not found in key vault '$KeyVaultName'..." 92 | } 93 | 94 | Write-Log "The value of secret '$AdminUsernameSecret' is '$adminUsername'..." 95 | 96 | Write-Log "Getting secret '$AdminPwdSecret' from key vault '$KeyVaultName'..." 97 | 98 | try { 99 | $adminPwd = Get-AzKeyVaultSecret -VaultName $KeyVaultName -Name $AdminPwdSecret -AsPlainText 100 | } 101 | catch { 102 | Exit-WithError $_ 103 | } 104 | 105 | if ([string]::IsNullOrEmpty($adminPwd)) { 106 | Exit-WithError "Secret '$AdminPwdSecret' not found in key vault '$KeyVaultName'..." 107 | } 108 | 109 | Write-Log "The length of secret '$AdminPwdSecret' is '$($adminPwd.Length)'..." 110 | 111 | # Disconnect from Azure 112 | Disconnect-AzAccount 113 | 114 | # Register scheduled task to configure Azure Storage for kerberos authentication with domain 115 | $scriptPath = "$((Get-Item $PSCommandPath).DirectoryName)\$TaskName.ps1" 116 | $domainAdminUser = "$($Domain.Split('.')[0].ToUpper())\$adminUsername" 117 | 118 | if ( -not (Test-Path $scriptPath) ) { 119 | Exit-WithError "Unable to locate '$scriptPath'..." 120 | } 121 | 122 | Write-Log "Registering scheduled task '$TaskName' to run '$scriptPath' as '$domainAdminUser'..." 123 | 124 | $commandParamParts = @( 125 | '$params = @{', 126 | "TenantId = '$TenantId'; ", 127 | "SubscriptionId = '$SubscriptionId'; ", 128 | "AppId = '$AppId'; ", 129 | "ResourceGroupName = '$ResourceGroupName'; ", 130 | "KeyVaultName = '$KeyVaultName'; ", 131 | "StorageAccountName = '$StorageAccountName'; ", 132 | "Domain = '$Domain'", 133 | '}' 134 | ) 135 | 136 | $taskAction = New-ScheduledTaskAction ` 137 | -Execute 'powershell.exe' ` 138 | -Argument "-ExecutionPolicy Unrestricted -Command `"$($commandParamParts -join ''); . $scriptPath @params`"" 139 | 140 | try { 141 | Register-ScheduledTask ` 142 | -Force ` 143 | -Password $adminPwd ` 144 | -User $domainAdminUser ` 145 | -TaskName $TaskName ` 146 | -Action $taskAction ` 147 | -RunLevel 'Highest' ` 148 | -Description "Configure Azure Files for kerberos authentication with domain." ` 149 | -ErrorAction Stop 150 | } 151 | catch { 152 | Exit-WithError $_ 153 | } 154 | 155 | Write-Log "Starting scheduled task '$TaskName'..." 156 | 157 | try { 158 | Start-ScheduledTask -TaskName $TaskName -ErrorAction Stop 159 | } 160 | catch { 161 | Exit-WithError $_ 162 | } 163 | 164 | $i = 0 165 | do { 166 | $i++ 167 | 168 | Write-Log "Getting information for scheduled task '$TaskName' (attempt '$i' of '$MaxTaskAttempts')..." 169 | 170 | try { 171 | $taskInfo = Get-ScheduledTaskInfo -TaskName $TaskName 172 | } 173 | catch { 174 | Exit-WithError $_ 175 | } 176 | 177 | # Note: LastTaskResult values are documented here: https://docs.microsoft.com/en-us/windows/win32/taskschd/task-scheduler-error-and-success-constants 178 | $lastTaskResult = $taskInfo.LastTaskResult 179 | 180 | Write-Log "LastTaskResult for task '$TaskName' is '$lastTaskResult'..." 181 | 182 | if ($lastTaskResult -eq 0) { 183 | break 184 | } 185 | 186 | if ($lastTaskResult -eq $SCHED_S_TASK_RUNNING) { 187 | Start-Sleep 10 188 | continue 189 | } 190 | 191 | if ($lastTaskResult -eq $ERROR_SHUTDOWN_IN_PROGRESS) { 192 | Exit-WithError "Task '$taskName' cannot be started because the system is shutting down..." 193 | } 194 | 195 | if ($i -eq $MaxTaskAttempts) { 196 | Exit-WithError "Task '$taskName' is taking too long to complete..." 197 | } 198 | 199 | Exit-WithError "Scheduled task '$taskName' returned unexpected LastTaskResult '$lastTaskResult'..." 200 | } while ($true) 201 | 202 | Write-Log "Unregistering scheduled task '$TaskName'..." 203 | 204 | try { 205 | Unregister-ScheduledTask ` 206 | -TaskName $TaskName ` 207 | -Confirm:$false ` 208 | -ErrorAction Stop 209 | } 210 | catch { 211 | Exit-WithError $_ 212 | } 213 | 214 | Write-Log "'$PSCommandPath' exiting normally..." 215 | Exit 0 216 | #endregion 217 | -------------------------------------------------------------------------------- /modules/vnet-app/scripts/JumpBoxConfiguration.ps1: -------------------------------------------------------------------------------- 1 | configuration JumpBoxConfiguration { 2 | param ( 3 | [Parameter(Mandatory = $true)] 4 | [String]$ComputerName 5 | ) 6 | 7 | Import-DscResource -ModuleName 'PSDscResources' 8 | Import-DscResource -ModuleName 'ComputerManagementDsc' 9 | Import-DscResource -ModuleName 'cChoco' 10 | 11 | 12 | $domain = Get-AutomationVariable -Name 'adds_domain_name' 13 | $domainAdminCredential = Get-AutomationPSCredential 'domainadmin' 14 | 15 | node $ComputerName { 16 | WindowsFeature RsatAdds { 17 | Name = 'RSAT-ADDS' 18 | Ensure = 'Present' 19 | } 20 | 21 | WindowsFeature RsatDns { 22 | Name = 'RSAT-DNS-Server' 23 | Ensure = 'Present' 24 | } 25 | 26 | cChocoInstaller InstallChoco { 27 | InstallDir = 'c:\choco' 28 | } 29 | 30 | cChocoPackageInstallerSet JumpboxSoftware { 31 | Ensure = 'Present' 32 | Name = @( 33 | "az.powershell" 34 | "mysql.workbench" 35 | "sql-server-management-studio" 36 | "vscode" 37 | ) 38 | DependsOn = '[cChocoInstaller]InstallChoco' 39 | } 40 | 41 | # Custom Script to Wait for Software Installation 42 | Script WaitForSoftware { 43 | GetScript = { 44 | @{ 45 | Result = (Get-Module -Name Az -ListAvailable | Where-Object { $_.Name -eq 'Az' }) 46 | } 47 | } 48 | TestScript = { 49 | (Get-Module -Name Az -ListAvailable | Where-Object { $_.Name -eq 'Az' }) -ne $null 50 | } 51 | SetScript = { 52 | Write-Verbose "Waiting for the Az PowerShell module to be installed on Windows PowerShell..." 53 | } 54 | DependsOn = "[cChocoPackageInstallerSet]JumpboxSoftware" 55 | } 56 | 57 | Computer JoinDomain { 58 | Name = $ComputerName 59 | DomainName = $domain 60 | Credential = $domainAdminCredential 61 | DependsOn = '[Script]WaitForSoftware' 62 | } 63 | 64 | # Force a reboot if required 65 | PendingReboot RebootAfterDomainJoin { 66 | Name = 'DomainJoin' 67 | DependsOn = '[Computer]JoinDomain' 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /modules/vnet-app/scripts/Set-AzureFilesConfiguration.ps1: -------------------------------------------------------------------------------- 1 | #region parameters 2 | param( 3 | [Parameter(Mandatory = $true)] 4 | [string]$TenantId, 5 | 6 | [Parameter(Mandatory = $true)] 7 | [string]$SubscriptionId, 8 | 9 | [Parameter(Mandatory = $true)] 10 | [String]$AppId, 11 | 12 | [Parameter(Mandatory = $true)] 13 | [string]$ResourceGroupName, 14 | 15 | [Parameter(Mandatory = $true)] 16 | [string]$KeyVaultName, 17 | 18 | [Parameter(Mandatory = $true)] 19 | [string]$StorageAccountName, 20 | 21 | [Parameter(Mandatory = $true)] 22 | [string]$Domain 23 | ) 24 | #endregion 25 | 26 | #region constants 27 | $defaultPermission = "StorageFileDataSmbShareContributor" 28 | $logpath = $PSCommandPath + '.log' 29 | #endregion 30 | 31 | #region functions 32 | function Write-Log { 33 | param( [string] $msg) 34 | "$(Get-Date -Format FileDateTimeUniversal) : $msg" | Out-File -FilePath $logpath -Append -Force 35 | } 36 | 37 | function Exit-WithError { 38 | param( [string]$msg ) 39 | Write-Log "There was an exception during the process, please review..." 40 | Write-Log $msg 41 | Exit 2 42 | } 43 | #endregion 44 | 45 | #region main 46 | Write-Log "Running '$PSCommandPath'..." 47 | 48 | Write-Log "Setting execution policy to 'RemoteSigned'..." 49 | Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser -Force 50 | 51 | # Retrieve secrets from key vault using managed identity 52 | Write-Log "Logging into Azure using managed identity..." 53 | 54 | try { 55 | Connect-AzAccount -Identity 56 | } 57 | catch { 58 | Exit-WithError $_ 59 | } 60 | 61 | Write-Log "Getting secret '$AppId' from key vault '$KeyVaultName'..." 62 | 63 | try { 64 | $appSecret = Get-AzKeyVaultSecret -VaultName $KeyVaultName -Name $AppId -AsPlainText 65 | } 66 | catch { 67 | Exit-WithError $_ 68 | } 69 | 70 | if ([string]::IsNullOrEmpty($appSecret)) { 71 | Exit-WithError "Secret '$AppId' not found in key vault '$KeyVaultName'..." 72 | } 73 | 74 | Write-Log "The length of secret '$AppId' is '$($appSecret.Length)'..." 75 | 76 | Disconnect-AzAccount 77 | 78 | # Configure identity-based access for storage account using service principal (not managed identity) 79 | $xDot500Path = "DC=$($Domain.Split('.')[0]),DC=$($Domain.Split('.')[1])" 80 | $spnValue = "cifs/$StorageAccountName.file.core.windows.net" 81 | 82 | Write-Log "Checking for existing computer account for storage account '$StorageAccountName' in domain '$Domain'..." 83 | 84 | $computer = Get-ADComputer -Identity $StorageAccountName 85 | 86 | if ($null -eq $computer) { 87 | Write-Log "Existing computer account for storage account '$StorageAccountName' in domain '$Domain' not found..." 88 | } 89 | else { 90 | Write-Log "Deleting existing computer account for storage account '$StorageAccountName' in domain '$Domain'..." 91 | 92 | try { 93 | Remove-ADComputer -Identity $StorageAccountName -Confirm:$false -ErrorAction Stop 94 | } 95 | catch { 96 | Exit-WithError $_ 97 | } 98 | } 99 | 100 | Write-Log "Logging into Azure using service principal id '$AppId'..." 101 | 102 | $appSecretSecure = ConvertTo-SecureString $appSecret -AsPlainText -Force 103 | $spCredential = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList $AppId, $appSecretSecure 104 | 105 | try { 106 | Connect-AzAccount -Credential $spCredential -Tenant $TenantId -ServicePrincipal -ErrorAction Stop | Out-Null 107 | } 108 | catch { 109 | Exit-WithError $_ 110 | } 111 | 112 | Write-Log "Setting default subscription to '$SubscriptionId'..." 113 | 114 | try { 115 | Set-AzContext -Subscription $SubscriptionId | Out-Null 116 | } 117 | catch { 118 | Exit-WithError $_ 119 | } 120 | 121 | Write-Log "Creating 'kerb1' key for storage account '$StorageAccountName' in resource group '$ResourceGroupName'..." 122 | 123 | try { 124 | New-AzStorageAccountKey -ResourceGroupName $ResourceGroupName -Name $StorageAccountName -KeyName "kerb1" | Out-Null 125 | } 126 | catch { 127 | Exit-WithError $_ 128 | } 129 | 130 | Write-Log "Getting 'kerb1' key for storage account '$StorageAccountName' in resource group '$ResourceGroupName'..." 131 | 132 | $storageAccountKerbKey = Get-AzStorageAccountKey -ListKerbKey -ResourceGroupName $ResourceGroupName -Name $StorageAccountName | Where-Object { $_.KeyName -eq "kerb1" } | Select-Object -ExpandProperty Value 133 | 134 | if ([string]::IsNullOrEmpty($storageAccountKerbKey)) { 135 | Exit-WithError "Key 'kerb1' not found for storage account '$StorageAccountName' in resource group '$ResourceGroupName'..." 136 | } 137 | 138 | Write-Log "The length of key 'kerb1' for storage account '$StorageAccountName' is '$($storageAccountKerbKey.Length)'..." 139 | 140 | Write-Log "Adding computer account for storage account '$StorageAccountName' to domain '$Domain'..." 141 | $storageAccountKerbKeySecure = ConvertTo-SecureString $storageAccountKerbKey -AsPlainText -Force 142 | 143 | try { 144 | New-ADComputer ` 145 | -SAMAccountName $StorageAccountName ` 146 | -Path $xDot500Path ` 147 | -Name $StorageAccountName ` 148 | -AccountPassword $storageAccountKerbKeySecure ` 149 | -AllowReversiblePasswordEncryption $false ` 150 | -Description "Computer account object for Azure storage account '$StorageAccountName'." ` 151 | -ServicePrincipalNames $spnValue ` 152 | -Server $Domain ` 153 | -Enabled $true ` 154 | -ErrorAction Stop 155 | } 156 | catch { 157 | Exit-WithError $_ 158 | } 159 | 160 | Write-Log "Retrieving new computer account for storage account '$StorageAccountName'..." 161 | 162 | try { 163 | $computer = Get-ADComputer -Identity $StorageAccountName 164 | } 165 | catch { 166 | Exit-WithError $_ 167 | } 168 | 169 | $azureStorageSid = $computer.SID.Value 170 | $domainInformation = Get-ADDomain -Server $Domain 171 | $domainGuid = $domainInformation.ObjectGUID.ToString() 172 | $domainName = $domainInformation.DNSRoot 173 | $domainSid = $domainInformation.DomainSID.Value 174 | $forestName = $domainInformation.Forest 175 | $netBiosDomainName = $domainInformation.DnsRoot 176 | 177 | Write-Log "Configuring storage account '$StorageAccountName' for Kerberos authentication with domain '$Domain'..." 178 | 179 | try { 180 | Set-AzStorageAccount ` 181 | -ResourceGroupName $ResourceGroupName ` 182 | -AccountName $StorageAccountName ` 183 | -EnableActiveDirectoryDomainServicesForFile $true ` 184 | -ActiveDirectoryDomainName $domainName ` 185 | -ActiveDirectoryNetBiosDomainName $netBiosDomainName ` 186 | -ActiveDirectoryForestName $forestName ` 187 | -ActiveDirectoryDomainGuid $domainGuid ` 188 | -ActiveDirectoryDomainSid $domainSid ` 189 | -ActiveDirectoryAzureStorageSid $azureStorageSid ` 190 | -DefaultSharePermission $defaultPermission ` 191 | -ErrorAction Stop 192 | } 193 | catch { 194 | Exit-WithError $_ 195 | } 196 | 197 | Disconnect-AzAccount 198 | 199 | Write-Log "'$PSCommandPath' exiting normally..." 200 | Exit 0 201 | #endregion 202 | -------------------------------------------------------------------------------- /modules/vnet-app/storage.tf: -------------------------------------------------------------------------------- 1 | #region storage-account 2 | resource "azurerm_storage_account" "this" { 3 | name = module.naming.storage_account.name_unique 4 | resource_group_name = var.resource_group_name 5 | location = var.location 6 | account_kind = "StorageV2" 7 | account_tier = "Standard" 8 | account_replication_type = "LRS" 9 | access_tier = "Hot" 10 | shared_access_key_enabled = false 11 | https_traffic_only_enabled = true 12 | min_tls_version = "TLS1_2" 13 | public_network_access_enabled = false 14 | 15 | lifecycle { 16 | ignore_changes = [ 17 | azure_files_authentication, # Configured separately by ./scripts/Set-AzureFilesConfiguration.ps1 18 | public_network_access_enabled # Avoid triggering recreation of resources in dependent modules if public access is temporarily enabled for terraform plan / apply operations 19 | ] 20 | } 21 | } 22 | 23 | resource "azurerm_role_assignment" "assignments_storage" { 24 | for_each = local.storage_roles 25 | 26 | principal_id = each.value.principal_id 27 | principal_type = each.value.principal_type 28 | role_definition_name = each.value.role_definition_name 29 | scope = azurerm_storage_account.this.id 30 | } 31 | #endregion 32 | 33 | #region storage-container 34 | resource "azurerm_storage_container" "this" { 35 | name = var.storage_container_name 36 | storage_account_id = azurerm_storage_account.this.id 37 | container_access_type = "private" 38 | 39 | depends_on = [time_sleep.wait_for_roles_and_public_access] 40 | } 41 | 42 | resource "azurerm_storage_blob" "remote_scripts" { 43 | for_each = local.remote_scripts 44 | 45 | name = each.value.name 46 | storage_account_name = azurerm_storage_account.this.name 47 | storage_container_name = azurerm_storage_container.this.name 48 | type = "Block" 49 | source = "./${path.module}/scripts/${each.value.name}" 50 | 51 | depends_on = [time_sleep.wait_for_roles_and_public_access] 52 | } 53 | #endregion 54 | 55 | #region storage-share 56 | resource "azurerm_storage_share" "this" { 57 | name = var.storage_share_name 58 | storage_account_id = azurerm_storage_account.this.id 59 | quota = var.storage_share_quota_gb 60 | 61 | depends_on = [time_sleep.wait_for_roles_and_public_access] 62 | } 63 | #endregion 64 | 65 | #region utility-resources 66 | resource "azapi_update_resource" "storage_account_enable_public_access" { 67 | type = "Microsoft.Storage/storageAccounts@2023-05-01" 68 | resource_id = azurerm_storage_account.this.id 69 | 70 | body = { properties = { publicNetworkAccess = "Enabled" } } 71 | 72 | lifecycle { ignore_changes = all } 73 | } 74 | 75 | resource "azapi_update_resource" "storage_account_disable_public_access" { 76 | type = "Microsoft.Storage/storageAccounts@2023-05-01" 77 | resource_id = azurerm_storage_account.this.id 78 | 79 | depends_on = [azurerm_storage_blob.remote_scripts, azurerm_storage_share.this] 80 | 81 | body = { properties = { publicNetworkAccess = "Disabled" } } 82 | 83 | lifecycle { ignore_changes = all } 84 | } 85 | 86 | resource "time_sleep" "wait_for_roles_and_public_access" { 87 | create_duration = "2m" 88 | depends_on = [azurerm_role_assignment.assignments_storage, azapi_update_resource.storage_account_enable_public_access] 89 | } 90 | #endregion 91 | -------------------------------------------------------------------------------- /modules/vnet-app/terraform.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_version = "~> 1.12.1" 3 | 4 | required_providers { 5 | azapi = { 6 | source = "Azure/azapi" 7 | version = "~> 2.4.0" 8 | } 9 | 10 | azurerm = { 11 | source = "hashicorp/azurerm" 12 | version = "~> 4.31.0" 13 | } 14 | 15 | random = { 16 | source = "hashicorp/random" 17 | version = "~> 3.7.2" 18 | } 19 | 20 | time = { 21 | source = "hashicorp/time" 22 | version = "~> 0.13.1" 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /modules/vnet-shared/compute.tf: -------------------------------------------------------------------------------- 1 | resource "azurerm_windows_virtual_machine" "this" { 2 | name = var.vm_adds_name 3 | resource_group_name = var.resource_group_name 4 | location = var.location 5 | size = var.vm_adds_size 6 | admin_username = var.admin_username 7 | admin_password = data.azurerm_key_vault_secret.adminpassword.value 8 | network_interface_ids = [azurerm_network_interface.this.id] 9 | patch_assessment_mode = "AutomaticByPlatform" 10 | patch_mode = "AutomaticByPlatform" 11 | provision_vm_agent = true 12 | encryption_at_host_enabled = true 13 | depends_on = [azurerm_automation_account.this] 14 | 15 | os_disk { 16 | caching = "ReadWrite" 17 | storage_account_type = var.vm_adds_storage_account_type 18 | } 19 | 20 | source_image_reference { 21 | publisher = var.vm_adds_image_publisher 22 | offer = var.vm_adds_image_offer 23 | sku = var.vm_adds_image_sku 24 | version = var.vm_adds_image_version 25 | } 26 | 27 | provisioner "local-exec" { 28 | command = "$params = @{ ${join(" ", local.local_scripts["provisioner_vm_windows"].parameters)}}; ./${path.module}/scripts/${local.local_scripts["provisioner_vm_windows"].name} @params" 29 | interpreter = ["pwsh", "-Command"] 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /modules/vnet-shared/main.tf: -------------------------------------------------------------------------------- 1 | #region data 2 | data "azurerm_client_config" "current" {} 3 | 4 | data "azurerm_key_vault_secret" "adminpassword" { 5 | name = azurerm_key_vault_secret.adminpassword.name 6 | key_vault_id = var.key_vault_id 7 | } 8 | 9 | data "azurerm_key_vault_secret" "arm_client_secret" { 10 | name = data.azurerm_client_config.current.client_id 11 | key_vault_id = var.key_vault_id 12 | } 13 | #endregion 14 | 15 | #region resources 16 | resource "random_string" "adminpassword_first_char" { 17 | length = 1 18 | upper = true 19 | lower = true 20 | numeric = false 21 | special = false 22 | } 23 | 24 | resource "random_password" "adminpassword_middle_chars" { 25 | length = 14 26 | special = true 27 | min_special = 1 28 | upper = true 29 | min_upper = 1 30 | lower = true 31 | min_lower = 1 32 | numeric = true 33 | min_numeric = 1 34 | override_special = ".+-=" 35 | } 36 | 37 | resource "random_string" "adminpassword_last_char" { 38 | length = 1 39 | upper = true 40 | lower = true 41 | numeric = false 42 | special = false 43 | } 44 | 45 | resource "azurerm_key_vault_secret" "adminpassword" { 46 | name = var.admin_password_secret 47 | value = "${random_string.adminpassword_first_char.result}${random_password.adminpassword_middle_chars.result}${random_string.adminpassword_last_char.result}" 48 | key_vault_id = var.key_vault_id 49 | expiration_date = timeadd(timestamp(), "8760h") 50 | 51 | lifecycle { 52 | ignore_changes = [expiration_date] 53 | } 54 | } 55 | 56 | resource "azurerm_key_vault_secret" "adminusername" { 57 | name = var.admin_username_secret 58 | value = var.admin_username 59 | key_vault_id = var.key_vault_id 60 | expiration_date = timeadd(timestamp(), "8760h") 61 | 62 | lifecycle { 63 | ignore_changes = [expiration_date] 64 | } 65 | } 66 | 67 | resource "azurerm_automation_account" "this" { 68 | name = module.naming.automation_account.name 69 | location = var.location 70 | resource_group_name = var.resource_group_name 71 | sku_name = "Basic" 72 | 73 | provisioner "local-exec" { 74 | command = "$params = @{ ${join(" ", local.local_scripts["provisioner_automation_account"].parameters)}}; ./${path.module}/scripts/${local.local_scripts["provisioner_automation_account"].name} @params" 75 | interpreter = ["pwsh", "-Command"] 76 | } 77 | } 78 | #endregion 79 | 80 | #region modules 81 | module "naming" { 82 | source = "Azure/naming/azurerm" 83 | version = "~> 0.4.2" 84 | suffix = [var.tags["project"], var.tags["environment"]] 85 | } 86 | #endregion 87 | -------------------------------------------------------------------------------- /modules/vnet-shared/network.tf: -------------------------------------------------------------------------------- 1 | #region virtual-network 2 | resource "azurerm_virtual_network" "this" { 3 | name = "${module.naming.virtual_network.name}-${var.vnet_name}" 4 | location = var.location 5 | resource_group_name = var.resource_group_name 6 | address_space = [var.vnet_address_space] 7 | dns_servers = [cidrhost(var.subnet_adds_address_prefix, 4), "168.63.129.16"] 8 | } 9 | 10 | resource "azurerm_subnet" "subnets" { 11 | for_each = local.subnets 12 | name = each.key 13 | resource_group_name = var.resource_group_name 14 | virtual_network_name = azurerm_virtual_network.this.name 15 | address_prefixes = [each.value.address_prefix] 16 | private_endpoint_network_policies = each.value.private_endpoint_network_policies 17 | default_outbound_access_enabled = false 18 | 19 | lifecycle { 20 | ignore_changes = [delegation] 21 | } 22 | } 23 | 24 | resource "azurerm_network_security_group" "groups" { 25 | for_each = { for k, v in local.subnets : k => v if length(v.nsg_rules) > 0 } 26 | 27 | name = "${module.naming.network_security_group.name}.${each.key}" 28 | location = var.location 29 | resource_group_name = var.resource_group_name 30 | } 31 | 32 | resource "azurerm_subnet_network_security_group_association" "associations" { 33 | for_each = azurerm_network_security_group.groups 34 | 35 | subnet_id = azurerm_subnet.subnets[each.key].id 36 | network_security_group_id = azurerm_network_security_group.groups[each.key].id 37 | 38 | depends_on = [ 39 | azurerm_network_security_rule.rules, 40 | azurerm_bastion_host.this 41 | ] 42 | } 43 | 44 | resource "azurerm_network_security_rule" "rules" { 45 | for_each = { for network_security_group_rule in local.network_security_group_rules : "${network_security_group_rule.subnet_name}.${network_security_group_rule.nsg_rule_name}" => network_security_group_rule } 46 | 47 | access = each.value.access 48 | destination_address_prefix = each.value.destination_address_prefix 49 | destination_port_range = length(each.value.destination_port_ranges) == 1 ? each.value.destination_port_ranges[0] : null 50 | destination_port_ranges = length(each.value.destination_port_ranges) > 1 ? each.value.destination_port_ranges : null 51 | direction = each.value.direction 52 | name = each.value.nsg_rule_name 53 | network_security_group_name = "${module.naming.network_security_group.name}.${each.value.subnet_name}" 54 | priority = each.value.priority 55 | protocol = each.value.protocol 56 | resource_group_name = var.resource_group_name 57 | source_address_prefix = each.value.source_address_prefix 58 | source_port_range = length(each.value.source_port_ranges) == 1 ? each.value.source_port_ranges[0] : null 59 | source_port_ranges = length(each.value.source_port_ranges) > 1 ? each.value.source_port_ranges : null 60 | 61 | depends_on = [ 62 | azurerm_network_security_group.groups 63 | ] 64 | } 65 | #endregion 66 | 67 | #region bastion 68 | resource "azurerm_bastion_host" "this" { 69 | name = module.naming.bastion_host.name 70 | location = var.location 71 | resource_group_name = var.resource_group_name 72 | depends_on = [azurerm_subnet.subnets] 73 | 74 | ip_configuration { 75 | name = "Primary" 76 | subnet_id = azurerm_subnet.subnets["AzureBastionSubnet"].id 77 | public_ip_address_id = azurerm_public_ip.bastion.id 78 | } 79 | } 80 | 81 | resource "azurerm_public_ip" "bastion" { 82 | name = "${module.naming.public_ip.name}-bastion" 83 | location = var.location 84 | resource_group_name = var.resource_group_name 85 | allocation_method = "Static" 86 | sku = "Standard" 87 | } 88 | #endregion 89 | 90 | #region firewall 91 | resource "azurerm_firewall" "this" { 92 | name = module.naming.firewall.name 93 | resource_group_name = var.resource_group_name 94 | location = var.location 95 | sku_name = "AZFW_VNet" 96 | sku_tier = "Standard" 97 | firewall_policy_id = azurerm_firewall_policy.this.id 98 | 99 | ip_configuration { 100 | name = "Primary" 101 | subnet_id = azurerm_subnet.subnets["AzureFirewallSubnet"].id 102 | public_ip_address_id = azurerm_public_ip.firewall.id 103 | } 104 | } 105 | 106 | resource "azurerm_firewall_policy" "this" { 107 | name = module.naming.firewall_policy.name 108 | resource_group_name = var.resource_group_name 109 | location = var.location 110 | sku = "Standard" 111 | threat_intelligence_mode = "Deny" 112 | } 113 | 114 | resource "azurerm_firewall_policy_rule_collection_group" "this" { 115 | name = module.naming.firewall_policy_rule_collection_group.name 116 | firewall_policy_id = azurerm_firewall_policy.this.id 117 | priority = 500 118 | network_rule_collection { 119 | name = "AllowOutboundInternet" 120 | priority = 400 121 | action = "Allow" 122 | 123 | rule { 124 | name = "AllowAllOutbound" 125 | source_addresses = ["*"] 126 | destination_addresses = ["0.0.0.0/0"] 127 | destination_ports = ["80", "443", "1688"] 128 | protocols = ["Any"] 129 | } 130 | } 131 | } 132 | 133 | resource "azurerm_route_table" "this" { 134 | name = module.naming.route_table.name 135 | resource_group_name = var.resource_group_name 136 | location = var.location 137 | 138 | route { 139 | name = "route-to-firewall" 140 | address_prefix = "0.0.0.0/0" 141 | next_hop_type = "VirtualAppliance" 142 | next_hop_in_ip_address = azurerm_firewall.this.ip_configuration[0].private_ip_address 143 | } 144 | } 145 | 146 | resource "azurerm_subnet_route_table_association" "associations" { 147 | for_each = { 148 | for subnet_key, subnet in local.subnets : subnet_key => subnet if subnet.route_table == "firewall" 149 | } 150 | 151 | subnet_id = azurerm_subnet.subnets[each.key].id 152 | route_table_id = azurerm_route_table.this.id 153 | 154 | depends_on = [azurerm_subnet_network_security_group_association.associations] 155 | } 156 | 157 | resource "azurerm_public_ip" "firewall" { 158 | name = "${module.naming.public_ip.name}-firewall" 159 | location = var.location 160 | resource_group_name = var.resource_group_name 161 | allocation_method = "Static" 162 | sku = "Standard" 163 | } 164 | #endregion 165 | 166 | #region network-interfaces 167 | resource "azurerm_network_interface" "this" { 168 | name = "${module.naming.network_interface.name}-${var.vm_adds_name}" 169 | location = var.location 170 | resource_group_name = var.resource_group_name 171 | depends_on = [azurerm_subnet_route_table_association.associations] 172 | 173 | ip_configuration { 174 | name = "Primary" 175 | subnet_id = azurerm_subnet.subnets["snet-adds-01"].id 176 | private_ip_address_allocation = "Dynamic" 177 | } 178 | } 179 | #endregion 180 | -------------------------------------------------------------------------------- /modules/vnet-shared/outputs.tf: -------------------------------------------------------------------------------- 1 | output "adds_domain_name" { 2 | value = var.adds_domain_name 3 | } 4 | 5 | output "admin_password_secret" { 6 | value = azurerm_key_vault_secret.adminpassword.name 7 | } 8 | 9 | output "admin_username_secret" { 10 | value = azurerm_key_vault_secret.adminusername.name 11 | } 12 | 13 | output "dns_server" { 14 | value = azurerm_virtual_network.this.dns_servers[0] 15 | } 16 | 17 | output "resource_ids" { 18 | value = { 19 | automation_account = azurerm_automation_account.this.id 20 | bastion_host = azurerm_bastion_host.this.id 21 | firewall = azurerm_firewall.this.id 22 | firewall_route_table = azurerm_route_table.this.id 23 | virtual_machine_adds1 = azurerm_windows_virtual_machine.this.id 24 | virtual_network_shared = azurerm_virtual_network.this.id 25 | } 26 | } 27 | 28 | output "resource_names" { 29 | value = { 30 | automation_account = azurerm_automation_account.this.name 31 | bastion_host = azurerm_bastion_host.this.name 32 | firewall = azurerm_firewall.this.name 33 | firewall_route_table = azurerm_route_table.this.name 34 | virtual_machine_adds1 = azurerm_windows_virtual_machine.this.name 35 | virtual_network_shared = azurerm_virtual_network.this.name 36 | } 37 | } 38 | 39 | output "subnets" { 40 | value = azurerm_subnet.subnets 41 | } 42 | -------------------------------------------------------------------------------- /modules/vnet-shared/scripts/DomainControllerConfiguration.ps1: -------------------------------------------------------------------------------- 1 | configuration DomainControllerConfiguration { 2 | param ( 3 | [Parameter(Mandatory = $true)] 4 | [String]$ComputerName 5 | ) 6 | 7 | Import-DscResource -ModuleName PSDscResources 8 | Import-DscResource -ModuleName ActiveDirectoryDsc 9 | Import-DscResource -ModuleName DnsServerDsc 10 | 11 | $adminCredential = Get-AutomationPSCredential 'bootstrapadmin' 12 | $domain = Get-AutomationVariable -Name 'adds_domain_name' 13 | 14 | node $ComputerName { 15 | WindowsFeature 'AD-Domain-Services' { 16 | Name = 'AD-Domain-Services' 17 | Ensure = 'Present' 18 | } 19 | 20 | ADDomain 'LabDomain' { 21 | DomainName = $domain 22 | Credential = $adminCredential 23 | SafemodeAdministratorPassword = $adminCredential 24 | ForestMode = 'WinThreshold' 25 | DependsOn = '[WindowsFeature]AD-Domain-Services' 26 | } 27 | 28 | DnsServerForwarder 'SetForwarders' { 29 | IsSingleInstance = 'Yes' 30 | IPAddresses = @('168.63.129.16') 31 | UseRootHint = $false 32 | DependsOn = '[ADDomain]LabDomain' 33 | } 34 | 35 | ADUser 'bootstrapadmin' { 36 | UserName = $adminCredential.UserName 37 | PasswordNeverExpires = $true 38 | DomainName = $domain 39 | DomainController = $ComputerName 40 | DependsOn = '[ADDomain]LabDomain' 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /modules/vnet-shared/scripts/Register-DscNode.ps1: -------------------------------------------------------------------------------- 1 | #region parameters 2 | param ( 3 | [Parameter(Mandatory = $true)] 4 | [String]$TenantId, 5 | 6 | [Parameter(Mandatory = $true)] 7 | [String]$SubscriptionId, 8 | 9 | [Parameter(Mandatory = $true)] 10 | [String]$ResourceGroupName, 11 | 12 | [Parameter(Mandatory = $true)] 13 | [String]$Location, 14 | 15 | [Parameter(Mandatory = $true)] 16 | [String]$AutomationAccountName, 17 | 18 | [Parameter(Mandatory = $true)] 19 | [String]$VirtualMachineName, 20 | 21 | [Parameter(Mandatory = $true)] 22 | [String]$AppId, 23 | 24 | [Parameter(Mandatory = $true)] 25 | [string]$AppSecret, 26 | 27 | [Parameter(Mandatory = $true)] 28 | [string]$DscConfigurationName 29 | ) 30 | #endregion 31 | 32 | #region functions 33 | function Write-Log { 34 | param( [string] $msg) 35 | "$(Get-Date -Format FileDateTimeUniversal) : $msg" | Write-Host 36 | } 37 | 38 | function Exit-WithError { 39 | param( [string]$msg ) 40 | Write-Log "There was an exception during the process, please review..." 41 | Write-Log $msg 42 | Exit 2 43 | } 44 | 45 | function Register-DscNode { 46 | param( 47 | [Parameter(Mandatory = $true)] 48 | [string] $ResourceGroupName, 49 | 50 | [Parameter(Mandatory = $true)] 51 | [string] $AutomationAccountName, 52 | 53 | [Parameter(Mandatory = $true)] 54 | [string] $VirtualMachineName, 55 | 56 | [Parameter(Mandatory = $true)] 57 | [string] $Location, 58 | 59 | [Parameter(Mandatory = $true)] 60 | [string] $DscConfigurationName 61 | ) 62 | 63 | $nodeConfigName = $DscConfigurationName + '.' + $VirtualMachineName 64 | 65 | try { 66 | $dscNodes = Get-AzAutomationDscNode ` 67 | -ResourceGroupName $ResourceGroupName ` 68 | -AutomationAccountName $AutomationAccountName ` 69 | -Name $VirtualMachineName ` 70 | -ErrorAction Stop 71 | } 72 | catch { 73 | Exit-WithError $_ 74 | } 75 | 76 | if ($null -eq $dscNodes) { 77 | Write-Log "No existing DSC node registrations for '$VirtualMachineName' with node configuration '$nodeConfigName' found..." 78 | } 79 | else { 80 | foreach ($dscNode in $dscNodes) { 81 | $dscNodeId = $dscNode.Id 82 | Write-Log "Unregistering DSC node registration '$dscNodeId'..." 83 | 84 | try { 85 | Unregister-AzAutomationDscNode ` 86 | -Id $dscNodeId ` 87 | -Force ` 88 | -ResourceGroupName $ResourceGroupName ` 89 | -AutomationAccountName $AutomationAccountName 90 | } 91 | catch { 92 | Exit-WithError $_ 93 | } 94 | } 95 | } 96 | 97 | Write-Log "Checking for node configuration '$nodeConfigName'..." 98 | 99 | try { 100 | $nodeConfig = Get-AzAutomationDscNodeConfiguration ` 101 | -ResourceGroupName $ResourceGroupName ` 102 | -AutomationAccountName $AutomationAccountName ` 103 | -Name $nodeConfigName ` 104 | -ErrorAction Stop 105 | } 106 | catch { 107 | Exit-WithError $_ 108 | } 109 | 110 | $rollupStatus = $nodeConfig.RollupStatus 111 | Write-Log "DSC node configuration '$nodeConfigName' RollupStatus is '$($rollupStatus)'..." 112 | 113 | if ($rollupStatus -ne 'Good'){ 114 | Exit-WithError "Invalid DSC node configuration RollupStatus..." 115 | } 116 | 117 | Write-Log "Registering DSC node '$VirtualMachineName' with node configuration '$nodeConfigName'..." 118 | Write-Log "Warning, this process can take several minutes and the VM will be rebooted..." 119 | 120 | Register-AzAutomationDscNode ` 121 | -ResourceGroupName $ResourceGroupName ` 122 | -AutomationAccountName $AutomationAccountName ` 123 | -AzureVMName $VirtualMachineName ` 124 | -AzureVMResourceGroup $ResourceGroupName ` 125 | -AzureVMLocation $Location ` 126 | -NodeConfigurationName $nodeConfigName ` 127 | -ConfigurationModeFrequencyMins 15 ` 128 | -ConfigurationMode 'ApplyOnly' ` 129 | -AllowModuleOverwrite $false ` 130 | -RebootNodeIfNeeded $true ` 131 | -ActionAfterReboot 'ContinueConfiguration' ` 132 | -ErrorAction SilentlyContinue 133 | 134 | Write-Log "Sleeping for 60 seconds before checking node status..." 135 | Start-Sleep -Seconds 60 136 | 137 | try { 138 | $dscNodes = Get-AzAutomationDscNode ` 139 | -ResourceGroupName $ResourceGroupName ` 140 | -AutomationAccountName $AutomationAccountName ` 141 | -Name $VirtualMachineName ` 142 | -ErrorAction Stop 143 | } 144 | catch { 145 | Exit-WithError $_ 146 | } 147 | 148 | if ($null -eq $dscNodes) { 149 | Exit-WithError "No existing DSC node registrations for '$VirtualMachineName' with node configuration '$nodeConfigName' found..." 150 | } 151 | 152 | $dscNode = $dscNodes[0] 153 | $dscNodeId = $dscNode.Id 154 | $dscNodeStatus = $dscNode.Status 155 | Write-Log "DSC node registration id '$dscNodeId' found with status '$dscNodeStatus'..." 156 | 157 | $maxRetries = 30 158 | $retryCount = 0 159 | $statusCompliant = "Compliant" 160 | 161 | while ($retryCount -lt $maxRetries -and $dscNodeStatus -ne $statusCompliant) { 162 | $retryCount++ 163 | try { 164 | $dscNodes = Get-AzAutomationDscNode ` 165 | -Id $dscNodeId ` 166 | -ResourceGroupName $ResourceGroupName ` 167 | -AutomationAccountName $AutomationAccountName ` 168 | -ErrorAction Stop 169 | } 170 | catch { 171 | Exit-WithError $_ 172 | } 173 | 174 | $dscNode = $dscNodes[0] 175 | $dscNodeId = $dscNode.Id 176 | $dscNodeStatus = $dscNode.Status 177 | Write-Log "Retry '$retryCount': DSC node registration id '$dscNodeId' status is '$dscNodeStatus'..." 178 | 179 | if ($dscNodeStatus -ne $statusCompliant) { 180 | Write-Log "DSC node status is not '$statusCompliant'. Retrying in 30 seconds..." 181 | Start-Sleep -Seconds 30 182 | } 183 | } 184 | 185 | if ($dscNodeStatus -ne $statusCompliant) { 186 | Exit-WithError "DSC node status did not reach '$statusCompliant' after $maxRetries attempts." 187 | } 188 | } 189 | #endregion 190 | 191 | #region main 192 | Write-Log "Running '$PSCommandPath'..." 193 | 194 | Write-Log "Logging into Azure using service principal id '$AppId'..." 195 | 196 | $AppSecretSecure = ConvertTo-SecureString $AppSecret -AsPlainText -Force 197 | $spCredential = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList $AppId, $AppSecretSecure 198 | 199 | try { 200 | Connect-AzAccount -Credential $spCredential -Tenant $TenantId -ServicePrincipal -ErrorAction Stop | Out-Null 201 | } 202 | catch { 203 | Exit-WithError $_ 204 | } 205 | 206 | # Set default subscription 207 | Write-Log "Setting default subscription to '$SubscriptionId'..." 208 | 209 | try { 210 | Set-AzContext -Subscription $SubscriptionId | Out-Null 211 | } 212 | catch { 213 | Exit-WithError $_ 214 | } 215 | 216 | # Get automation account 217 | $automationAccount = Get-AzAutomationAccount -ResourceGroupName $ResourceGroupName -Name $AutomationAccountName 218 | 219 | if ($null -eq $automationAccount) { 220 | Exit-WithError "Automation account '$AutomationAccountName' was not found..." 221 | } 222 | 223 | Write-Log "Located automation account '$AutomationAccountName' in resource group '$ResourceGroupName'" 224 | 225 | # Register DSC Node 226 | Register-DscNode ` 227 | -ResourceGroupName $ResourceGroupName ` 228 | -AutomationAccountName $AutomationAccountName ` 229 | -VirtualMachineName $VirtualMachineName ` 230 | -Location $Location ` 231 | -DscConfigurationName $DscConfigurationName 232 | 233 | Exit 234 | #endregion 235 | -------------------------------------------------------------------------------- /modules/vnet-shared/terraform.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_version = "~> 1.12.1" 3 | 4 | required_providers { 5 | azurerm = { 6 | source = "hashicorp/azurerm" 7 | version = "~> 4.31.0" 8 | } 9 | 10 | random = { 11 | source = "hashicorp/random" 12 | version = "~> 3.7.2" 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /modules/vwan/locals.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | public_cert_data = join("\n", slice(split("\n", tls_self_signed_cert.root_cert.cert_pem), 1, length(split("\n", tls_self_signed_cert.root_cert.cert_pem)) - 2)) 3 | } 4 | -------------------------------------------------------------------------------- /modules/vwan/main.tf: -------------------------------------------------------------------------------- 1 | #region resources 2 | 3 | # Generate a private key for the root certificate 4 | resource "tls_private_key" "root_cert_key" { 5 | algorithm = "RSA" 6 | rsa_bits = 2048 7 | } 8 | 9 | # Generate a self-signed root certificate 10 | resource "tls_self_signed_cert" "root_cert" { 11 | private_key_pem = tls_private_key.root_cert_key.private_key_pem 12 | subject { 13 | common_name = "MyP2SVPNRootCert" 14 | organization = "AzureSandbox" 15 | } 16 | validity_period_hours = 8760 # 1 year 17 | is_ca_certificate = true 18 | allowed_uses = [ 19 | "cert_signing", 20 | "key_encipherment", 21 | "digital_signature" 22 | ] 23 | } 24 | 25 | # Generate a private key for the client certificate 26 | resource "tls_private_key" "client_cert_key" { 27 | algorithm = "RSA" 28 | rsa_bits = 2048 29 | } 30 | 31 | # Store the private key in key vault so it can be used later to create a pfx file 32 | resource "azurerm_key_vault_secret" "this" { 33 | name = "p2svpn-client-private-key-pem" 34 | value = tls_private_key.client_cert_key.private_key_pem 35 | key_vault_id = var.key_vault_id 36 | } 37 | 38 | # Generate a client certificate signed by the root certificate 39 | resource "tls_cert_request" "client_cert_request" { 40 | private_key_pem = tls_private_key.client_cert_key.private_key_pem 41 | subject { 42 | common_name = "MyP2SVPNClientCert" 43 | organization = "AzureSandbox" 44 | } 45 | dns_names = ["MyP2SVPNClientCert"] 46 | } 47 | 48 | resource "tls_locally_signed_cert" "client_cert" { 49 | cert_request_pem = tls_cert_request.client_cert_request.cert_request_pem 50 | ca_private_key_pem = tls_private_key.root_cert_key.private_key_pem 51 | ca_cert_pem = tls_self_signed_cert.root_cert.cert_pem 52 | validity_period_hours = 8760 # 1 year 53 | allowed_uses = [ 54 | "client_auth", 55 | "digital_signature", 56 | "key_encipherment" 57 | ] 58 | } 59 | #endregion 60 | 61 | #region modules 62 | module "naming" { 63 | source = "Azure/naming/azurerm" 64 | version = "~> 0.4.2" 65 | suffix = [var.tags["project"], var.tags["environment"]] 66 | } 67 | #endregion 68 | -------------------------------------------------------------------------------- /modules/vwan/network.tf: -------------------------------------------------------------------------------- 1 | resource "azurerm_virtual_wan" "this" { 2 | name = module.naming.virtual_wan.name 3 | resource_group_name = var.resource_group_name 4 | location = var.location 5 | } 6 | 7 | resource "azurerm_virtual_hub" "this" { 8 | name = "${module.naming.virtual_wan.name}-hub" 9 | resource_group_name = var.resource_group_name 10 | location = var.location 11 | virtual_wan_id = azurerm_virtual_wan.this.id 12 | address_prefix = var.vwan_hub_address_prefix 13 | } 14 | 15 | resource "azurerm_virtual_hub_connection" "connections" { 16 | for_each = var.virtual_networks 17 | 18 | name = each.key 19 | virtual_hub_id = azurerm_virtual_hub.this.id 20 | remote_virtual_network_id = each.value 21 | } 22 | 23 | resource "azurerm_point_to_site_vpn_gateway" "this" { 24 | name = module.naming.point_to_site_vpn_gateway.name 25 | resource_group_name = var.resource_group_name 26 | location = var.location 27 | virtual_hub_id = azurerm_virtual_hub.this.id 28 | vpn_server_configuration_id = azurerm_vpn_server_configuration.this.id 29 | scale_unit = 1 30 | dns_servers = [var.dns_server, "168.63.129.16"] 31 | 32 | connection_configuration { 33 | name = "Clients" 34 | 35 | vpn_client_address_pool { 36 | address_prefixes = [var.client_address_pool] 37 | } 38 | } 39 | } 40 | 41 | resource "azurerm_vpn_server_configuration" "this" { 42 | name = "${module.naming.point_to_site_vpn_gateway.name}-server-config" 43 | resource_group_name = var.resource_group_name 44 | location = var.location 45 | vpn_authentication_types = ["Certificate"] 46 | tags = var.tags 47 | 48 | client_root_certificate { 49 | name = "Self signed certificate" 50 | public_cert_data = local.public_cert_data 51 | } 52 | } 53 | 54 | -------------------------------------------------------------------------------- /modules/vwan/outputs.tf: -------------------------------------------------------------------------------- 1 | output "client_cert_pem" { 2 | value = tls_locally_signed_cert.client_cert.cert_pem 3 | } 4 | 5 | output "resource_ids" { 6 | value = { 7 | virtual_wan = azurerm_virtual_wan.this.id 8 | virtual_wan_hub = azurerm_virtual_hub.this.id 9 | } 10 | } 11 | 12 | output "resource_names" { 13 | value = { 14 | virtual_wan = azurerm_virtual_wan.this.name 15 | virtual_wan_hub = azurerm_virtual_hub.this.name 16 | } 17 | } 18 | 19 | output "root_cert_pem" { 20 | description = "Self signed root certificate in PEM format for use with point-to-site VPN clients." 21 | value = tls_self_signed_cert.root_cert.cert_pem 22 | } 23 | -------------------------------------------------------------------------------- /modules/vwan/scripts/Export-Certificates.ps1: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env pwsh 2 | 3 | # Helper script for exporting the certificates required to authenticate with point-to-site VPN from a client machine 4 | # Requires Terraform, OpenSSL and PowerShell Az module to be installed 5 | # Script must be run from root module directory 6 | 7 | # Export self-signed root certificate in PEM format to file 'root_cert.pem' 8 | terraform output -raw root_cert_pem > root_cert.pem 9 | 10 | # Retrieve the client certificate in PEM format from the Terraform output 11 | terraform output -raw client_cert_pem > client_cert.pem 12 | 13 | # Retrieve the key vault name from the Terraform output 14 | $keyVaultName = (terraform output -json resource_names | ConvertFrom-Json).key_vault 15 | 16 | # Retrieve the client private key in PEM format from the Azure Key Vault 17 | $clientKeyPem = (Get-AzKeyVaultSecret -VaultName $keyVaultName -Name 'p2svpn-client-private-key-pem' -AsPlainText) 18 | 19 | # Retrieve the admin password from the Azure Key Vault for use as client certificate passkey 20 | $clientCertPasskey = (Get-AzKeyVaultSecret -VaultName $keyVaultName -Name 'adminpassword' -AsPlainText) 21 | 22 | # Write the private key and client certificate to temporary files 23 | Set-Content -Path "client_key.pem" -Value $clientKeyPem 24 | 25 | # Use OpenSSL to create the PFX file 26 | openssl pkcs12 -export -out client_cert.pfx -inkey client_key.pem -in client_cert.pem -certfile root_cert.pem -passout pass:$clientCertPasskey 27 | 28 | # Clean up temporary files 29 | Remove-Item -Path "root_cert.pem", "client_key.pem", "client_cert.pem" 30 | -------------------------------------------------------------------------------- /modules/vwan/scripts/export-certificates.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Helper script for exporting the certificates required to authenticate with point-to-site VPN from a client machine 4 | # Requires Terraform, Azure CLI, OpenSSL and jq packages to be installed 5 | # Script must be run from the root module directory 6 | 7 | # Retrieve self-signed root certificate in PEM format 8 | root_cert_pem=$(terraform output -raw root_cert_pem) 9 | 10 | # Retrieve the client certificate in PEM format from the Terraform output 11 | client_cert_pem=$(terraform output -raw client_cert_pem) 12 | 13 | # Retrieve the key vault name from the Terraform output 14 | key_vault_name=$(terraform output -json resource_names | jq -r .key_vault) 15 | 16 | # Retrieve the client private key in PEM format from the Azure Key Vault 17 | client_key_pem=$(az keyvault secret show --name p2svpn-client-private-key-pem --vault-name $key_vault_name --query value -o tsv) 18 | 19 | # Retrieve the admin password from the Azure Key Vault for use as client certificate passkey 20 | client_cert_passkey=$(az keyvault secret show --name adminpassword --vault-name $key_vault_name --query value -o tsv) 21 | 22 | # Export client certificate in PFX format to file 'client_cert.pfx' 23 | openssl pkcs12 -export -out client_cert.pfx \ 24 | -inkey <(echo "$client_key_pem") \ 25 | -in <(echo "$client_cert_pem") \ 26 | -certfile <(echo "$root_cert_pem") \ 27 | -passout pass:"$client_cert_passkey" 28 | -------------------------------------------------------------------------------- /modules/vwan/terraform.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_version = "~> 1.12.1" 3 | 4 | required_providers { 5 | azurerm = { 6 | source = "hashicorp/azurerm" 7 | version = "~> 4.31.0" 8 | } 9 | 10 | tls = { 11 | source = "hashicorp/tls" 12 | version = "~> 4.1.0" 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /modules/vwan/variables.tf: -------------------------------------------------------------------------------- 1 | variable "client_address_pool" { 2 | type = string 3 | description = "The client address pool for the point to site VPN gateway." 4 | default = "10.4.0.0/16" 5 | 6 | validation { 7 | condition = can(cidrhost(var.client_address_pool, 0)) 8 | error_message = "Must be valid IPv4 CIDR." 9 | } 10 | } 11 | 12 | variable "dns_server" { 13 | type = string 14 | description = "The IP address for the DNS server." 15 | 16 | validation { 17 | condition = can(regex("^(10\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3}))$|^(172\\.(1[6-9]|2[0-9]|3[0-1])\\.(\\d{1,3})\\.(\\d{1,3}))$|^(192\\.168\\.(\\d{1,3})\\.(\\d{1,3}))$", var.dns_server)) 18 | error_message = "Must be a valid RFC 1918 private IP address (e.g., 10.x.x.x, 172.16.x.x - 172.31.x.x, or 192.168.x.x)." 19 | } 20 | } 21 | 22 | variable "key_vault_id" { 23 | type = string 24 | description = "The existing key vault where secrets are stored" 25 | 26 | validation { 27 | condition = can(regex("^/subscriptions/[0-9a-fA-F-]+/resourceGroups/[a-zA-Z0-9-_()]+/providers/Microsoft.KeyVault/vaults/[a-zA-Z0-9-]+$", var.key_vault_id)) 28 | error_message = "Must be a valid Azure Resource ID for a Key Vault. It should follow the format '/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.KeyVault/vaults/{keyVaultName}'." 29 | } 30 | } 31 | 32 | variable "location" { 33 | type = string 34 | description = "The name of the Azure Region where resources will be provisioned." 35 | 36 | validation { 37 | condition = can(regex("^[a-z0-9-]+$", var.location)) 38 | error_message = "Must be a valid Azure region name. It should only contain lowercase letters, numbers, and dashes (e.g., 'eastus', 'westus2', 'centralus')." 39 | } 40 | } 41 | 42 | variable "resource_group_name" { 43 | type = string 44 | description = "The name of the existing resource group." 45 | 46 | validation { 47 | condition = can(regex("^[a-zA-Z0-9._()-]{1,90}$", var.resource_group_name)) 48 | error_message = "Must conform to Azure resource group naming requirements: it can only contain alphanumeric characters, periods (.), underscores (_), parentheses (()), and hyphens (-), and must be between 1 and 90 characters long." 49 | } 50 | } 51 | 52 | variable "tags" { 53 | type = map(any) 54 | description = "The tags in map format to be used when creating new resources." 55 | 56 | validation { 57 | condition = alltrue([ 58 | for key, value in var.tags : 59 | can(regex("^[a-zA-Z0-9._-]{1,512}$", key)) && 60 | can(regex("^[a-zA-Z0-9._ -]{0,256}$", value)) 61 | ]) 62 | error_message = "Each tag key must be 1-512 characters long and consist of alphanumeric characters, periods (.), underscores (_), or hyphens (-). Each tag value must be 0-256 characters long and consist of alphanumeric characters, periods (.), underscores (_), spaces, or hyphens (-)." 63 | } 64 | } 65 | 66 | variable "virtual_networks" { 67 | type = map(any) 68 | description = "The virtual networks to be connected to the vwan hub." 69 | 70 | # default = { MyHubVNetId = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/MyResourceGroupName/providers/Microsoft.Network/virtualNetworks/MyHubVNetName", MySpokeVnetId = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/MyResourceGroupName/providers/Microsoft.Network/virtualNetworks/MySpokeVNetName" } 71 | } 72 | 73 | variable "vwan_hub_address_prefix" { 74 | type = string 75 | description = "The address prefix in CIDR notation for the new spoke virtual wan hub." 76 | default = "10.3.0.0/16" 77 | 78 | validation { 79 | condition = can(cidrhost(var.vwan_hub_address_prefix, 0)) 80 | error_message = "Must be valid IPv4 CIDR." 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /outputs.tf: -------------------------------------------------------------------------------- 1 | output "client_cert_pem" { 2 | description = "Client certificate in PEM format for use with point-to-site VPN clients." 3 | value = length(module.vwan) == 1 ? module.vwan[0].client_cert_pem : null 4 | } 5 | 6 | 7 | output "resource_ids" { 8 | value = merge( 9 | { 10 | key_vault = azurerm_key_vault.this.id 11 | log_analytics_workspace = azurerm_log_analytics_workspace.this.id 12 | resource_group = azurerm_resource_group.this.id 13 | }, 14 | module.vnet_shared.resource_ids, 15 | length(module.vnet_app) > 0 ? module.vnet_app[0].resource_ids : {}, 16 | length(module.vm_jumpbox_linux) > 0 ? module.vm_jumpbox_linux[0].resource_ids : {}, 17 | length(module.vm_mssql_win) > 0 ? module.vm_mssql_win[0].resource_ids : {}, 18 | length(module.mssql) > 0 ? module.mssql[0].resource_ids : {}, 19 | length(module.mysql) > 0 ? module.mysql[0].resource_ids : {}, 20 | length(module.vwan) > 0 ? module.vwan[0].resource_ids : {}, 21 | length(module.vnet_onprem) > 0 ? module.vnet_onprem[0].resource_ids : {} 22 | ) 23 | } 24 | 25 | output "resource_names" { 26 | value = merge( 27 | { 28 | key_vault = azurerm_key_vault.this.name 29 | log_analytics_workspace = azurerm_log_analytics_workspace.this.name 30 | resource_group = azurerm_resource_group.this.name 31 | }, 32 | module.vnet_shared.resource_names, 33 | length(module.vnet_app) > 0 ? module.vnet_app[0].resource_names : {}, 34 | length(module.vm_jumpbox_linux) > 0 ? module.vm_jumpbox_linux[0].resource_names : {}, 35 | length(module.vm_mssql_win) > 0 ? module.vm_mssql_win[0].resource_names : {}, 36 | length(module.mssql) > 0 ? module.mssql[0].resource_names : {}, 37 | length(module.mysql) > 0 ? module.mysql[0].resource_names : {}, 38 | length(module.vwan) > 0 ? module.vwan[0].resource_names : {}, 39 | length(module.vnet_onprem) > 0 ? module.vnet_onprem[0].resource_names : {} 40 | ) 41 | } 42 | 43 | output "root_cert_pem" { 44 | description = "Self signed root certificate in PEM format for use with point-to-site VPN clients." 45 | value = length(module.vwan) == 1 ? module.vwan[0].root_cert_pem : null 46 | } 47 | -------------------------------------------------------------------------------- /providers.tf: -------------------------------------------------------------------------------- 1 | provider "azapi" { 2 | subscription_id = var.subscription_id 3 | client_id = var.arm_client_id 4 | client_secret = var.arm_client_secret 5 | tenant_id = var.aad_tenant_id 6 | } 7 | 8 | provider "azurerm" { 9 | subscription_id = var.subscription_id 10 | client_id = var.arm_client_id 11 | client_secret = var.arm_client_secret 12 | tenant_id = var.aad_tenant_id 13 | resource_provider_registrations = "extended" 14 | storage_use_azuread = true 15 | 16 | features { 17 | resource_group { 18 | prevent_deletion_if_contains_resources = false # This is to handle policy driven resource creation. 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /scripts/bootstrap.ps1: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env pwsh 2 | 3 | # Creates a terraform.tfvars file for Azure Sandbox 4 | # Requires Azure PowerShell module 5 | 6 | #region functions 7 | function Show-Usage { 8 | Write-Host "Usage: .\bootstrap.ps1" -ForegroundColor Red 9 | exit 1 10 | } 11 | 12 | function Show-JWTtoken { 13 | 14 | [cmdletbinding()] 15 | param( 16 | [Parameter(Mandatory=$true)] 17 | [SecureString]$token 18 | ) 19 | 20 | # Convert SecureString to plain text 21 | $plainToken = [System.Net.NetworkCredential]::new("", $token).Password 22 | 23 | # Validate as per https://tools.ietf.org/html/rfc7519 24 | # Access and ID tokens are fine, Refresh tokens will not work 25 | if (!$plainToken.Contains(".") -or !$plainToken.StartsWith("eyJ")) { Write-Error "Invalid token" -ErrorAction Stop } 26 | 27 | # Header 28 | $tokenheader = $plainToken.Split(".")[0].Replace('-', '+').Replace('_', '/') 29 | # Fix padding as needed, keep adding "=" until string length modulus 4 reaches 0 30 | while ($tokenheader.Length % 4) { Write-Verbose "Invalid length for a Base-64 char array or string, adding ="; $tokenheader += "=" } 31 | Write-Verbose "Base64 encoded (padded) header:" 32 | Write-Verbose $tokenheader 33 | # Convert from Base64 encoded string to PSObject all at once 34 | Write-Verbose "Decoded header:" 35 | [System.Text.Encoding]::ASCII.GetString([system.convert]::FromBase64String($tokenheader)) | ConvertFrom-Json | Format-List | Out-Null 36 | 37 | # Payload 38 | $tokenPayload = $plainToken.Split(".")[1].Replace('-', '+').Replace('_', '/') 39 | # Fix padding as needed, keep adding "=" until string length modulus 4 reaches 0 40 | while ($tokenPayload.Length % 4) { Write-Verbose "Invalid length for a Base-64 char array or string, adding ="; $tokenPayload += "=" } 41 | Write-Verbose "Base64 encoded (padded) payload:" 42 | Write-Verbose $tokenPayload 43 | # Convert to Byte array 44 | $tokenByteArray = [System.Convert]::FromBase64String($tokenPayload) 45 | # Convert to string array 46 | $tokenArray = [System.Text.Encoding]::ASCII.GetString($tokenByteArray) 47 | Write-Verbose "Decoded array in JSON format:" 48 | Write-Verbose $tokenArray 49 | # Convert from JSON to PSObject 50 | $tokobj = $tokenArray | ConvertFrom-Json 51 | Write-Verbose "Decoded Payload:" 52 | 53 | return $tokobj 54 | }#endregion 55 | 56 | #region constants 57 | # Initialize constants 58 | $defaultCostCenter = "mycostcenter" 59 | $defaultEnvironment = "dev" 60 | $defaultLocation = "centralus" 61 | $defaultProject = "sand" 62 | #endregion 63 | 64 | #region main 65 | 66 | # Check if environment variables are set 67 | if (-not $env:TF_VAR_arm_client_secret) { 68 | Write-Host "Environment variable 'TF_VAR_arm_client_secret' must be set." -ForegroundColor Red 69 | Show-Usage 70 | } 71 | 72 | # Ensure Azure PowerShell module is installed 73 | if (-not (Get-Module -ListAvailable -Name Az)) { 74 | Write-Host "Azure PowerShell module is not installed. Please install it using 'Install-Module -Name Az'." -ForegroundColor Red 75 | Show-Usage 76 | } 77 | 78 | # Connect to Azure if not already connected 79 | if (-not (Get-AzContext)) { 80 | Write-Host "You are not logged into Azure. Please log in." -ForegroundColor Yellow 81 | Connect-AzAccount -UseDeviceAuthentication 82 | } 83 | 84 | # Retrieve runtime defaults 85 | Write-Host "Retrieving runtime defaults ..." 86 | 87 | # Set default subscription from currently logged in Azure PowerShell session 88 | $defaultSubscriptionId = (Get-AzContext).Subscription.Id 89 | if (-not $defaultSubscriptionId) { 90 | Write-Host "Unable to retrieve Azure subscription details. Please log in using 'Connect-AzAccount'." -ForegroundColor Red 91 | Show-Usage 92 | } 93 | 94 | # Set default user from currently logged in Azure PowerShell session 95 | $defaultUserObjectId = (Show-JWTtoken -token (Get-AzAccessToken -AsSecureString).Token).oid 96 | 97 | # Set default Microsoft Entra tenant id from currently logged in Azure PowerShell session 98 | $defaultAadTenantId = (Get-AzContext).Tenant.Id 99 | 100 | # Get user input 101 | $armClientId = Read-Host "Service principal appId (arm_client_id)" 102 | $aadTenantId = Read-Host "Microsoft Entra tenant id (aad_tenant_id) default '$defaultAadTenantId'" 103 | 104 | if (-not $aadTenantId) { 105 | $aadTenantId = $defaultAadTenantId 106 | } 107 | 108 | $userObjectId = Read-Host "Object id for Azure PowerShell signed in user (user_object_id) default '$defaultUserObjectId'" 109 | 110 | if (-not $userObjectId) { 111 | $userObjectId = $defaultUserObjectId 112 | } 113 | 114 | $subscriptionId = Read-Host "Azure subscription id (subscription_id) default '$defaultSubscriptionId'" 115 | 116 | if (-not $subscriptionId) { 117 | $subscriptionId = $defaultSubscriptionId 118 | } 119 | 120 | $location = Read-Host "Azure location (location) default '$defaultLocation'" 121 | 122 | if (-not $location) { 123 | $location = $defaultLocation 124 | } 125 | 126 | $environment = Read-Host "Environment tag value (environment) default '$defaultEnvironment'" 127 | 128 | if (-not $environment) { 129 | $environment = $defaultEnvironment 130 | } 131 | 132 | $costCenter = Read-Host "Cost center tag value (costcenter) default '$defaultCostCenter'" 133 | 134 | if (-not $costCenter) { 135 | $costCenter = $defaultCostCenter 136 | } 137 | 138 | $project = Read-Host "Project tag value (project) default '$defaultProject'" 139 | 140 | if (-not $project) { 141 | $project = $defaultProject 142 | } 143 | 144 | # Validate user input 145 | if (-not $armClientId) { 146 | Write-Host "arm_client_id is required." -ForegroundColor Red 147 | Show-Usage 148 | } 149 | 150 | # Validate service principal 151 | $armClientDisplayName = (Get-AzADServicePrincipal -AppId $armClientId).DisplayName 152 | if ($armClientDisplayName) { 153 | Write-Host "Found service principal '$armClientDisplayName'..." 154 | } else { 155 | Write-Host "Invalid service principal AppId '$armClientId'." -ForegroundColor Red 156 | Show-Usage 157 | } 158 | 159 | # Validate subscription 160 | $subscriptionName = (Get-AzSubscription -SubscriptionId $subscriptionId).Name 161 | if ($subscriptionName) { 162 | Write-Host "Found subscription '$subscriptionName'..." 163 | } else { 164 | Write-Host "Invalid subscription id '$subscriptionId'." -ForegroundColor Red 165 | Show-Usage 166 | } 167 | 168 | # Validate location 169 | $locationDisplayName = (Get-AzLocation | Where-Object { $_.Location -eq $location }).DisplayName 170 | if ($locationDisplayName) { 171 | Write-Host "Found location '$locationDisplayName'..." 172 | } else { 173 | Write-Host "Invalid location '$location'." -ForegroundColor Red 174 | Show-Usage 175 | } 176 | 177 | # Build tags map 178 | $tags = @{ 179 | project = $project 180 | costcenter = $costCenter 181 | environment = $environment 182 | } 183 | 184 | # Generate terraform.tfvars file 185 | Write-Host "`nGenerating terraform.tfvars file...`n" 186 | 187 | @" 188 | aad_tenant_id = "$aadTenantId" 189 | arm_client_id = "$armClientId" 190 | location = "$location" 191 | subscription_id = "$subscriptionId" 192 | user_object_id = "$userObjectId" 193 | 194 | tags = { 195 | project = "$($tags.project)" 196 | costcenter = "$($tags.costcenter)" 197 | environment = "$($tags.environment)" 198 | } 199 | 200 | # Enable modules here 201 | 202 | # enable_module_vnet_app = true 203 | # enable_module_vm_jumpbox_linux = true 204 | # enable_module_vm_mssql_win = true 205 | # enable_module_mssql = true 206 | # enable_module_mysql = true 207 | # enable_module_vwan = true 208 | # enable_module_vnet_onprem = true 209 | "@ | Out-File -FilePath ./terraform.tfvars -Encoding utf8 210 | 211 | Get-Content ./terraform.tfvars 212 | 213 | exit 0 214 | #endregion 215 | -------------------------------------------------------------------------------- /scripts/bootstrap.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # This script generates a terraform.tfvars file for use with Azure Sandbox 4 | # 5 | # Package dependencies: 6 | # - Azure CLI 7 | # - PyJWT python library to decode the JWT tokens 8 | 9 | #region functions 10 | 11 | usage() { 12 | printf "Usage: $0 \n" 1>&2 13 | exit 1 14 | } 15 | 16 | #endregion 17 | 18 | #region constants 19 | 20 | default_costcenter=mycostcenter 21 | default_environment=dev 22 | default_location=centralus 23 | default_project=sand 24 | 25 | #endregion 26 | 27 | #region main 28 | 29 | # Check if Azure CLI is installed 30 | if ! command -v az &> /dev/null 31 | then 32 | printf "Azure CLI could not be found. Please install Azure CLI.\n" 33 | usage 34 | fi 35 | # Check if Python 3 is installed 36 | if ! command -v python3 &> /dev/null 37 | then 38 | printf "Python 3 could not be found. Please install Python 3.\n" 39 | usage 40 | fi 41 | # Check if PyJWT is installed 42 | if ! python3 -c "import jwt" &> /dev/null 43 | then 44 | printf "PyJWT could not be found. Please install PyJWT.\n" 45 | usage 46 | fi 47 | 48 | # Get runtime defaults 49 | printf "Retrieving runtime defaults ...\n" 50 | 51 | # Validate TF_VAR_arm_client_secret 52 | if [ -z "$TF_VAR_arm_client_secret" ] 53 | then 54 | printf "Environment variable 'TF_VAR_arm_client_secret' must be set.\n" 55 | usage 56 | fi 57 | 58 | # Set default subscription from currently logged in Azure CLI user. 59 | default_subscription_id=$(az account list --only-show-errors --query "[? isDefault]|[0].id" --output tsv) 60 | 61 | if [ -z $default_subscription_id ] 62 | then 63 | printf "Unable to retrieve Azure subscription details. Please run 'az login' first.\n" 64 | usage 65 | fi 66 | 67 | # Set default user from currently logged in Azure CLI user. 68 | default_user_object_id=$(az account get-access-token --query accessToken --output tsv | tr -d '\n' | python3 -c "import jwt, sys; print(jwt.decode(sys.stdin.read(), algorithms=['RS256'], options={'verify_signature': False})['oid'])") 69 | 70 | # Set default Microsoft Entra tenant id from currently logged in Azure CLI user. 71 | default_aad_tenant_id=$(az account show --query tenantId --output tsv) 72 | 73 | # Get user input 74 | read -e -p "Service principal appId (arm_client_id) -----------------: " arm_client_id 75 | read -e -i $default_aad_tenant_id -p "Microsoft Entra tenant id (aad_tenant_id) ---------------: " aad_tenant_id 76 | read -e -i $default_user_object_id -p "Object id for Azure CLI signed in user (user_object_id) -: " user_object_id 77 | read -e -i $default_subscription_id -p "Azure subscription id (subscription_id) -----------------: " subscription_id 78 | read -e -i $default_location -p "Azure location (location) -------------------------------: " location 79 | read -e -i $default_environment -p "Environment tag value (environment) ---------------------: " environment 80 | read -e -i $default_costcenter -p "Cost center tag value (costcenter) ----------------------: " costcenter 81 | read -e -i $default_project -p "Project tag value (project) -----------------------------: " project 82 | 83 | # Validate user input 84 | aad_tenant_id=${aad_tenant_id:-$default_aad_tenant_id} 85 | costcenter=${costcenter:-$default_costcenter} 86 | environment=${environment:-$default_environment} 87 | location=${location:-$default_location} 88 | user_object_id=${user_object_id:-$default_user_object_id} 89 | project=${project:-$default_project} 90 | subscription_id=${subscription_id:-$default_subscription_id} 91 | 92 | # Validate arm_client_id 93 | if [ -z "$arm_client_id" ] 94 | then 95 | printf "arm_client_id is required.\n" 96 | usage 97 | fi 98 | 99 | # Validate TF_VAR_arm_client_secret 100 | if [ -z "$TF_VAR_arm_client_secret" ] 101 | then 102 | printf "Environment variable 'TF_VAR_arm_client_secret' must be set.\n" 103 | usage 104 | fi 105 | 106 | # Validate service principal 107 | arm_client_display_name=$(az ad sp show --id $arm_client_id --query "appDisplayName" --output tsv) 108 | 109 | if [ -n "$arm_client_display_name" ] 110 | then 111 | printf "Found service principal '$arm_client_display_name'...\n" 112 | else 113 | printf "Invalid service principal AppId '$arm_client_id'...\n" 114 | usage 115 | fi 116 | 117 | # Validate subscription 118 | subscription_name=$(az account list --query "[?id=='$subscription_id'].name" --output tsv) 119 | 120 | if [ -n "$subscription_name" ] 121 | then 122 | printf "Found subscription '$subscription_name'...\n" 123 | else 124 | printf "Invalid subscription id '$subscription_id'.\n" 125 | usage 126 | fi 127 | 128 | # Validate object id of Azure CLI signed in user 129 | if [ -z "$user_object_id" ] 130 | then 131 | printf "Object id for Azure CLI signed in user (user_object_id) not provided.\n" 132 | usage 133 | fi 134 | 135 | # Validate location 136 | location_id=$(az account list-locations --query "[?name=='$location'].id" --output tsv) 137 | 138 | if [ -z "$location_id" ] 139 | then 140 | printf "Invalid location '$location'...\n" 141 | usage 142 | fi 143 | 144 | # Build tags map 145 | tags=$(cat < ./terraform.tfvars 158 | printf "arm_client_id = \"$arm_client_id\"\n" >> ./terraform.tfvars 159 | printf "location = \"$location\"\n" >> ./terraform.tfvars 160 | printf "subscription_id = \"$subscription_id\"\n" >> ./terraform.tfvars 161 | printf "user_object_id = \"$user_object_id\"\n" >> ./terraform.tfvars 162 | printf "\ntags = $tags\n" >> ./terraform.tfvars 163 | printf "\n# Enable modules here\n\n" >> ./terraform.tfvars 164 | printf "# enable_module_vnet_app = true\n" >> ./terraform.tfvars 165 | printf "# enable_module_vm_jumpbox_linux = true\n" >> ./terraform.tfvars 166 | printf "# enable_module_vm_mssql_win = true\n" >> ./terraform.tfvars 167 | printf "# enable_module_mssql = true\n" >> ./terraform.tfvars 168 | printf "# enable_module_mysql = true\n" >> ./terraform.tfvars 169 | printf "# enable_module_vwan = true\n" >> ./terraform.tfvars 170 | printf "# enable_module_vnet_onprem = true\n" >> ./terraform.tfvars 171 | 172 | cat ./terraform.tfvars 173 | 174 | exit 0 175 | #endregion 176 | -------------------------------------------------------------------------------- /scripts/cleanterraformtemp.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | printf "Removing all files matching 'terraform.tfvars'...\n" 4 | 5 | find ../. -type f -name 'terraform.tfvars' 6 | find ../. -type f -name 'terraform.tfvars' | xargs -r rm 7 | 8 | cd ..printf "Removing all files matching 'terraform.tfstate'...\n" 9 | 10 | find ../. -type f -name 'terraform.tfstate' 11 | find ../. -type f -name 'terraform.tfstate' | xargs -r rm 12 | 13 | printf "Removing all files matching 'terraform.tfstate.backup'...\n" 14 | 15 | find ../. -type f -name 'terraform.tfstate.backup' 16 | find ../. -type f -name 'terraform.tfstate.backup' | xargs -r rm 17 | 18 | printf "Removing all files matching 'terraform.tfstate.*.backup'...\n" 19 | 20 | find ../. -type f -name 'terraform.tfstate.*.backup' 21 | find ../. -type f -name 'terraform.tfstate.*.backup' | xargs -r rm 22 | 23 | printf "Removing all files and directories matching '.terraform'...\n" 24 | 25 | find ../. -type d -name '.terraform' 26 | find ../. -type d -name '.terraform' | xargs -r rm -r 27 | 28 | printf "Removing all files matching '.terraform.tfstate.lock.info'...\n" 29 | 30 | find ../. -type f -name '.terraform.tfstate.lock.info' 31 | find ../. -type f -name '.terraform.tfstate.lock.info' | xargs -r rm 32 | 33 | printf "Removing all files matching '.terraform.lock.hcl'...\n" 34 | 35 | find ../. -type f -name '.terraform.lock.hcl' 36 | find ../. -type f -name '.terraform.lock.hcl' | xargs -r rm 37 | 38 | printf "Removing all files matching 'sshkeytemp*'...\n" 39 | 40 | find ../. -type f -name 'sshkeytemp*' 41 | find ../. -type f -name 'sshkeytemp*' | xargs -r rm 42 | 43 | printf "Removing all files matching '*.pem'...\n" 44 | 45 | find ../. -type f -name '*.pem' 46 | find ../. -type f -name '*.pem' | xargs -r rm 47 | 48 | printf "Removing all files matching '*.pfx'...\n" 49 | 50 | find ../. -type f -name '*.pfx' 51 | find ../. -type f -name '*.pfx' | xargs -r rm 52 | 53 | exit 0 54 | -------------------------------------------------------------------------------- /scripts/configure-powershell.ps1: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env pwsh 2 | 3 | #region functions 4 | function Write-Log { 5 | param( [string] $msg) 6 | "$(Get-Date -Format FileDateTimeUniversal) : $msg" | Write-Host 7 | } 8 | function Exit-WithError { 9 | param( [string]$msg ) 10 | Write-Log "There was an exception during the process, please review..." 11 | Write-Log $msg 12 | Exit 2 13 | } 14 | #endregion 15 | 16 | #region main 17 | # Install PowerShell prerequisites 18 | $nugetPackage = Get-PackageProvider | Where-Object Name -eq 'NuGet' 19 | 20 | if ($null -eq $nugetPackage) { 21 | Write-Log "Installing NuGet PowerShell package provider..." 22 | 23 | try { 24 | Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force 25 | } 26 | catch { 27 | Exit-WithError $_ 28 | } 29 | } 30 | 31 | $nugetPackage = Get-PackageProvider | Where-Object Name -eq 'NuGet' 32 | Write-Log "NuGet Powershell Package Provider version $($nugetPackage.Version.Major).$($nugetPackage.Version.Minor).$($nugetPackage.Version.Build).$($nugetPackage.Version.Revision) is already installed..." 33 | 34 | $repo = Get-PSRepository -Name PSGallery 35 | if ( $repo.InstallationPolicy -eq 'Trusted' ) { 36 | Write-Log "PSGallery installation policy is already set to 'Trusted'..." 37 | } 38 | else { 39 | Write-Log "Setting PSGallery installation policy to 'Trusted'..." 40 | 41 | try { 42 | Set-PSRepository -Name PSGallery -InstallationPolicy Trusted 43 | } 44 | catch { 45 | Exit-WithError $_ 46 | } 47 | } 48 | 49 | $azModule = Get-Module -ListAvailable -Name Az* 50 | if ($null -eq $azModule ) { 51 | Write-Log "Installing PowerShell Az module..." 52 | 53 | try { 54 | Install-Module -Name Az -AllowClobber -Scope AllUsers 55 | } 56 | catch { 57 | Exit-WithError $_ 58 | } 59 | } 60 | else { 61 | Write-Log "PowerShell Az module is already installed..." 62 | } 63 | 64 | $azAutomationModule = Get-Module -ListAvailable -Name Az.Automation 65 | Write-Log "PowerShell Az.Automation version $($azAutomationModule.Version) is installed..." 66 | 67 | Exit 0 68 | #endregion 69 | -------------------------------------------------------------------------------- /terraform.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_version = "~> 1.12.1" 3 | 4 | required_providers { 5 | azapi = { 6 | source = "Azure/azapi" 7 | version = "~> 2.4.0" 8 | } 9 | 10 | azurerm = { 11 | source = "hashicorp/azurerm" 12 | version = "~> 4.31.0" 13 | } 14 | 15 | cloudinit = { 16 | source = "hashicorp/cloudinit" 17 | version = "~> 2.3.7" 18 | } 19 | 20 | random = { 21 | source = "hashicorp/random" 22 | version = "~> 3.7.2" 23 | } 24 | 25 | time = { 26 | source = "hashicorp/time" 27 | version = "~> 0.13.1" 28 | } 29 | 30 | tls = { 31 | source = "hashicorp/tls" 32 | version = "~> 4.1.0" 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /variables.tf: -------------------------------------------------------------------------------- 1 | variable "aad_tenant_id" { 2 | type = string 3 | description = "The Microsoft Entra tenant id." 4 | 5 | validation { 6 | condition = can(regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", var.aad_tenant_id)) 7 | error_message = "Must be a valid GUID in the format 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'." 8 | } 9 | } 10 | 11 | variable "arm_client_id" { 12 | type = string 13 | description = "The AppId of the service principal used for authenticating with Azure. Must have a 'Contributor' role assignment." 14 | 15 | validation { 16 | condition = can(regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", var.arm_client_id)) 17 | error_message = "Must be a valid GUID in the format 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'." 18 | } 19 | } 20 | 21 | variable "arm_client_secret" { 22 | type = string 23 | description = "The password for the service principal used for authenticating with Azure. Set interactively or using an environment variable 'TF_VAR_arm_client_secret'." 24 | sensitive = true 25 | 26 | validation { 27 | condition = length(var.arm_client_secret) >= 8 28 | error_message = "Must be at least 8 characters long." 29 | } 30 | } 31 | 32 | variable "enable_module_mssql" { 33 | type = bool 34 | description = "Set to true to enable the Azure SQL Database (mssql) module, false to skip it." 35 | default = false 36 | 37 | } 38 | 39 | variable "enable_module_mysql" { 40 | type = bool 41 | description = "Set to true to enable the Azure Database for MySQL (mysql) module, false to skip it." 42 | default = false 43 | 44 | } 45 | 46 | variable "enable_module_vm_jumpbox_linux" { 47 | type = bool 48 | description = "Set to true to enable the vm_jumpbox_linux module, false to skip it." 49 | default = false 50 | } 51 | 52 | variable "enable_module_vm_mssql_win" { 53 | type = bool 54 | description = "Set to true to enable the vm_mssql_win module, false to skip it." 55 | default = false 56 | } 57 | 58 | variable "enable_module_vnet_app" { 59 | type = bool 60 | description = "Set to true to enable the vnet_app module, false to skip it." 61 | default = false 62 | } 63 | 64 | variable "enable_module_vnet_onprem" { 65 | type = bool 66 | description = "Set to true to enable the vnet_onprem module, false to skip it." 67 | default = false 68 | } 69 | 70 | variable "enable_module_vwan" { 71 | type = bool 72 | description = "Set to true to enable the vwan module, false to skip it." 73 | default = false 74 | 75 | } 76 | 77 | variable "location" { 78 | type = string 79 | description = "The name of the Azure Region where resources will be provisioned." 80 | 81 | validation { 82 | condition = can(regex("^[a-z0-9-]+$", var.location)) 83 | error_message = "Must be a valid Azure region name. It should only contain lowercase letters, numbers, and dashes." 84 | } 85 | } 86 | 87 | variable "log_analytics_workspace_retention_days" { 88 | type = string 89 | description = "The retention period for the new log analytics workspace." 90 | default = "30" 91 | 92 | validation { 93 | condition = can(regex("^(30|31|60|90|120|180|270|365|550|730)$", var.log_analytics_workspace_retention_days)) 94 | error_message = "Must be one of the valid retention periods: 30, 31, 60, 90, 120, 180, 270, 365, 550, or 730 days." 95 | } 96 | } 97 | 98 | variable "subscription_id" { 99 | type = string 100 | description = "The Azure subscription id used to provision resources." 101 | 102 | validation { 103 | condition = can(regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", var.subscription_id)) 104 | error_message = "Must be a valid GUID in the format 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'." 105 | } 106 | } 107 | 108 | variable "tags" { 109 | type = map(any) 110 | description = "The tags in map format to be used when creating new resources." 111 | default = { costcenter = "mycostcenter", environment = "dev", project = "sand" } 112 | 113 | validation { 114 | condition = alltrue([ 115 | for key, value in var.tags : 116 | can(regex("^[a-zA-Z0-9._-]{1,512}$", key)) && 117 | can(regex("^[a-zA-Z0-9._ -]{0,256}$", value)) 118 | ]) 119 | error_message = "Each tag key must be 1-512 characters long and consist of alphanumeric characters, periods (.), underscores (_), or hyphens (-). Each tag value must be 0-256 characters long and consist of alphanumeric characters, periods (.), underscores (_), spaces, or hyphens (-)." 120 | } 121 | } 122 | 123 | variable "user_object_id" { 124 | type = string 125 | description = "The object id of the user in Microsoft Entra ID." 126 | 127 | validation { 128 | condition = can(regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", var.user_object_id)) 129 | error_message = "Must be a valid GUID in the format 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'." 130 | } 131 | } 132 | --------------------------------------------------------------------------------