diff --git a/ansible/playbooks/ad-data.yml b/ansible/playbooks/ad-data.yml index 49466db9..fc30c6c0 100644 --- a/ansible/playbooks/ad-data.yml +++ b/ansible/playbooks/ad-data.yml @@ -15,6 +15,7 @@ ad_users: "{{ lab.domains[lab.hosts[dict_key].domain].users }}" ad_ou: "{{ lab.domains[lab.hosts[dict_key].domain].organisation_units | default({}) }}" ad_groups: "{{ lab.domains[lab.hosts[dict_key].domain].groups }}" + ad_multi_domain_groups_member: "{{ lab.domains[lab.hosts[dict_key].domain].multi_domain_groups_member | default({}) }}" - name: Servers AD data configuration hosts: server diff --git a/ansible/roles/ad/README.md b/ansible/roles/ad/README.md index 5e66b1d7..93b21af2 100644 --- a/ansible/roles/ad/README.md +++ b/ansible/roles/ad/README.md @@ -11,6 +11,19 @@ Configure Active Directory domain administrator membership and settings ## Role Variables +### Default Variables (main.yml) + +| Variable | Type | Default | Description | +| -------- | ---- | ------- | ----------- | +| `ad_reconcile_passwords` | bool | `True` | No description | +| `ad_reconcile_group_membership` | bool | `True` | No description | +| `ad_reconcile_check_only` | bool | `False` | No description | +| `ad_reconcile_protected_accounts` | list | `[]` | No description | +| `ad_reconcile_protected_accounts.0` | str | `ssm-user` | No description | +| `ad_reconcile_protected_accounts.1` | str | `ansible` | No description | +| `ad_reconcile_protected_accounts.2` | str | `vagrant` | No description | +| `ad_multi_domain_groups_member` | dict | `{}` | No description | + ## Tasks ### groups.yml @@ -25,9 +38,11 @@ Configure Active Directory domain administrator membership and settings - **Organisation units** (ansible.builtin.import_tasks) - **Groups** (ansible.builtin.import_tasks) - **Users** (ansible.builtin.import_tasks) +- **Reconcile user passwords** (ansible.builtin.import_tasks) - Conditional - **Add members to the Domainlocal group, preserving existing membership** (microsoft.ad.group) - Conditional - **Add members to the Universal group, preserving existing membership** (microsoft.ad.group) - Conditional - **Add members to the Global group, preserving existing membership** (microsoft.ad.group) - Conditional +- **Reconcile group membership** (ansible.builtin.import_tasks) - Conditional - **Assign managed_by domainlocal groups** (ansible.windows.win_powershell) - Conditional - **Assign managed_by universal groups** (ansible.windows.win_powershell) - Conditional - **Assign managed_by global groups** (ansible.windows.win_powershell) - Conditional @@ -37,6 +52,16 @@ Configure Active Directory domain administrator membership and settings - **Create OU** (ansible.windows.win_powershell) - **Wait for OU creation to complete** (ansible.builtin.async_status) - Conditional +### reconcile_group_membership.yml + +- **Reconcile group membership against the lab config** (ansible.windows.win_powershell) + +### reconcile_passwords.yml + +- **Confirm the credential probe rejects invalid credentials** (ansible.windows.win_powershell) +- **Reconcile user passwords against the lab config** (ansible.windows.win_powershell) +- **Report password drift** (ansible.builtin.debug) - Conditional + ### users.yml - **Sync the contents of one directory to another - hack to get Requires -Module Ansible.ModuleUtils.Legacy loaded** (community.windows.win_robocopy) diff --git a/ansible/roles/ad/defaults/main.yml b/ansible/roles/ad/defaults/main.yml new file mode 100644 index 00000000..46206ee6 --- /dev/null +++ b/ansible/roles/ad/defaults/main.yml @@ -0,0 +1,30 @@ +--- +# Drift reconciliation. +# +# Creating users and adding group members is not enough to make `lab reset` +# converge on the baseline: both operations are add-only, so a password an +# attack run changed and a group an agent added itself to both survive the +# reset. The lab ends up at a superset of baseline, which is exactly the +# noise a reproducible benchmark cannot tolerate. +# +# These two passes close that gap by reconciling the observed state back down +# to what the lab config declares. +ad_reconcile_passwords: true +ad_reconcile_group_membership: true + +# Report drift without correcting it. Useful for measuring how much an attack +# run actually moved the lab before you reset it. +ad_reconcile_check_only: false + +# Accounts that are never removed from a group even when the lab config does +# not list them. These are management-plane accounts that exist on the hosts +# but are deliberately absent from the lab topology. +ad_reconcile_protected_accounts: + - ssm-user + - ansible + - vagrant + +# Cross-domain group membership, applied by the groups_domains role in a later +# play. The membership reconciler needs it because these entries can name +# principals in this domain, which would otherwise look unmanaged. +ad_multi_domain_groups_member: {} diff --git a/ansible/roles/ad/tasks/main.yml b/ansible/roles/ad/tasks/main.yml index 5eeb95a0..3734f103 100644 --- a/ansible/roles/ad/tasks/main.yml +++ b/ansible/roles/ad/tasks/main.yml @@ -37,6 +37,10 @@ - name: Users ansible.builtin.import_tasks: users.yml +- name: Reconcile user passwords + ansible.builtin.import_tasks: reconcile_passwords.yml + when: ad_reconcile_passwords | bool + - name: Add members to the Domainlocal group, preserving existing membership microsoft.ad.group: name: "{{ item.key }}" @@ -67,6 +71,10 @@ loop: "{{ ad_groups['global'] | dict2items }}" when: ad_groups['global'] is defined and item.value.members is defined +- name: Reconcile group membership + ansible.builtin.import_tasks: reconcile_group_membership.yml + when: ad_reconcile_group_membership | bool + # Managed BY - name: Assign managed_by domainlocal groups ansible.windows.win_powershell: diff --git a/ansible/roles/ad/tasks/reconcile_group_membership.yml b/ansible/roles/ad/tasks/reconcile_group_membership.yml new file mode 100644 index 00000000..d2d97d0d --- /dev/null +++ b/ansible/roles/ad/tasks/reconcile_group_membership.yml @@ -0,0 +1,151 @@ +--- +# Group membership is add-only everywhere in this collection, so a group an +# agent added itself to during an attack run survives the reset. Diff each +# lab-managed group against the config and remove what should not be there. +# +# Must run after every add-member task in this role, otherwise it reconciles +# against a half-built baseline and removes members that are about to be added. + +- name: Reconcile group membership against the lab config + ansible.windows.win_powershell: + script: | + [CmdletBinding()] + param ( + [string]$UsersB64, + [string]$GroupsB64, + [string]$MultiDomainB64, + [string]$AdminUser, + [string[]]$ProtectedAccounts, + [bool]$CheckOnly + ) + + $ProgressPreference = 'SilentlyContinue' + + $users = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($UsersB64)) | ConvertFrom-Json + $groups = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($GroupsB64)) | ConvertFrom-Json + $multi = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($MultiDomainB64)) | ConvertFrom-Json + + $domain = Get-ADDomain + $domainSid = $domain.DomainSID.Value + + $neverRemove = @{} + foreach ($n in @($ProtectedAccounts)) { + if ($n) { $neverRemove[$n.ToLower()] = $true } + } + + # expected[group] = set of sAMAccountNames the config says belong to it. + # Only groups that appear here are reconciled at all, so a group the lab + # has no opinion about is never touched. + $expected = @{} + # A null Member registers the group for reconciliation with no expected + # members, which is how declared-but-empty groups get cleaned. + function Add-Expected { + param([string]$Group, [string]$Member) + if (-not $Group) { return } + $g = $Group.ToLower() + if (-not $expected.ContainsKey($g)) { $expected[$g] = @{} } + if (-not $Member) { return } + # Config writes members as NETBIOS\Name or fqdn\Name; AD compares on + # the bare sAMAccountName. Foreign principals are filtered by SID + # below, so a stripped foreign name can only ever over-permit a + # same-domain account that shares its sAMAccountName. + $m = ($Member -replace '^.*\\', '').ToLower() + $expected[$g][$m] = $true + } + + foreach ($u in $users.PSObject.Properties) { + foreach ($g in @($u.Value.groups)) { Add-Expected -Group $g -Member $u.Name } + } + foreach ($scope in $groups.PSObject.Properties) { + foreach ($grp in $scope.Value.PSObject.Properties) { + # Seed every declared group even when it has no declared members, so + # a group whose membership is entirely cross-domain still gets + # reconciled instead of silently accepting anything added to it. + Add-Expected -Group $grp.Name -Member $null + foreach ($m in @($grp.Value.members)) { Add-Expected -Group $grp.Name -Member $m } + } + } + # multi_domain_groups_member is applied by the groups_domains role in a + # later play and can name principals in this domain as well as foreign + # ones. Without it those same-domain members look unmanaged and get + # stripped whenever ad-data runs without ad-relations. + foreach ($grp in $multi.PSObject.Properties) { + foreach ($m in @($grp.Value)) { Add-Expected -Group $grp.Name -Member $m } + } + Add-Expected -Group 'Domain Admins' -Member $AdminUser + + $drift = @() + $errors = @() + $kept = 0 + + foreach ($gname in $expected.Keys) { + try { + $grp = Get-ADGroup -Identity $gname -Properties member -ErrorAction Stop + } catch { + $errors += ("group " + $gname + " not found: " + $_.Exception.Message) + continue + } + + foreach ($dn in @($grp.member)) { + try { + $obj = Get-ADObject -Identity $dn -Properties objectSid, sAMAccountName, objectClass -ErrorAction Stop + } catch { + $errors += ("resolve " + $dn + ": " + $_.Exception.Message) + continue + } + + # Machine accounts join groups through domain join, not lab config. + if ($obj.objectClass -eq 'computer') { continue } + if (-not $obj.objectSid) { continue } + $sidStr = $obj.objectSid.Value + + # Cross-domain members come from multi_domain_groups_member and are + # applied by the groups_domains role in a later play. Removing them + # here would strip them whenever ad-data runs without ad-relations. + if (-not $sidStr.StartsWith($domainSid + "-")) { continue } + + # RIDs below 1000 are built-ins the lab does not enumerate: + # Administrator (500), krbtgt (502), Enterprise Admins (519), and + # the default nesting between the admin groups. + $rid = [int](($sidStr -split '-')[-1]) + if ($rid -lt 1000) { continue } + + $sam = $obj.sAMAccountName + if (-not $sam) { continue } + $samLower = $sam.ToLower() + if ($neverRemove.ContainsKey($samLower)) { continue } + + if ($expected[$gname].ContainsKey($samLower)) { + $kept++ + continue + } + + $drift += ($gname + " <- " + $sam) + if (-not $CheckOnly) { + try { + Remove-ADGroupMember -Identity $grp -Members $obj.DistinguishedName -Confirm:$false -ErrorAction Stop + } catch { + $errors += ("remove " + $sam + " from " + $gname + ": " + $_.Exception.Message) + } + } + } + } + + foreach ($d in $drift) { + if ($CheckOnly) { Write-Output ("DRIFT " + $d) } else { Write-Output ("REMOVED " + $d) } + } + foreach ($e in $errors) { Write-Warning $e } + + if ($drift.Count -eq 0) { + Write-Output ("group membership clean (" + $kept + " expected members verified across " + $expected.Count + " groups)") + } + + $Ansible.Changed = ($drift.Count -gt 0) -and (-not $CheckOnly) + parameters: + UsersB64: "{{ ad_users | to_json | b64encode }}" + GroupsB64: "{{ ad_groups | to_json | b64encode }}" + MultiDomainB64: "{{ ad_multi_domain_groups_member | to_json | b64encode }}" + AdminUser: "{{ admin_user }}" + ProtectedAccounts: "{{ ad_reconcile_protected_accounts }}" + CheckOnly: "{{ ad_reconcile_check_only }}" + register: group_membership_reconcile diff --git a/ansible/roles/ad/tasks/reconcile_passwords.yml b/ansible/roles/ad/tasks/reconcile_passwords.yml new file mode 100644 index 00000000..ee7a9920 --- /dev/null +++ b/ansible/roles/ad/tasks/reconcile_passwords.yml @@ -0,0 +1,150 @@ +--- +# Passwords are only ever set on user CREATE, so any password an attack run +# changed stays changed across a reset. Detect that by actually authenticating +# with the configured password and resetting only the accounts that fail. + +- name: Confirm the credential probe rejects invalid credentials + ansible.windows.win_powershell: + script: | + $ProgressPreference = 'SilentlyContinue' + + if (-not ('DgLogon' -as [type])) { + Add-Type -TypeDefinition @' + using System; + using System.Runtime.InteropServices; + public static class DgLogon { + [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern bool LogonUser(string user, string domain, string password, + int logonType, int logonProvider, out IntPtr token); + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool CloseHandle(IntPtr handle); + } + '@ + } + + # A bogus principal must fail to authenticate. Running as SYSTEM on a DC, + # some credential-check APIs (notably DirectoryEntry) return success for + # any password, which would silently turn this whole role into a no-op. + # Use a name that cannot exist rather than a real account with a wrong + # password, so the control never burns a lockout attempt on a real user. + $token = [IntPtr]::Zero + $bogus = "dgprobe-" + [guid]::NewGuid().ToString("N") + $ok = [DgLogon]::LogonUser($bogus, "{{ domain }}", [guid]::NewGuid().ToString(), 3, 0, [ref]$token) + if ($ok -and $token -ne [IntPtr]::Zero) { [void][DgLogon]::CloseHandle($token) } + + if ($ok) { + throw "credential probe authenticated a nonexistent principal; password drift cannot be detected on this host" + } + + Write-Output "credential probe rejects invalid credentials" + $Ansible.Changed = $false + changed_when: false + +- name: Reconcile user passwords against the lab config + ansible.windows.win_powershell: + script: | + [CmdletBinding()] + param ( + [string]$Username, + [string]$Password, + [string]$Domain, + [bool]$CheckOnly + ) + + $ProgressPreference = 'SilentlyContinue' + + if (-not ('DgLogon' -as [type])) { + Add-Type -TypeDefinition @' + using System; + using System.Runtime.InteropServices; + public static class DgLogon { + [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern bool LogonUser(string user, string domain, string password, + int logonType, int logonProvider, out IntPtr token); + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool CloseHandle(IntPtr handle); + } + '@ + } + + function Test-DgPassword { + param([string]$User, [string]$Dom, [string]$Pass) + $token = [IntPtr]::Zero + # 3 = LOGON32_LOGON_NETWORK, 0 = LOGON32_PROVIDER_DEFAULT + $ok = [DgLogon]::LogonUser($User, $Dom, $Pass, 3, 0, [ref]$token) + if ($ok -and $token -ne [IntPtr]::Zero) { [void][DgLogon]::CloseHandle($token) } + return $ok + } + + $changed = $false + + try { + $user = Get-ADUser -Identity $Username -Properties LockedOut, memberOf -ErrorAction Stop + } catch { + Write-Warning ("Skipping " + $Username + " - " + $_.Exception.Message) + $Ansible.Changed = $false + return + } + + # A locked account fails the probe no matter what its password is, which + # would make this task reset the password on every run until the lockout + # expires. Clear the lockout first so the probe result is meaningful. + if ($user.LockedOut) { + if ($CheckOnly) { + Write-Output ("DRIFT " + $Username + " is locked out") + } else { + Unlock-ADAccount -Identity $Username -ErrorAction Stop + Write-Output ("UNLOCKED " + $Username) + $changed = $true + } + } + + # Protected Users members cannot do NTLM network logon, so the probe + # always fails for them and we would reset their password every run. + $protectedSid = (Get-ADDomain).DomainSID.Value + "-525" + $isProtected = $false + foreach ($dn in @($user.memberOf)) { + try { + if ((Get-ADGroup -Identity $dn -ErrorAction Stop).SID.Value -eq $protectedSid) { + $isProtected = $true + break + } + } catch { } + } + + if ($isProtected) { + Write-Warning ($Username + " is in Protected Users - password drift is not checked for this account") + $Ansible.Changed = $changed + return + } + + if (Test-DgPassword -User $Username -Dom $Domain -Pass $Password) { + Write-Output ($Username + " password matches lab config") + } elseif ($CheckOnly) { + Write-Output ("DRIFT " + $Username + " password does not match lab config") + } else { + $securePassword = ConvertTo-SecureString -String $Password -AsPlainText -Force + Set-ADAccountPassword -Identity $Username -Reset -NewPassword $securePassword -ErrorAction Stop + Write-Output ("RESET " + $Username + " password had drifted") + $changed = $true + } + + $Ansible.Changed = $changed + parameters: + Username: "{{ item.key }}" + Password: "{{ item.value.password }}" + Domain: "{{ domain }}" + CheckOnly: "{{ ad_reconcile_check_only }}" + with_dict: "{{ ad_users }}" + loop_control: + label: "Reconciling password for user: {{ item.key }}" + register: password_reconcile + no_log: true + +# The task above is no_log because it takes cleartext passwords as parameters, +# which also hides which accounts drifted. The script's own output carries no +# secrets, so replay just that. +- name: Report password drift + ansible.builtin.debug: + msg: "{{ password_reconcile.results | map(attribute='output') | select('defined') | flatten | select('search', '^(RESET|DRIFT|UNLOCKED) ') | list }}" + when: password_reconcile is defined and password_reconcile.results is defined diff --git a/cli/cmd/lab_reset.go b/cli/cmd/lab_reset.go index fdad9080..58662997 100644 --- a/cli/cmd/lab_reset.go +++ b/cli/cmd/lab_reset.go @@ -219,10 +219,20 @@ var labResetCmd = &cobra.Command{ 2. Re-run AD-state playbooks to restore users, ACLs, group membership, trusts, and vulnerability seeding. +Stage 2 reconciles rather than only re-applies. Passwords an attack run +changed are reset back to the lab config, and group memberships an attack +run added are removed. Cross-domain members, machine accounts, and built-in +principals (RID < 1000) are never touched. + +Stage 2 writes. To see what it would change without changing it, rehearse with +-E ad_reconcile_check_only=true, which reports drift and corrects nothing. That +is also how to measure how far an attack run moved the lab before resetting it. + Idempotent: safe to re-run.`, Example: ` dreadgoad lab reset dreadgoad lab reset --skip-purge - dreadgoad lab reset --plays ad-data.yml,ad-acl.yml`, + dreadgoad lab reset --plays ad-data.yml,ad-acl.yml + dreadgoad lab reset -E ad_reconcile_check_only=true # report drift, change nothing`, RunE: runLabReset, } @@ -242,6 +252,7 @@ func init() { labResetCmd.Flags().Int("max-retries", 0, "Max retry attempts (default: from config)") labResetCmd.Flags().Int("retry-delay", 0, "Delay between retries in seconds (default: from config)") labResetCmd.Flags().Bool("skip-creator-check", false, "Skip the admin creator-SID safety belt during purge") + labResetCmd.Flags().StringArrayP("extra-vars", "E", nil, extraVarsUsage) } type purgeOptions struct { @@ -485,6 +496,10 @@ func runLabReset(cmd *cobra.Command, args []string) error { maxRetries, _ := cmd.Flags().GetInt("max-retries") retryDelay, _ := cmd.Flags().GetInt("retry-delay") skipCreator, _ := cmd.Flags().GetBool("skip-creator-check") + extraVars, err := parseExtraVars(cmd) + if err != nil { + return err + } playbooks := adStatePlaybooks if playsFlag != "" { @@ -506,7 +521,7 @@ func runLabReset(cmd *cobra.Command, args []string) error { if !skipProvision { fmt.Println("--- Stage 2: restore AD baseline state ---") - if err := provisionPlaybooks(ctx, cfg, playbooks, limit, maxRetries, retryDelay); err != nil { + if err := provisionPlaybooks(ctx, cfg, playbooks, limit, maxRetries, retryDelay, extraVars); err != nil { return err } } diff --git a/cli/cmd/provision.go b/cli/cmd/provision.go index 11ebb492..cb42216a 100644 --- a/cli/cmd/provision.go +++ b/cli/cmd/provision.go @@ -64,14 +64,20 @@ func init() { provisionCmd.Flags().String("limit", "", "Limit execution to specific hosts") provisionCmd.Flags().Int("max-retries", 0, "Max retry attempts (default: from config)") provisionCmd.Flags().Int("retry-delay", 0, "Delay between retries in seconds (default: from config)") + provisionCmd.Flags().StringArrayP("extra-vars", "E", nil, extraVarsUsage) provisionCmd.MarkFlagsMutuallyExclusive("plays", "from") adUsersCmd.Flags().String("plays", "ad-data.yml", "Playbooks to run") adUsersCmd.Flags().String("limit", "", "Limit execution to specific hosts") adUsersCmd.Flags().Int("max-retries", 0, "Max retry attempts") adUsersCmd.Flags().Int("retry-delay", 0, "Delay between retries in seconds") + adUsersCmd.Flags().StringArrayP("extra-vars", "E", nil, extraVarsUsage) } +// extraVarsUsage is shared so the flag reads identically everywhere it appears. +const extraVarsUsage = "Ansible variable as key=value, repeatable " + + "(e.g. -E ad_reconcile_check_only=true to report drift instead of correcting it)" + func resolvePlaybooks(cfg *config.Config, playsFlag, fromFlag string) ([]string, error) { if playsFlag != "" && fromFlag != "" { return nil, fmt.Errorf("--plays and --from are mutually exclusive") @@ -333,13 +339,70 @@ func runProvision(cmd *cobra.Command, args []string) error { limit, _ := cmd.Flags().GetString("limit") maxRetries, _ := cmd.Flags().GetInt("max-retries") retryDelay, _ := cmd.Flags().GetInt("retry-delay") + extraVars, err := parseExtraVars(cmd) + if err != nil { + return err + } + + return provisionPlaybooks(ctx, cfg, playbooks, limit, maxRetries, retryDelay, extraVars) +} + +// parseExtraVars reads the repeatable --extra-vars flag into the map the +// Ansible runner passes through as `-e key=value`. +// +// This is the only way to reach a role default from the command line, which +// matters most for the ones that are destructive by design: the `ad` role +// reconciles passwords and group membership on every ad-data.yml run, and +// `ad_reconcile_check_only=true` is what turns that into a report instead of a +// write. Without a flag, rehearsing a reset meant editing defaults/main.yml. +func parseExtraVars(cmd *cobra.Command) (map[string]string, error) { + pairs, _ := cmd.Flags().GetStringArray("extra-vars") + if len(pairs) == 0 { + return nil, nil + } + out := make(map[string]string, len(pairs)) + for _, p := range pairs { + k, v, ok := strings.Cut(p, "=") + if !ok || k == "" { + return nil, fmt.Errorf("--extra-vars %q is not key=value", p) + } + out[k] = v + } + return out, nil +} - return provisionPlaybooks(ctx, cfg, playbooks, limit, maxRetries, retryDelay) +// applyExtraVars layers user-supplied vars over the SOCKS tunnel's, so an +// explicit -e always wins; the tunnel only sets connection plumbing, which +// nobody overrides by accident. It also echoes what it applied, because a var +// that silently failed to take effect is indistinguishable from one that did. +func applyExtraVars(socksVars, extraVars map[string]string) map[string]string { + if len(extraVars) == 0 { + return socksVars + } + out := make(map[string]string, len(socksVars)+len(extraVars)) + for k, v := range socksVars { + out[k] = v + } + for k, v := range extraVars { + out[k] = v + } + fmt.Printf("Extra vars: %s\n", strings.Join(sortedPairs(extraVars), " ")) + return out +} + +// sortedPairs renders a var map as stable "k=v" strings for display. +func sortedPairs(m map[string]string) []string { + out := make([]string, 0, len(m)) + for k, v := range m { + out = append(out, k+"="+v) + } + slices.Sort(out) + return out } // provisionPlaybooks runs preflight checks then executes the given playbooks // with retry logic. Shared between `provision` and `lab reset`. -func provisionPlaybooks(ctx context.Context, cfg *config.Config, playbooks []string, limit string, maxRetries, retryDelay int) error { +func provisionPlaybooks(ctx context.Context, cfg *config.Config, playbooks []string, limit string, maxRetries, retryDelay int, extraVars map[string]string) error { _ = os.MkdirAll(cfg.LogDir, 0o755) logFile := filepath.Join(cfg.LogDir, fmt.Sprintf("%s-dreadgoad-%s.log", cfg.Env, time.Now().Format("20060102_150405"))) @@ -377,6 +440,8 @@ func provisionPlaybooks(ctx context.Context, cfg *config.Config, playbooks []str defer socksTunnel.Close() } + runVars := applyExtraVars(socksVars, extraVars) + log := slog.Default() useSSM := isSSMInventory(cfg) @@ -394,7 +459,7 @@ func provisionPlaybooks(ctx context.Context, cfg *config.Config, playbooks []str Limit: limit, Debug: cfg.Debug, LogFile: logFile, - ExtraVars: socksVars, + ExtraVars: runVars, } if maxRetries > 0 { opts.MaxRetries = maxRetries @@ -404,6 +469,7 @@ func provisionPlaybooks(ctx context.Context, cfg *config.Config, playbooks []str } if err := ansible.RunPlaybookWithRetry(ctx, opts); err != nil { + log.Error("provisioning failed", "playbook", playbook, "log_file", logFile, "error", err) return fmt.Errorf("provisioning failed at %s: %w\n see full log: %s", playbook, err, logFile) } @@ -421,6 +487,7 @@ func provisionPlaybooks(ctx context.Context, cfg *config.Config, playbooks []str } } + log.Info("provisioning complete", "playbooks", len(playbooks), "log_file", logFile) fmt.Println("===============================================") fmt.Printf("All playbooks completed successfully at %s\n", time.Now().Format(time.RFC3339)) fmt.Printf("Full log: %s\n", logFile) diff --git a/cli/cmd/provision_test.go b/cli/cmd/provision_test.go new file mode 100644 index 00000000..003459dd --- /dev/null +++ b/cli/cmd/provision_test.go @@ -0,0 +1,145 @@ +package cmd + +import ( + "testing" + + "github.com/spf13/cobra" +) + +func extraVarsCmd(t *testing.T, args ...string) *cobra.Command { + t.Helper() + c := &cobra.Command{Use: "test", RunE: func(*cobra.Command, []string) error { return nil }} + c.Flags().StringArrayP("extra-vars", "E", nil, extraVarsUsage) + c.SetArgs(args) + c.SetOut(nil) + if err := c.Execute(); err != nil { + t.Fatalf("execute: %v", err) + } + return c +} + +func TestParseExtraVars(t *testing.T) { + tests := []struct { + name string + args []string + want map[string]string + }{ + { + name: "absent flag yields no vars", + args: nil, + want: nil, + }, + { + name: "the reconciler dry-run this flag exists for", + args: []string{"-E", "ad_reconcile_check_only=true"}, + want: map[string]string{"ad_reconcile_check_only": "true"}, + }, + { + name: "repeated flag accumulates", + args: []string{"-E", "a=1", "--extra-vars", "b=2"}, + want: map[string]string{"a": "1", "b": "2"}, + }, + { + // Ansible values legitimately contain '=', so only the first + // separator may split. Cutting on the last would corrupt them. + name: "value keeps later equals signs", + args: []string{"-E", "filter=name=jon"}, + want: map[string]string{"filter": "name=jon"}, + }, + { + name: "empty value is preserved, not dropped", + args: []string{"-E", "quiet="}, + want: map[string]string{"quiet": ""}, + }, + { + name: "last write wins on a repeated key", + args: []string{"-E", "a=1", "-E", "a=2"}, + want: map[string]string{"a": "2"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := parseExtraVars(extraVarsCmd(t, tc.args...)) + if err != nil { + t.Fatalf("parseExtraVars: %v", err) + } + if len(got) != len(tc.want) { + t.Fatalf("got %v, want %v", got, tc.want) + } + for k, v := range tc.want { + if got[k] != v { + t.Errorf("key %q: got %q, want %q", k, got[k], v) + } + } + }) + } +} + +// TestParseExtraVarsRejectsMalformed matters more than it looks: a silently +// ignored var reads as "the dry-run ran and found nothing" when what actually +// happened is a destructive write with the default still in force. +// +// `-E ""` is deliberately absent. pflag drops an empty StringArray value before +// the parser sees it, so there is nothing to reject and nothing at risk. +func TestParseExtraVarsRejectsMalformed(t *testing.T) { + for _, arg := range []string{"novalue", "=novalue"} { + t.Run(arg, func(t *testing.T) { + if _, err := parseExtraVars(extraVarsCmd(t, "-E", arg)); err == nil { + t.Errorf("expected an error for %q, got none", arg) + } + }) + } +} + +// TestApplyExtraVarsPrecedence pins the layering. The tunnel vars are +// connection plumbing, so a user var must win, but only the keys it names: a +// -e that quietly dropped the rest would break the connection instead of the +// setting the operator meant to change. +func TestApplyExtraVarsPrecedence(t *testing.T) { + socks := map[string]string{ + "ansible_connection": "psrp", + "ansible_port": "5985", + } + got := applyExtraVars(socks, map[string]string{ + "ansible_port": "5986", + "ad_reconcile_check_only": "true", + }) + + want := map[string]string{ + "ansible_connection": "psrp", + "ansible_port": "5986", + "ad_reconcile_check_only": "true", + } + if len(got) != len(want) { + t.Fatalf("got %v, want %v", got, want) + } + for k, v := range want { + if got[k] != v { + t.Errorf("key %q: got %q, want %q", k, got[k], v) + } + } + if socks["ansible_port"] != "5985" { + t.Errorf("caller's map was mutated: %v", socks) + } +} + +func TestApplyExtraVarsWithoutUserVarsIsPassthrough(t *testing.T) { + socks := map[string]string{"ansible_connection": "psrp"} + if got := applyExtraVars(socks, nil); got["ansible_connection"] != "psrp" || len(got) != 1 { + t.Errorf("got %v, want the tunnel vars unchanged", got) + } +} + +func TestSortedPairsIsStable(t *testing.T) { + got := sortedPairs(map[string]string{"b": "2", "a": "1", "c": "3"}) + want := []string{"a=1", "b=2", "c=3"} + if len(got) != len(want) { + t.Fatalf("got %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("got %v, want %v", got, want) + } + } +}