diff --git a/plugins/action/domain.py b/plugins/action/domain.py index 36cdb26..803f94d 100644 --- a/plugins/action/domain.py +++ b/plugins/action/domain.py @@ -1,34 +1,8 @@ # Copyright (c) 2022 Ansible Project # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) -import typing as t +from ..plugin_utils._module_with_reboot import DomainPromotionWithReboot -from ..plugin_utils._module_with_reboot import ActionModuleWithReboot - -class ActionModule(ActionModuleWithReboot): - def __init__(self, *args: t.Any, **kwargs: t.Any) -> None: - super().__init__(*args, **kwargs) - self._ran_once = False - - def _ad_should_rerun(self, result: t.Dict[str, t.Any]) -> bool: - ran_once = self._ran_once - self._ran_once = True - - if ran_once or not result.get("_do_action_reboot", False): - return False - - if self._task.check_mode: - # Assume that on a rerun it will not have failed and that it - # ran successfull. - result["failed"] = False - result.pop("msg", None) - return False - - else: - return True - - def _ad_process_result(self, result: t.Dict[str, t.Any]) -> t.Dict[str, t.Any]: - result.pop("_do_action_reboot", None) - - return result +class ActionModule(DomainPromotionWithReboot): + ... diff --git a/plugins/action/domain_child.py b/plugins/action/domain_child.py new file mode 100644 index 0000000..ecc566c --- /dev/null +++ b/plugins/action/domain_child.py @@ -0,0 +1,8 @@ +# Copyright (c) 2024 Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from ..plugin_utils._module_with_reboot import DomainPromotionWithReboot + + +class ActionModule(DomainPromotionWithReboot): + ... diff --git a/plugins/action/domain_controller.py b/plugins/action/domain_controller.py index 36cdb26..803f94d 100644 --- a/plugins/action/domain_controller.py +++ b/plugins/action/domain_controller.py @@ -1,34 +1,8 @@ # Copyright (c) 2022 Ansible Project # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) -import typing as t +from ..plugin_utils._module_with_reboot import DomainPromotionWithReboot -from ..plugin_utils._module_with_reboot import ActionModuleWithReboot - -class ActionModule(ActionModuleWithReboot): - def __init__(self, *args: t.Any, **kwargs: t.Any) -> None: - super().__init__(*args, **kwargs) - self._ran_once = False - - def _ad_should_rerun(self, result: t.Dict[str, t.Any]) -> bool: - ran_once = self._ran_once - self._ran_once = True - - if ran_once or not result.get("_do_action_reboot", False): - return False - - if self._task.check_mode: - # Assume that on a rerun it will not have failed and that it - # ran successfull. - result["failed"] = False - result.pop("msg", None) - return False - - else: - return True - - def _ad_process_result(self, result: t.Dict[str, t.Any]) -> t.Dict[str, t.Any]: - result.pop("_do_action_reboot", None) - - return result +class ActionModule(DomainPromotionWithReboot): + ... diff --git a/plugins/modules/domain.py b/plugins/modules/domain.py index 15578f7..0d93592 100644 --- a/plugins/modules/domain.py +++ b/plugins/modules/domain.py @@ -99,6 +99,7 @@ bypass_host_loop: support: none seealso: +- module: microsoft.ad.domain_child - module: microsoft.ad.domain_controller - module: microsoft.ad.group - module: microsoft.ad.membership diff --git a/plugins/modules/domain_child.ps1 b/plugins/modules/domain_child.ps1 new file mode 100644 index 0000000..85fe305 --- /dev/null +++ b/plugins/modules/domain_child.ps1 @@ -0,0 +1,242 @@ +#!powershell + +# Copyright (c) 2024 Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#AnsibleRequires -CSharpUtil Ansible.Basic + +$spec = @{ + options = @{ + create_dns_delegation = @{ + type = 'bool' + } + database_path = @{ + type = 'path' + } + dns_domain_name = @{ + type = 'str' + } + domain_admin_password = @{ + type = 'str' + required = $true + no_log = $true + } + domain_admin_user = @{ + type = 'str' + required = $true + } + domain_mode = @{ + type = 'str' + } + domain_type = @{ + choices = 'child', 'tree' + default = 'child' + type = 'str' + } + install_dns = @{ + type = 'bool' + } + log_path = @{ + type = 'path' + } + parent_domain_name = @{ + type = 'str' + } + reboot = @{ + default = $false + type = 'bool' + } + safe_mode_password = @{ + type = 'str' + required = $true + no_log = $true + } + site_name = @{ + type = 'str' + } + sysvol_path = @{ + type = 'path' + } + } + required_if = @( + , @('domain_type', 'tree', @('parent_domain_name')) + ) + required_together = @( + , @("domain_admin_user", "domain_admin_password") + ) + supports_check_mode = $true +} +$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec) + +$module.Result.reboot_required = $false +$module.Result._do_action_reboot = $false # Used by action plugin + +$createDnsDelegation = $module.Params.create_dns_delegation +$databasePath = $module.Params.database_path +$dnsDomainName = $module.Params.dns_domain_name +$domainMode = $module.Params.domain_mode +$domainType = $module.Params.domain_type +$installDns = $module.Params.install_dns +$logPath = $module.Params.log_path +$parentDomainName = $module.Params.parent_domain_name +$safeModePassword = $module.Params.safe_mode_password +$siteName = $module.Params.site_name +$sysvolPath = $module.Params.sysvol_path + +$domainCredential = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList @( + $module.Params.domain_admin_user, + (ConvertTo-SecureString -AsPlainText -Force -String $module.Params.domain_admin_password) +) + +if ($domainType -eq 'child' -and $parentDomainName) { + $module.FailJson("parent_domain_name must not be set when domain_type=child") +} + +$requiredFeatures = @("AD-Domain-Services", "RSAT-ADDS") +$features = Get-WindowsFeature -Name $requiredFeatures +$unavailableFeatures = Compare-Object -ReferenceObject $requiredFeatures -DifferenceObject $features.Name -PassThru + +if ($unavailableFeatures) { + $module.FailJson("The following features required for a domain child are unavailable: $($unavailableFeatures -join ',')") +} + +$missingFeatures = $features | Where-Object InstallState -NE Installed +if ($missingFeatures) { + $res = Install-WindowsFeature -Name $missingFeatures -WhatIf:$module.CheckMode + $module.Result.changed = $true + $module.Result.reboot_required = [bool]$res.RestartNeeded + + # When in check mode and the prereq was "installed" we need to exit early as + # the AD cmdlets weren't really installed + if ($module.CheckMode) { + $module.ExitJson() + } +} + +# Check that we got a valid domain_mode +$validDomainModes = [Enum]::GetNames((Get-Command -Name Install-ADDSDomain).Parameters.DomainMode.ParameterType) +if (($null -ne $domainMode) -and -not ($domainMode -in $validDomainModes)) { + $validModes = $validDomainModes -join ", " + $module.FailJson("The parameter 'domain_mode' does not accept '$domainMode', please use one of: $validModes") +} + +$systemRole = Get-CimInstance -ClassName Win32_ComputerSystem -Property Domain, DomainRole +if ($systemRole.DomainRole -in @(4, 5)) { + if ($systemRole.Domain -ne $dnsDomainName) { + $module.FailJson("Host is already a domain controller in another domain $($systemRole.Domain)") + } + $module.ExitJson() +} + +$installParams = @{ + Confirm = $false + Credential = $domainCredential + Force = $true + NoRebootOnCompletion = $true + SafeModeAdministratorPassword = (ConvertTo-SecureString $safeModePassword -AsPlainText -Force) + SkipPreChecks = $true + WhatIf = $module.CheckMode +} + +if ($domainType -eq 'child') { + $newDomainName, $parentDomainName = $dnsDomainName.Split([char[]]".", 2) + $installParams.DomainType = 'ChildDomain' + $installParams.NewDomainName = $newDomainName + $installParams.ParentDomainName = $parentDomainName +} +else { + $installParams.DomainType = 'TreeDomain' + $installParams.NewDomainName = $dnsDomainName + $installParams.ParentDomainName = $parentDomainName +} + +if ($null -ne $createDnsDelegation) { + $installParams.CreateDnsDelegation = $createDnsDelegation +} +if ($databasePath) { + $installParams.DatabasePath = $databasePath +} +if ($domainMode) { + $installParams.DomainMode = $domainMode +} +if ($null -ne $installDns) { + $installParams.InstallDns = $installDns +} +if ($logPath) { + $installParams.LogPath = $logPath +} +if ($siteName) { + $installParams.SiteName = $siteName +} +if ($sysvolPath) { + $installParams.SysvolPath = $sysvolPath +} + +try { + $null = Install-ADDSDomain @installParams +} +catch [Microsoft.DirectoryServices.Deployment.DCPromoExecutionException] { + # ExitCode 15 == 'Role change is in progress or this computer needs to be restarted.' + # DCPromo exit codes details can be found at + # https://docs.microsoft.com/en-us/windows-server/identity/ad-ds/deploy/troubleshooting-domain-controller-deployment + if ($_.Exception.ExitCode -in @(15, 19)) { + $module.Result.reboot_required = $true + $module.Result._do_action_reboot = $true + } + + $module.FailJson("Failed to install ADDSDomain, DCPromo exited with $($_.Exception.ExitCode)", $_) +} +finally { + # The Netlogon service is set to auto start but is not started. This is + # required for Ansible to connect back to the host and reboot in a + # later task. Even if this fails Ansible can still connect but only + # with ansible_winrm_transport=basic so we just display a warning if + # this fails. + if (-not $module.CheckMode) { + try { + Start-Service -Name Netlogon + } + catch { + $msg = -join @( + "Failed to start the Netlogon service after promoting the host, " + "Ansible may be unable to connect until the host is manually rebooted: $($_.Exception.Message)" + ) + $module.Warn($msg) + } + } +} + +$module.Result.changed = $true +$module.Result.reboot_required = $true + +if ($module.Result.reboot_required -and $module.Params.reboot -and -not $module.CheckMode) { + # Promoting or depromoting puts the server in a very funky state and it may + # not be possible for Ansible to connect back without a reboot is done. If + # the user requested the action plugin to perform the reboot then start it + # here and get the action plugin to continue where this left off. + + $lastBootTime = (Get-CimInstance -ClassName Win32_OperatingSystem -Property LastBootUpTime).LastBootUpTime.ToFileTime() + $module.Result._previous_boot_time = $lastBootTime + + $shutdownRegPath = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon\AutoLogonChecked' + Remove-Item -LiteralPath $shutdownRegPath -Force -ErrorAction SilentlyContinue + + $comment = 'Reboot initiated by Ansible' + $stdout = $null + $stderr = . { shutdown.exe /r /t 10 /c $comment | Set-Variable stdout } 2>&1 | ForEach-Object ToString + if ($LASTEXITCODE -eq 1190) { + # A reboot was already scheduled, abort it and try again + shutdown.exe /a + $stdout = $null + $stderr = . { shutdown.exe /r /t 10 /c $comment | Set-Variable stdout } 2>&1 | ForEach-Object ToString + } + + if ($LASTEXITCODE) { + $module.Result.rc = $LASTEXITCODE + $module.Result.stdout = $stdout + $module.Result.stderr = $stderr + $module.FailJson("Failed to initiate reboot, see rc, stdout, stderr for more information") + } +} + +$module.ExitJson() diff --git a/plugins/modules/domain_child.yml b/plugins/modules/domain_child.yml new file mode 100644 index 0000000..90b6002 --- /dev/null +++ b/plugins/modules/domain_child.yml @@ -0,0 +1,183 @@ +# Copyright (c) 2024 Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION: + module: domain_child + short_description: Manage domain children in an existing Active Directory forest. + description: + - Ensure that a Windows Server host is configured as a domain controller as + a new domain in an existing forest. + - This module may require subsequent use of the + M(ansible.windows.win_reboot) action if changes are made. + - This module will only check if the domain specified by I(dns_domain_name) + exists or not. If the domain already exists under the same name, no other + options, other than the domain name will be checked during the run. + options: + create_dns_delegation: + description: + - Whether to create a DNS delegation that references the new DNS + server that was installed. + - Valid for Active Directory-integrated DNS only. + - The default is computed automatically based on the environment. + type: bool + database_path: + description: + - The path to a directory on a fixed disk of the Windows host where the + domain database will be created.. + - If not set then the default path is C(%SYSTEMROOT%\NTDS). + type: path + dns_domain_name: + description: + - The full DNS name of the domain to create. + - When I(domain_type=child), the parent DNS domain name is derived + from this value. + type: str + domain_admin_password: + description: + - Password for the specified I(domain_admin_user). + type: str + required: true + domain_admin_user: + description: + - Username of a domain admin for the parent domain. + type: str + required: true + domain_mode: + description: + - Specifies the domain functional level of child/tree. + - The domain functional level cannot be lower than the forest + functional level, but it can be higher. + - The default is automatically computed and set. + - Current known modes are C(Win2003), C(Win2008), C(Win2008R2), + C(Win2012), C(Win2012R2), or C(WinThreshold). + type: str + domain_type: + description: + - Specifies the type of domain to create. + - Set to C(child) to create a child of an existing domain as specified + by I(dns_domain_name). + - Set to C(tree) to create a new domain tree in an existing forest as + specified by I(parent_domain_name). The I(dns_domain_name) must be + the full domain name of the new domain tree to create. + choices: + - child + - tree + default: child + install_dns: + description: + - Whether to install the DNS service when creating the domain + controller. + - If not specified then the C(-InstallDns) option is not supplied to + the C(Install-ADDSDomain) command, see + L(Install-ADDSDomain,https://learn.microsoft.com/en-us/powershell/module/addsdeployment/install-addsdomain#-installdns) + for more information. + type: bool + log_path: + description: + - Specified the fully qualified, non-UNC path to a directory on a fixed + disk of the local computer that will contain the domain log files. + type: path + parent_domain_name: + description: + - The fully qualified domain name of an existing parent domain to + create a new domain tree in. + - This can only be set when I(domain_type=tree). + type: str + reboot: + description: + - If C(true), this will reboot the host if a reboot was create the + domain. + - If C(false), this will not reboot the host if a reboot was required + and instead sets the I(reboot_required) return value to C(true). + - Multiple reboots may occur if the host required a reboot before the + domain promotion. + - This cannot be used with async mode. + type: bool + default: false + safe_mode_password: + description: + - Safe mode password for the domain controller. + required: true + type: str + site_name: + description: + - Specifies the name of an existing site where you can place the new + domain controller. + type: str + sysvol_path: + description: + - The path to a directory on a fixed disk of the Windows host where the + Sysvol folder will be created. + - If not set then the default path is C(%SYSTEMROOT%\SYSVOL). + type: path + notes: + - It is highly recommended to set I(reboot=true) to have Ansible manage the + host reboot phase as the actions done by this module puts the host in a + state where it may not be possible for Ansible to reconnect in a + subsequent task without a reboot. + - This module must be run on a Windows target host. + extends_documentation_fragment: + - ansible.builtin.action_common_attributes + - ansible.builtin.action_common_attributes.flow + attributes: + check_mode: + support: full + diff_mode: + support: none + platform: + platforms: + - windows + action: + support: full + async: + support: partial + details: Supported for all scenarios except with I(reboot=True). + bypass_host_loop: + support: none + seealso: + - module: microsoft.ad.domain + - module: microsoft.ad.domain_controller + author: + - Jordan Borean (@jborean93) + +EXAMPLES: | + - name: Create a child domain foo.example.com with parent example.com + microsoft.ad.domain_child: + dns_domain_name: foo.example.com + domain_admin_user: testguy@example.com + domain_admin_password: password123! + safe_mode_password: password123! + reboot: true + + - name: Create a domain tree foo.example.com with parent bar.example.com + microsoft.ad.domain_child: + dns_domain_name: foo.example.com + parent_domain_name: bar.example.com + domain_type: tree + domain_admin_user: testguy@bar.example.com + domain_admin_password: password123! + local_admin_password: password123! + reboot: true + + # This scenario is not recommended, use reboot: true when possible + - name: Promote server with custom paths with manual reboot task + microsoft.ad.domain_child: + dns_domain_name: foo.ansible.vagrant + domain_admin_user: testguy@ansible.vagrant + domain_admin_password: password123! + safe_mode_password: password123! + sysvol_path: D:\SYSVOL + database_path: D:\NTDS + log_path: D:\NTDS + register: dc_promotion + + - name: Reboot after promotion + microsoft.ad.win_reboot: + when: dc_promotion.reboot_required + +RETURNS: + reboot_required: + description: True if changes were made that require a reboot. + returned: always + type: bool + sample: true diff --git a/plugins/modules/domain_controller.py b/plugins/modules/domain_controller.py index 7bcab2d..46ecdb6 100644 --- a/plugins/modules/domain_controller.py +++ b/plugins/modules/domain_controller.py @@ -114,6 +114,7 @@ seealso: - module: microsoft.ad.computer - module: microsoft.ad.domain +- module: microsoft.ad.domain_child - module: microsoft.ad.group - module: microsoft.ad.membership - module: microsoft.ad.user diff --git a/plugins/plugin_utils/_module_with_reboot.py b/plugins/plugin_utils/_module_with_reboot.py index ebc46ea..95e2346 100644 --- a/plugins/plugin_utils/_module_with_reboot.py +++ b/plugins/plugin_utils/_module_with_reboot.py @@ -156,7 +156,7 @@ def run( if self._ad_should_rerun(module_res) and not self._task.check_mode: display.vv( - "Module result has indicated it should rerun after a reboot has occured, rerunning" + "Module result has indicated it should rerun after a reboot has occurred, rerunning" ) continue @@ -169,3 +169,38 @@ def run( result = merge_hash(result, module_res) return self._ad_process_result(result) + + +class DomainPromotionWithReboot(ActionModuleWithReboot): + """Domain Promotion Action Plugin with Auto Reboot. + + An action plugin that runs a task that can promote the target Windows host + to a domain controller. It implements the common reboot handling for that + particular task. + """ + + def __init__(self, *args: t.Any, **kwargs: t.Any) -> None: + super().__init__(*args, **kwargs) + self._ran_once = False + + def _ad_should_rerun(self, result: t.Dict[str, t.Any]) -> bool: + ran_once = self._ran_once + self._ran_once = True + + if ran_once or not result.get("_do_action_reboot", False): + return False + + if self._task.check_mode: + # Assume that on a rerun it will not have failed and that it + # ran successful. + result["failed"] = False + result.pop("msg", None) + return False + + else: + return True + + def _ad_process_result(self, result: t.Dict[str, t.Any]) -> t.Dict[str, t.Any]: + result.pop("_do_action_reboot", None) + + return result diff --git a/tests/integration/targets/domain_child/README.md b/tests/integration/targets/domain_child/README.md new file mode 100644 index 0000000..f7bc08f --- /dev/null +++ b/tests/integration/targets/domain_child/README.md @@ -0,0 +1,36 @@ +# microsoft.ad.domain_child tests + +As this cannot be run in CI this is a brief guide on how to run these tests locally. +Run the following: + +```bash +vagrant up + +ansible-playbook setup.yml +``` + +It is a good idea to create a snapshot of both hosts before running the tests. +This allows you to reset the host back to a blank starting state if the tests need to be rerun. +To create a snapshot do the following: + +```bash +virsh snapshot-create-as --domain "domain_child_PARENT" --name "pretest" +virsh snapshot-create-as --domain "domain_child_CHILD" --name "pretest" +virsh snapshot-create-as --domain "domain_child_TREE" --name "pretest" +``` + +To restore these snapshots run the following: + +```bash +virsh snapshot-revert --domain "domain_child_PARENT" --snapshotname "pretest" --running +virsh snapshot-revert --domain "domain_child_CHILD" --snapshotname "pretest" --running +virsh snapshot-revert --domain "domain_child_TREE" --snapshotname "pretest" --running +``` + +Once you are ready to run the tests run the following: + +```bash +ansible-playbook test.yml +``` + +Run `vagrant destroy` to remove the test VMs. diff --git a/tests/integration/targets/domain_child/Vagrantfile b/tests/integration/targets/domain_child/Vagrantfile new file mode 100644 index 0000000..13af403 --- /dev/null +++ b/tests/integration/targets/domain_child/Vagrantfile @@ -0,0 +1,27 @@ +# -*- mode: ruby -*- +# vi: set ft=ruby : + +require 'yaml' + +inventory = YAML.load_file('inventory.yml') + +Vagrant.configure("2") do |config| + inventory['all']['children'].each do |group,details| + details['hosts'].each do |server,host_details| + config.vm.define server do |srv| + srv.vm.box = host_details['vagrant_box'] + srv.vm.hostname = server + srv.vm.network :private_network, + :ip => host_details['ansible_host'], + :libvirt__network_name => 'microsoft.ad', + :libvirt__domain_name => inventory['all']['vars']['domain_realm'] + + srv.vm.provider :libvirt do |l| + l.memory = 8192 + l.cpus = 4 + end + end + end + end +end + diff --git a/tests/integration/targets/domain_child/aliases b/tests/integration/targets/domain_child/aliases new file mode 100644 index 0000000..435ff20 --- /dev/null +++ b/tests/integration/targets/domain_child/aliases @@ -0,0 +1,2 @@ +windows +unsupported # can never run in CI, see README.md diff --git a/tests/integration/targets/domain_child/ansible.cfg b/tests/integration/targets/domain_child/ansible.cfg new file mode 100644 index 0000000..cfedec7 --- /dev/null +++ b/tests/integration/targets/domain_child/ansible.cfg @@ -0,0 +1,4 @@ +[defaults] +callback_result_format = yaml +inventory = inventory.yml +retry_files_enabled = False diff --git a/tests/integration/targets/domain_child/inventory.yml b/tests/integration/targets/domain_child/inventory.yml new file mode 100644 index 0000000..e57f755 --- /dev/null +++ b/tests/integration/targets/domain_child/inventory.yml @@ -0,0 +1,28 @@ +all: + children: + windows: + hosts: + PARENT: + ansible_host: 192.168.11.10 + vagrant_box: jborean93/WindowsServer2022 + CHILD: + ansible_host: 192.168.11.11 + vagrant_box: jborean93/WindowsServer2022 + new_hostname: foo + child_domain_name: child.ad.test + TREE: + ansible_host: 192.168.11.12 + vagrant_box: jborean93/WindowsServer2022 + new_hostname: bar + child_domain_name: tree.test + vars: + ansible_port: 5985 + ansible_connection: psrp + + vars: + ansible_user: vagrant + ansible_password: vagrant + domain_username: vagrant-domain + domain_user_upn: '{{ domain_username }}@{{ domain_realm | upper }}' + domain_password: VagrantPass1 + domain_realm: ad.test diff --git a/tests/integration/targets/domain_child/setup.yml b/tests/integration/targets/domain_child/setup.yml new file mode 100644 index 0000000..de08438 --- /dev/null +++ b/tests/integration/targets/domain_child/setup.yml @@ -0,0 +1,71 @@ +- name: setup common Windows information + hosts: windows + gather_facts: no + + tasks: + - name: get network connection names + ansible.windows.win_powershell: + parameters: + IPAddress: '{{ ansible_host }}' + script: | + param ($IPAddress) + + $Ansible.Changed = $false + + Get-CimInstance -ClassName Win32_NetworkAdapter -Filter "Netenabled='True'" | + ForEach-Object -Process { + $config = Get-CimInstance -ClassName Win32_NetworkAdapterConfiguration -Filter "Index='$($_.Index)'" + if ($config.IPAddress -contains $IPAddress) { + $_.NetConnectionID + } + } + register: connection_name + +- name: create parent forest + hosts: PARENT + gather_facts: no + + tasks: + - name: set the DNS for the internal adapters to localhost + ansible.windows.win_dns_client: + adapter_names: + - '{{ connection_name.output[0] }}' + dns_servers: + - 127.0.0.1 + + - name: ensure domain exists and DC is promoted as a domain controller + microsoft.ad.domain: + dns_domain_name: '{{ domain_realm }}' + safe_mode_password: '{{ domain_password }}' + reboot: true + + - name: create parent domain username + microsoft.ad.user: + name: '{{ domain_username }}' + upn: '{{ domain_user_upn }}' + description: '{{ domain_username }} Domain Account' + password: '{{ domain_password }}' + password_never_expires: yes + update_password: when_changed + groups: + add: + - Domain Admins + - Enterprise Admins + state: present + +- name: setup test host + hosts: CHILD,TREE + gather_facts: no + + tasks: + - name: set DNS for the private adapter to point to the parent forest DC + ansible.windows.win_dns_client: + adapter_names: + - '{{ connection_name.output[0] }}' + dns_servers: + - '{{ hostvars["PARENT"]["ansible_host"] }}' + + - name: install RSAT tools for debugging purposes + ansible.windows.win_feature: + name: RSAT-AD-PowerShell + state: present diff --git a/tests/integration/targets/domain_child/tasks/main_child.yml b/tests/integration/targets/domain_child/tasks/main_child.yml new file mode 100644 index 0000000..40f4f2e --- /dev/null +++ b/tests/integration/targets/domain_child/tasks/main_child.yml @@ -0,0 +1,98 @@ +- name: create child domain - check mode + domain_child: + dns_domain_name: '{{ child_domain_name }}' + domain_admin_user: '{{ domain_user_upn }}' + domain_admin_password: '{{ domain_password }}' + safe_mode_password: '{{ domain_password }}' + reboot: true + register: to_domain_check + check_mode: true + +- name: get result of promote to domain - check mode + ansible.windows.win_powershell: + script: '{{ get_role_script }}' + register: to_domain_check_actual + +- name: assert promote to domain - check mode + assert: + that: + - to_domain_check is changed + - to_domain_check_actual.output[0]["Domain"] == None + - to_domain_check_actual.output[0]["DomainRole"] == "StandaloneServer" + +- name: change hostname to have a pending change before promotion + ansible.windows.win_hostname: + name: '{{ new_hostname }}' + +- name: create child domain with pending reboot + domain_child: + dns_domain_name: '{{ child_domain_name }}' + domain_admin_user: '{{ domain_user_upn }}' + domain_admin_password: '{{ domain_password }}' + safe_mode_password: '{{ domain_password }}' + reboot: true + register: to_domain + +- name: get result of promote to domain with pending reboot + ansible.windows.win_powershell: + script: '{{ get_role_script }}' + register: to_domain_actual + +- name: assert promote to domain with pending reboot + assert: + that: + - to_domain is changed + - to_domain_actual.output[0]["Domain"] == child_domain_name + - to_domain_actual.output[0]["DomainRole"] == "PrimaryDC" + - to_domain_actual.output[0]["HostName"] == new_hostname | upper + +- name: create child domain - idempotent + domain_child: + dns_domain_name: '{{ child_domain_name }}' + domain_admin_user: '{{ domain_user_upn }}' + domain_admin_password: '{{ domain_password }}' + safe_mode_password: '{{ domain_password }}' + reboot: true + register: to_domain_again + +- name: assert create child domain - idempotent + assert: + that: + - not to_domain_again is changed + +- name: fail to change domain of host + domain_child: + dns_domain_name: bogus.local + domain_admin_user: '{{ domain_user_upn }}' + domain_admin_password: '{{ domain_password }}' + safe_mode_password: '{{ domain_password }}' + reboot: true + register: change_domain_fail + failed_when: + - change_domain_fail.msg != "Host is already a domain controller in another domain " ~ child_domain_name + +- name: fail with parent_domain_name with domain_type mode + domain_child: + dns_domain_name: '{{ child_domain_name }}' + domain_admin_user: '{{ domain_user_upn }}' + domain_admin_password: '{{ domain_password }}' + safe_mode_password: '{{ domain_password }}' + parent_domain_name: other + reboot: true + register: invalid_parent + failed_when: + - invalid_parent.msg != "parent_domain_name must not be set when domain_type=child" + +- name: fail with invalid domain_mode + domain_child: + dns_domain_name: bogus.local + parent_domain_name: '{{ domain_realm }}' + domain_admin_user: '{{ domain_user_upn }}' + domain_admin_password: '{{ domain_password }}' + safe_mode_password: '{{ domain_password }}' + domain_mode: Invalid + reboot: true + register: change_domain_invalid_mode + failed_when: + - >- + change_domain_invalid_mode.msg.startswith("The parameter 'domain_mode' does not accept 'Invalid', please use one of: ") diff --git a/tests/integration/targets/domain_child/tasks/main_tree.yml b/tests/integration/targets/domain_child/tasks/main_tree.yml new file mode 100644 index 0000000..01e5e06 --- /dev/null +++ b/tests/integration/targets/domain_child/tasks/main_tree.yml @@ -0,0 +1,91 @@ +- name: create test folders + ansible.windows.win_file: + path: 'C:\ansible_testing\{{ item }}' + state: directory + loop: + - DB + - LogPath + - SysVol + +- name: create tree domain - check mode + domain_child: + dns_domain_name: '{{ child_domain_name }}' + parent_domain_name: '{{ domain_realm }}' + domain_type: tree + domain_admin_user: '{{ domain_user_upn }}' + domain_admin_password: '{{ domain_password }}' + safe_mode_password: '{{ domain_password }}' + domain_mode: WinThreshold + database_path: C:\ansible_testing\DB + log_path: C:\ansible_testing\LogPath + sysvol_path: C:\ansible_testing\SysVol + reboot: true + register: to_tree_check + check_mode: true + +- name: get result of promote to tree domain - check mode + ansible.windows.win_powershell: + script: '{{ get_role_script }}' + register: to_tree_check_actual + +- name: assert promote to domain - check mode + assert: + that: + - to_tree_check is changed + - not to_tree_check.reboot_required + - to_tree_check_actual.output[0]["Domain"] == None + - to_tree_check_actual.output[0]["DomainRole"] == "StandaloneServer" + +- name: change hostname to have a pending change before promotion + ansible.windows.win_hostname: + name: '{{ new_hostname }}' + +- name: create tree domain with pending reboot + domain_child: + dns_domain_name: '{{ child_domain_name }}' + parent_domain_name: '{{ domain_realm }}' + domain_type: tree + domain_admin_user: '{{ domain_user_upn }}' + domain_admin_password: '{{ domain_password }}' + safe_mode_password: '{{ domain_password }}' + domain_mode: WinThreshold + database_path: C:\ansible_testing\DB + log_path: C:\ansible_testing\LogPath + sysvol_path: C:\ansible_testing\SysVol + reboot: true + register: to_tree + +- name: get result of promote to domain with pending reboot + ansible.windows.win_powershell: + script: '{{ get_role_script }}' + register: to_tree_actual + +- name: assert promote to domain with pending reboot + assert: + that: + - to_tree is changed + - not to_tree.reboot_required + - to_tree_actual.output[0]["Domain"] == child_domain_name + - to_tree_actual.output[0]["DomainRole"] == "PrimaryDC" + - to_tree_actual.output[0]["HostName"] == new_hostname | upper + +- name: create tree domain - idempotent + domain_child: + dns_domain_name: '{{ child_domain_name }}' + parent_domain_name: '{{ domain_realm }}' + domain_type: tree + domain_admin_user: '{{ domain_user_upn }}' + domain_admin_password: '{{ domain_password }}' + safe_mode_password: '{{ domain_password }}' + domain_mode: WinThreshold + database_path: C:\ansible_testing\DB + log_path: C:\ansible_testing\LogPath + sysvol_path: C:\ansible_testing\SysVol + reboot: true + register: to_tree_again + +- name: assert create tree domain - idempotent + assert: + that: + - not to_tree_again is changed + - not to_tree_again.reboot_required diff --git a/tests/integration/targets/domain_child/test.yml b/tests/integration/targets/domain_child/test.yml new file mode 100644 index 0000000..6cb2495 --- /dev/null +++ b/tests/integration/targets/domain_child/test.yml @@ -0,0 +1,81 @@ +- name: ensure time is in sync + hosts: windows + gather_facts: no + tasks: + - name: get current host datetime + command: date +%s + changed_when: False + delegate_to: localhost + run_once: True + register: local_time + + - name: set datetime on Windows + ansible.windows.win_powershell: + parameters: + SecondsSinceEpoch: '{{ local_time.stdout | trim }}' + script: | + param($SecondsSinceEpoch) + + $utc = [System.DateTimeKind]::Utc + $epoch = New-Object -TypeName System.DateTime -ArgumentList 1970, 1, 1, 0, 0, 0, 0, $utc + $date = $epoch.AddSeconds($SecondsSinceEpoch) + + Set-Date -Date $date + + - name: set common test vars + ansible.builtin.set_fact: + get_role_script: | + $Ansible.Changed = $false + Get-CimInstance -ClassName Win32_ComputerSystem -Property Domain, DomainRole, PartOfDomain | + Select-Object -Property @{ + N = 'Domain' + E = { + if ($_.PartOfDomain) { + $_.Domain + } + else { + $null + } + } + }, @{ + N = 'DomainRole' + E = { + switch ($_.DomainRole) { + 0 { "StandaloneWorkstation" } + 1 { "MemberWorkstation" } + 2 { "StandaloneServer" } + 3 { "MemberServer" } + 4 { "BackupDC" } + 5 { "PrimaryDC" } + } + } + }, @{ + N = 'HostName' + E = { $env:COMPUTERNAME } + } + +- name: run microsoft.ad.domain_child child tests + hosts: CHILD + gather_facts: no + + tasks: + - name: check domain status to see if test will run + ansible.windows.win_powershell: + script: '{{ get_role_script }}' + register: domain_status + + - ansible.builtin.include_tasks: tasks/main_child.yml + when: domain_status.output[0].Domain != child_domain_name + +- name: run microsoft.ad.domain_child tree tests + hosts: TREE + gather_facts: no + + tasks: + - name: check domain status to see if test will run + ansible.windows.win_powershell: + script: '{{ get_role_script }}' + register: domain_status + + - ansible.builtin.include_tasks: tasks/main_tree.yml + when: domain_status.output[0].Domain != child_domain_name