id: gcp-deploy-ad-domain-controller
namespace: company.team
description: |
Create or delete a Windows Active Directory domain controller on Google Cloud.
Terraform provisions the Windows Server VM with a startup script that promotes
it to a DC, gcloud resets the admin password, the connection details are stored
in the KV store, and Ansible configures the reverse DNS lookup zone over WinRM.
inputs:
- id: action
type: SELECT
values:
- create
- delete
defaults: create
- id: domain
type: STRING
defaults: corp.example.local
- id: vmName
type: STRING
defaults: ad-controller
- id: ipAddress
type: STRING
defaults: 10.128.0.10
description: Internal IP address for your domain controller.
- id: remoteUserIpAddress
type: STRING
description: Your public IP for allowing RDP access. Check using `curl -s ifconfig.me`.
defaults: 203.0.113.10
- id: osFlavor
type: STRING
defaults: windows-cloud/windows-2022
- id: subnet
type: STRING
defaults: default
- id: subnetCIDR
type: STRING
defaults: 10.128.0.0/20
- id: zone
type: STRING
defaults: us-central1-a
- id: vmSize
type: STRING
defaults: e2-standard-4
- id: billingAccount
type: STRING
description: GCP project ID.
defaults: my-gcp-project
- id: network
type: STRING
defaults: default
- id: username
type: STRING
defaults: Administrator
variables:
main_tf: |
resource "google_compute_firewall" "allow_rdp" {
name = "allow-rdp-ad-admin"
network = "{{ inputs.network }}"
project = "{{ inputs.billingAccount }}"
allow {
protocol = "tcp"
ports = ["3389", "53", "88", "135", "389", "445", "464", "5985", "5986"]
}
allow {
protocol = "udp"
ports = ["53", "88", "389", "464", "123"]
}
allow {
protocol = "icmp"
}
source_ranges = ["{{ inputs.remoteUserIpAddress }}/32", "{{ inputs.subnetCIDR }}"]
target_tags = ["rdp-enabled"]
}
resource "google_compute_instance" "ad_dc" {
name = "{{ inputs.vmName }}"
machine_type = "{{ inputs.vmSize }}"
zone = "{{ inputs.zone }}"
project = "{{ inputs.billingAccount }}"
tags = ["rdp-enabled"]
boot_disk {
initialize_params {
image = "{{ inputs.osFlavor }}"
size = 50
}
}
network_interface {
network = "{{ inputs.network }}"
subnetwork = "{{ inputs.subnet }}"
subnetwork_project = "{{ inputs.billingAccount }}"
network_ip = "{{ inputs.ipAddress }}"
access_config {}
}
metadata = {
windows-startup-script-ps1 = file("${path.module}/setup-dc.ps1")
}
service_account {
scopes = ["cloud-platform"]
}
}
output "external_ip" {
value = google_compute_instance.ad_dc.network_interface[0].access_config[0].nat_ip
description = "The external IP address of the VM"
}
setup_dc_ps1: |
# Startup script that promotes the Windows Server to a domain controller.
# Condensed for the blueprint; extend with your full hardening baseline.
# Enable WinRM so Ansible can manage the host
Enable-PSRemoting -Force -SkipNetworkProfileCheck
Set-Item -Path WSMan:\localhost\Service\AllowUnencrypted -Value $true
Set-Item -Path WSMan:\localhost\Service\Auth\Basic -Value $true
$InternalSubnet = "{{ inputs.subnetCIDR }}"
$PasswordString = "{{ secret('AD_ADMIN_PASSWORD') }}"
$Secret = ConvertTo-SecureString $PasswordString -AsPlainText -Force
# Allow internal subnet traffic and NTP (Kerberos requires time sync)
New-NetFirewallRule -DisplayName "AD-Internal" -Direction Inbound -Action Allow -Protocol TCP -LocalPort Any -RemoteAddress $InternalSubnet
New-NetFirewallRule -DisplayName "AD-Internal-UDP" -Direction Inbound -Action Allow -Protocol UDP -LocalPort Any -RemoteAddress $InternalSubnet
Set-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Services\W32Time\TimeProviders\NtpServer" -Name "Enabled" -Value 1
New-NetFirewallRule -DisplayName "NTP-Inbound-UDP" -Direction Inbound -Action Allow -Protocol UDP -LocalPort 123
if (!(Get-WindowsFeature AD-Domain-Services).Installed) {
Install-WindowsFeature -Name AD-Domain-Services, DNS, RSAT-DNS-Server, RSAT-AD-PowerShell -IncludeManagementTools
}
Import-Module DnsServer -ErrorAction SilentlyContinue
Set-DnsServerForwarder -IPAddress "8.8.8.8" -PassThru -ErrorAction SilentlyContinue
# Promote only if not already a domain controller (idempotent on reboot)
$IsDC = (Get-WmiObject Win32_ComputerSystem).DomainRole
if ($IsDC -ne 4 -and $IsDC -ne 5) {
net user Administrator $PasswordString
$ADParams = @{
DomainName = "{{ inputs.domain }}"
DomainNetbiosName = "{{ inputs.domain | split('\.') | first | upper }}"
SafeModeAdministratorPassword = $Secret
InstallDns = $true
Force = $true
NoRebootOnCompletion = $false
}
Import-Module ADDSDeployment
Install-ADDSForest @ADParams
}
tasks:
- id: createOrDelete
type: io.kestra.plugin.core.flow.If
condition: "{{ inputs.action == 'create' }}"
then:
- id: createADController
type: io.kestra.plugin.terraform.cli.TerraformCLI
env:
GOOGLE_APPLICATION_CREDENTIALS: gcp-svc-account.json
inputFiles:
gcp-svc-account.json: "{{ secret('GCP_SERVICE_ACCOUNT_JSON') }}"
main.tf: "{{ render(vars.main_tf) }}"
setup-dc.ps1: "{{ render(vars.setup_dc_ps1) }}"
backend.tf: |
terraform {
backend "gcs" {
# Replace with your Terraform state bucket
bucket = "my-terraform-state-bucket"
}
}
beforeCommands:
- terraform init -backend-config="prefix={{ inputs.vmName }}"
commands:
- terraform plan
- terraform apply -auto-approve -json
- terraform output -raw external_ip > external_ip.txt
outputFiles:
- "*.txt"
assets:
outputs:
- id: "{{ inputs.vmName }}"
type: io.kestra.plugin.ee.assets.VM
metadata:
vmName: "{{ inputs.vmName }}"
vmSize: "{{ inputs.vmSize }}"
zone: "{{ inputs.zone }}"
billingAccount: "{{ inputs.billingAccount }}"
osFlavor: "{{ inputs.osFlavor }}"
network: "{{ inputs.network }}"
subnet: "{{ inputs.subnet }}"
ipAddress: "{{ inputs.ipAddress }}"
state: active
- id: waitForStartupBootConfig
type: io.kestra.plugin.core.flow.Sleep
description: Give the DC promotion startup script time to complete and reboot.
duration: PT10M
- id: resetPassword
type: io.kestra.plugin.gcp.cli.GCloudCLI
description: Generate Windows credentials for the admin user.
inputFiles:
gcp-svc-account.json: "{{ secret('GCP_SERVICE_ACCOUNT_JSON') }}"
projectId: "{{ inputs.billingAccount }}"
commands:
- gcloud auth activate-service-account --key-file=gcp-svc-account.json
- gcloud compute reset-windows-password "{{ inputs.vmName }}" --user
"{{ inputs.username }}" --zone "{{ inputs.zone }}" --project "{{
inputs.billingAccount }}" --quiet --format json > credentials.json
outputFiles:
- credentials.json
- id: createADConfigMap
type: io.kestra.plugin.core.kv.Set
kvType: JSON
description: Holds the internal/external IP addresses, domain/VM name, and admin
username.
key: dcConfig
value: |
{
"internalIPAddress": "{{ inputs.ipAddress }}",
"externalIPAddress": "{{ fromJson(read(outputs.resetPassword.outputFiles['credentials.json'])).ip_address }}",
"username": "{{ inputs.username }}",
"domainName": "{{ inputs.domain }}",
"vmName": "{{ inputs.vmName }}",
"subnetCIDR": "{{ inputs.subnetCIDR }}"
}
- id: enableReverseLookupZone
type: io.kestra.plugin.ansible.cli.AnsibleCLI
description: Set the domain admin password and create the reverse DNS zone over
WinRM.
beforeCommands:
- pip install pywinrm
inputFiles:
inventory.ini: |
[windows_vms]
{{ kv('dcConfig').externalIPAddress }}
[windows_vms:vars]
ansible_user={{ kv('dcConfig').username }}
ansible_password="{{ fromJson(read(outputs.resetPassword.outputFiles['credentials.json'])).password }}"
ansible_connection=winrm
ansible_port=5985
ansible_winrm_server_cert_validation=ignore
ansible_winrm_transport=ntlm
playbook.yml: |
---
- name: Configure the new domain controller
hosts: windows_vms
gather_facts: no
tasks:
- name: Set a durable password for the admin user
ansible.windows.win_user:
name: "{{ fromJson(read(outputs.resetPassword.outputFiles['credentials.json'])).username }}"
password: "{{ secret('AD_ADMIN_PASSWORD') }}"
state: present
- name: Create the reverse lookup zone
ansible.windows.win_shell: |
$ZoneName = "{{ (inputs.subnetCIDR | split('\.'))[1] }}.{{ (inputs.subnetCIDR | split('\.'))[0] }}.in-addr.arpa"
if (!(Get-DnsServerZone -Name $ZoneName -ErrorAction SilentlyContinue)) {
Add-DnsServerPrimaryZone -Name $ZoneName -ReplicationScope Forest -ErrorAction Stop
Write-Output "Reverse zone $ZoneName created successfully."
} else {
Write-Output "Reverse zone $ZoneName already exists. Skipping."
}
register: dns_script_output
- name: Print output to Kestra logs
debug:
var: dns_script_output.stdout_lines
commands:
- ansible-playbook -i inventory.ini playbook.yml
else:
- id: deleteADController
type: io.kestra.plugin.terraform.cli.TerraformCLI
env:
GOOGLE_APPLICATION_CREDENTIALS: gcp-svc-account.json
inputFiles:
gcp-svc-account.json: "{{ secret('GCP_SERVICE_ACCOUNT_JSON') }}"
main.tf: "{{ render(vars.main_tf) }}"
setup-dc.ps1: "{{ render(vars.setup_dc_ps1) }}"
backend.tf: |
terraform {
backend "gcs" {
# Replace with your Terraform state bucket
bucket = "my-terraform-state-bucket"
}
}
beforeCommands:
- terraform init -backend-config="prefix={{ inputs.vmName }}"
commands:
- terraform plan
- terraform destroy -auto-approve -json
assets:
outputs:
- id: "{{ inputs.vmName }}"
type: io.kestra.plugin.ee.assets.VM
metadata:
vmName: "{{ inputs.vmName }}"
zone: "{{ inputs.zone }}"
billingAccount: "{{ inputs.billingAccount }}"
state: deleted