From b2a16379c8628a9d0c7dacfd3ec6de3e358318d7 Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Tue, 17 Jan 2017 20:21:04 -0800 Subject: [PATCH] new module: win_path (#20073) --- CHANGELOG.md | 1 + .../modules/windows/win_environment.py | 5 +- lib/ansible/modules/windows/win_path.ps1 | 158 +++++++++++++++ lib/ansible/modules/windows/win_path.py | 87 +++++++++ test/integration/targets/win_path/aliases | 1 + .../targets/win_path/tasks/main.yml | 183 ++++++++++++++++++ test/integration/test_win_group2.yml | 1 + 7 files changed, 435 insertions(+), 1 deletion(-) create mode 100644 lib/ansible/modules/windows/win_path.ps1 create mode 100644 lib/ansible/modules/windows/win_path.py create mode 100644 test/integration/targets/win_path/aliases create mode 100644 test/integration/targets/win_path/tasks/main.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index fec47d3e5b..7dcfc372e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,6 +83,7 @@ Ansible Changes By Release - windows: * win_say * win_shortcut + * win_path - openstack * os_quota - zfs: diff --git a/lib/ansible/modules/windows/win_environment.py b/lib/ansible/modules/windows/win_environment.py index f66771a758..484d9e6987 100644 --- a/lib/ansible/modules/windows/win_environment.py +++ b/lib/ansible/modules/windows/win_environment.py @@ -65,7 +65,10 @@ options: - process - user author: "Jon Hawkesworth (@jhawkesworth)" -notes: +notes: + - This module is best-suited for setting the entire value of an + environment variable. For safe element-based management of + path-like environment vars, use the M(win_path) module. - This module does not broadcast change events. This means that the minority of windows applications which can have their environment changed without restarting will not be notified and diff --git a/lib/ansible/modules/windows/win_path.ps1 b/lib/ansible/modules/windows/win_path.ps1 new file mode 100644 index 0000000000..219f5a3486 --- /dev/null +++ b/lib/ansible/modules/windows/win_path.ps1 @@ -0,0 +1,158 @@ +#!powershell +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . + +# WANT_JSON +# POWERSHELL_COMMON + +Set-StrictMode -Version 2 +$ErrorActionPreference = "Stop" + +$system_path = "System\CurrentControlSet\Control\Session Manager\Environment" +$user_path = "Environment" + +# list/arraylist methods don't allow IEqualityComparer override for case/backslash/quote-insensitivity, roll our own search +Function Get-IndexOfPathElement ($list, [string]$value) { + $idx = 0 + $value = $value.Trim('"').Trim('\') + ForEach($el in $list) { + If ([string]$el.Trim('"').Trim('\') -ieq $value) { + return $idx + } + + $idx++ + } + + return -1 +} + +# alters list in place, returns true if at least one element was added +Function Add-Elements ($existing_elements, $elements_to_add) { + $last_idx = -1 + $changed = $false + + ForEach($el in $elements_to_add) { + $idx = Get-IndexOfPathElement $existing_elements $el + + # add missing elements at the end + If ($idx -eq -1) { + $last_idx = $existing_elements.Add($el) + $changed = $true + } + ElseIf ($idx -lt $last_idx) { + $existing_elements.RemoveAt($idx) | Out-Null + $existing_elements.Add($el) | Out-Null + $last_idx = $existing_elements.Count - 1 + $changed = $true + } + Else { + $last_idx = $idx + } + } + + return $changed +} + +# alters list in place, returns true if at least one element was removed +Function Remove-Elements ($existing_elements, $elements_to_remove) { + $count = $existing_elements.Count + + ForEach($el in $elements_to_remove) { + $idx = Get-IndexOfPathElement $existing_elements $el + $result.removed_idx = $idx + If ($idx -gt -1) { + $existing_elements.RemoveAt($idx) + } + } + + return $count -ne $existing_elements.Count +} + +# PS registry provider doesn't allow access to unexpanded REG_EXPAND_SZ; fall back to .NET +Function Get-RawPathVar ($scope) { + If ($scope -eq "user") { + $env_key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey($user_path) + } + ElseIf ($scope -eq "machine") { + $env_key = [Microsoft.Win32.Registry]::LocalMachine.OpenSubKey($system_path) + } + + return $env_key.GetValue($var_name, "", [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) +} + +Function Set-RawPathVar($path_value, $scope) { + If ($scope -eq "user") { + $var_path = "HKCU:\" + $user_path + } + ElseIf ($scope -eq "machine") { + $var_path = "HKLM:\" + $system_path + } + + Set-ItemProperty $var_path -Name $var_name -Value $path_value -Type ExpandString | Out-Null + + return $path_value +} + +$parsed_args = Parse-Args $args -supports_check_mode $true + +$result = @{changed=$false} + +$var_name = Get-AnsibleParam $parsed_args "name" -Default "PATH" +$elements = Get-AnsibleParam $parsed_args "elements" -FailIfEmpty $result +$state = Get-AnsibleParam $parsed_args "state" -Default "present" -ValidateSet "present","absent" +$scope = Get-AnsibleParam $parsed_args "scope" -Default "machine" -ValidateSet "machine","user" + +$check_mode = Get-AnsibleParam $parsed_args "_ansible_check_mode" -Default $false + +If ($elements -is [string]) { + $elements = @($elements) +} + +If ($elements -isnot [Array]) { + Fail-Json $result "elements must be a string or list of path strings" +} + +$current_value = Get-RawPathVar $scope +$result.path_value = $current_value + +# TODO: test case-canonicalization on wacky unicode values (eg turkish i) +# TODO: detect and warn/fail on unparseable path? (eg, unbalanced quotes, invalid path chars) +# TODO: detect and warn/fail if system path and Powershell isn't on it? + +$existing_elements = New-Object System.Collections.ArrayList + +# split on semicolons, accounting for quoted values with embedded semicolons (which may or may not be wrapped in whitespace) +$pathsplit_re = [regex] '((?\s*"[^"]+"\s*)|(?[^;]+))(;$|$|;)' + +ForEach ($m in $pathsplit_re.Matches($current_value)) { + $existing_elements.Add($m.Groups['q'].Value) | Out-Null +} + +If ($state -eq "absent") { + $result.changed = Remove-Elements $existing_elements $elements +} +ElseIf ($state -eq "present") { + $result.changed = Add-Elements $existing_elements $elements +} + +# calculate the new path value from the existing elements +$path_value = [String]::Join(";", $existing_elements.ToArray()) +$result.path_value = $path_value + +If ($result.changed -and -not $check_mode) { + Set-RawPathVar $path_value $scope | Out-Null +} + +Exit-Json $result diff --git a/lib/ansible/modules/windows/win_path.py b/lib/ansible/modules/windows/win_path.py new file mode 100644 index 0000000000..d775f5e527 --- /dev/null +++ b/lib/ansible/modules/windows/win_path.py @@ -0,0 +1,87 @@ +#!/usr/bin/python +# +# Copyright 2016 Red Hat | Ansible +# +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . + +# This is a windows documentation stub. Actual code lives in the .ps1 +# file of the same name + +ANSIBLE_METADATA = {'status': ['preview'], + 'supported_by': 'core', + 'version': '1.0'} + +DOCUMENTATION = ''' +--- +module: win_path +version_added: "2.3" +short_description: Manage Windows path environment variables +description: + - Allows element-based ordering, addition, and removal of Windows path environment variables. +options: + name: + description: + - Target path environment variable name + default: PATH + elements: + description: + - A single path element, or a list of path elements (ie, directories) to add or remove. + - When multiple elements are included in the list (and C(state) is C(present)), the elements are guaranteed to appear in the same relative order in the resultant path value. + - Variable expansions (eg, C(%VARNAME%)) are allowed, and are stored unexpanded in the target path element. + - Any existing path elements not mentioned in C(elements) are always preserved in their current order. + - New path elements are appended to the path, and existing path elements may be moved closer to the end to satisfy the requested ordering. + - Paths are compared in a case-insensitive fashion, and trailing backslashes are ignored for comparison purposes. However, note that trailing backslashes in YAML require quotes. + required: true + state: + description: + - Whether the path elements specified in C(elements) should be present or absent. + choices: + - present + - absent + scope: + description: + - The level at which the environment variable specified by C(name) should be managed (either for the current user or global machine scope). + choices: + - machine + - user + default: machine +author: "Matt Davis (@nitzmahone)" +notes: + - This module is for modifying indidvidual elements of path-like + environment variables. For general-purpose management of other + environment vars, use the M(win_environment) module. + - This module does not broadcast change events. + This means that the minority of windows applications which can have + their environment changed without restarting will not be notified and + therefore will need restarting to pick up new environment settings. + User level environment variables will require an interactive user to + log out and in again before they become available. +''' + +EXAMPLES = r''' +- name: Ensure that system32 and Powershell are present on the global system path, and in the specified order + win_path: + elements: + - %SystemRoot%\system32 + - %SystemRoot%\system32\WindowsPowerShell\v1.0 + +- name: Ensure that C:\Program Files\MyJavaThing is not on the current user's CLASSPATH + win_path + name: CLASSPATH + elements: C:\Program Files\MyJavaThing + scope: user + state: absent +''' diff --git a/test/integration/targets/win_path/aliases b/test/integration/targets/win_path/aliases new file mode 100644 index 0000000000..ee0ed5974e --- /dev/null +++ b/test/integration/targets/win_path/aliases @@ -0,0 +1 @@ +windows/ci/group2 diff --git a/test/integration/targets/win_path/tasks/main.yml b/test/integration/targets/win_path/tasks/main.yml new file mode 100644 index 0000000000..c51e0b0377 --- /dev/null +++ b/test/integration/targets/win_path/tasks/main.yml @@ -0,0 +1,183 @@ +- set_fact: + varname: WINPATH_TEST + +- name: Remove {{ varname }} vars from user and machine scope + raw: '[Environment]::SetEnvironmentVariable("{{ varname }}", $null, "User"); [Environment]::SetEnvironmentVariable("{{ varname }}", $null, "Machine")' + +- name: Set a var at the machine and user levels + win_path: + name: "{{ varname }}" + elements: C:\{{ item }}Path + scope: "{{ item }}" + with_items: + - machine + - user + register: pathout + +- name: Get path value from machine and user levels + raw: '[Environment]::GetEnvironmentVariable("{{ varname }}","{{ item.item }}")' + with_items: "{{ pathout.results }}" + register: varout + +- name: Ensure output + assert: + that: + - item.0 | changed + - item.0.path_value == "C:\\{{ item.0.item }}Path" + - item.1.stdout_lines[0] == 'C:\\{{ item.0.item }}Path' + with_together: + - "{{ pathout.results }}" + - "{{ varout.results }}" + +- name: Remove {{ varname }} vars from user and machine scope + raw: '[Environment]::SetEnvironmentVariable("{{ varname }}", $null, "User"); [Environment]::SetEnvironmentVariable("{{ varname }}", $null, "Machine")' + +- name: Create multi-element path + win_path: + name: "{{ varname }}" + elements: + - C:\PathZ + - C:\PathA + register: multiout + +- name: Get path value + raw: $env:{{ varname }} + register: varout + +- name: Ensure output + assert: + that: + - multiout | changed + - multiout.path_value == "C:\\PathZ;C:\\PathA" + - varout.stdout_lines[0] == "C:\\PathZ;C:\\PathA" + +- name: Add value to middle and end + win_path: + name: "{{ varname }}" + elements: + - C:\NewPath + - C:\PathA + - 'C:\PathWithTrailingBackslash\' # store with a trailing backslash + - '"C:\Quoted;With;Semicolons"' # embedded semicolon, wrapped in quotes + - '%SystemRoot%\stuff' + register: addout + +- name: Get path value + raw: $env:{{ varname }} + register: varout + +- name: Test idempotence- retry values to middle and end, test case-insensitive comparison, backslash canonicalization + win_path: + name: "{{ varname }}" + elements: + - c:\nEwPaTh + - c:\patha + - C:\pathwithtrailingbackslash # no trailing backslash, should be the same + - '"C:\Quoted;With;Semicolons"' + - '%SystemRoot%\stuff' + register: idemout + +- name: Get path value + raw: $env:{{ varname }} + register: idemvarout + +- name: Ensure output + assert: + that: + - addout | changed + - addout.path_value == 'C:\\PathZ;C:\\NewPath;C:\\PathA;C:\\PathWithTrailingBackslash\\;"C:\Quoted;With;Semicolons";%SystemRoot%\stuff' + - varout.stdout_lines[0] == ('C:\\PathZ;C:\\NewPath;C:\\PathA;C:\\PathWithTrailingBackslash\\;"C:\Quoted;With;Semicolons";C:\Windows\stuff') + - not idemout | changed + - idemout.path_value == 'C:\\PathZ;C:\\NewPath;C:\\PathA;C:\\PathWithTrailingBackslash\\;"C:\Quoted;With;Semicolons";%SystemRoot%\stuff' + - idemvarout.stdout_lines[0] == ('C:\\PathZ;C:\\NewPath;C:\\PathA;C:\\PathWithTrailingBackslash\\;"C:\Quoted;With;Semicolons";C:\Windows\stuff') + +- name: Remove single element + win_path: + name: "{{ varname }}" + elements: C:\NewPath + state: absent + register: removeout + +- name: Get path value + raw: $env:{{ varname }} + register: varout + +- name: Test idempotence- retry remove single element + win_path: + name: "{{ varname }}" + elements: C:\NewPath + state: absent + register: idemremoveout + +- name: Get path value + raw: $env:{{ varname }} + register: idemvarout + +- name: Ensure output + assert: + that: + - removeout | changed + - removeout.path_value == 'C:\\PathZ;C:\\PathA;C:\\PathWithTrailingBackslash\\;"C:\Quoted;With;Semicolons";%SystemRoot%\stuff' + - varout.stdout_lines[0] == 'C:\\PathZ;C:\\PathA;C:\\PathWithTrailingBackslash\\;"C:\Quoted;With;Semicolons";C:\Windows\stuff' + - not idemremoveout | changed + - idemremoveout.path_value == 'C:\\PathZ;C:\\PathA;C:\\PathWithTrailingBackslash\\;"C:\Quoted;With;Semicolons";%SystemRoot%\stuff' + - idemvarout.stdout_lines[0] == 'C:\\PathZ;C:\\PathA;C:\\PathWithTrailingBackslash\\;"C:\Quoted;With;Semicolons";C:\Windows\stuff' + +- name: Remove multiple elements + win_path: + name: "{{ varname }}" + elements: + - C:\PathWithTrailingBackslash # no trailing backslash + - c:\pathz + - '"C:\Quoted;With;Semicolons"' + - '%SystemRoot%\stuff\' # add trailing backslash + state: absent + register: removeout + +- name: Get path value + raw: $env:{{ varname }} + register: varout + +- name: Ensure output + assert: + that: + - removeout | changed + - removeout.path_value == "C:\\PathA" + - varout.stdout_lines[0] == "C:\\PathA" + +- name: Test check mode add + check_mode: yes + win_path: + name: "{{ varname }}" + elements: + - C:\MissingPath + register: checkadd + +- name: Get path value + raw: $env:{{ varname }} + register: checkaddvarout + +- name: Test check mode remove + check_mode: yes + win_path: + name: "{{ varname }}" + elements: C:\PathA + state: absent + register: checkremove + +- name: Get path value + raw: $env:{{ varname }} + register: checkremovevarout + +- name: Ensure output + assert: + that: + - checkadd | changed + - checkadd.path_value == "C:\\PathA;C:\\MissingPath" + - checkaddvarout.stdout_lines[0] == "C:\\PathA" # shouldn't have actually changed the value + - checkremove | changed + - checkremove.path_value == "" + - checkremovevarout.stdout_lines[0] == "C:\\PathA" # shouldn't have actually changed the value + +- name: Remove {{ varname }} vars from user and machine scope + raw: '[Environment]::SetEnvironmentVariable("{{ varname }}", $null, "User"); [Environment]::SetEnvironmentVariable("{{ varname }}", $null, "Machine")' diff --git a/test/integration/test_win_group2.yml b/test/integration/test_win_group2.yml index 5f669630e0..94724ad073 100644 --- a/test/integration/test_win_group2.yml +++ b/test/integration/test_win_group2.yml @@ -10,3 +10,4 @@ - { role: win_get_url, tags: test_win_get_url } - { role: win_msi, tags: test_win_msi } - { role: win_package, tags: test_win_package } + - { role: win_path, tags: test_win_path }