From c759381b0b42017e70473c8d61ed08ae329619c9 Mon Sep 17 00:00:00 2001 From: Richard Levenberg Date: Thu, 30 Aug 2018 19:20:09 -0700 Subject: [PATCH] win_xml module for manipulating XML files on Windows (#26404) documentation fixups handling backup in a more ansible canonical way remove quotes from $dest Handle elements with only text child nodes --- lib/ansible/modules/windows/win_xml.ps1 | 239 ++++++++++++++++++ lib/ansible/modules/windows/win_xml.py | 90 +++++++ test/integration/targets/win_xml/aliases | 1 + .../targets/win_xml/files/config.xml | 4 + .../targets/win_xml/files/log4j.xml | 49 ++++ .../integration/targets/win_xml/meta/main.yml | 2 + .../targets/win_xml/tasks/main.yml | 73 ++++++ 7 files changed, 458 insertions(+) create mode 100644 lib/ansible/modules/windows/win_xml.ps1 create mode 100644 lib/ansible/modules/windows/win_xml.py create mode 100644 test/integration/targets/win_xml/aliases create mode 100644 test/integration/targets/win_xml/files/config.xml create mode 100644 test/integration/targets/win_xml/files/log4j.xml create mode 100644 test/integration/targets/win_xml/meta/main.yml create mode 100644 test/integration/targets/win_xml/tasks/main.yml diff --git a/lib/ansible/modules/windows/win_xml.ps1 b/lib/ansible/modules/windows/win_xml.ps1 new file mode 100644 index 0000000000..97d363fdd6 --- /dev/null +++ b/lib/ansible/modules/windows/win_xml.ps1 @@ -0,0 +1,239 @@ +#!powershell + +# Copyright: (c) 2018, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.Legacy + +Set-StrictMode -Version 2 + +function Copy-Xml($dest, $src, $xmlorig) { + if ($src.get_NodeType() -eq "Text") { + $dest.set_InnerText($src.get_InnerText()) + } + + if ($src.get_HasAttributes()) { + foreach ($attr in $src.get_Attributes()) { + $dest.SetAttribute($attr.get_Name(), $attr.get_Value()) + } + } + + if ($src.get_HasChildNodes()) { + foreach ($childnode in $src.get_ChildNodes()) { + if ($childnode.get_NodeType() -eq "Element") { + $newnode = $xmlorig.CreateElement($childnode.get_Name(), $xmlorig.get_DocumentElement().get_NamespaceURI()) + Copy-Xml $newnode $childnode $xmlorig + $dest.AppendChild($newnode) | Out-Null + } elseif ($childnode.get_NodeType() -eq "Text") { + $dest.set_InnerText($childnode.get_InnerText()) + } + } + } +} + +function Compare-XmlDocs($actual, $expected) { + if ($actual.get_Name() -ne $expected.get_Name()) { + throw "Actual name not same as expected: actual=" + $actual.get_Name() + ", expected=" + $expected.get_Name() + } + ##attributes... + + if (($actual.get_NodeType() -eq "Element") -and ($expected.get_NodeType() -eq "Element")) { + if ($actual.get_HasAttributes() -and $expected.get_HasAttributes()) { + if ($actual.get_Attributes().Count -ne $expected.get_Attributes().Count) { + throw "attribute mismatch for actual=" + $actual.get_Name() + } + for ($i=0;$i -lt $expected.get_Attributes().Count; $i =$i+1) { + if ($expected.get_Attributes()[$i].get_Name() -ne $actual.get_Attributes()[$i].get_Name()) { + throw "attribute name mismatch for actual=" + $actual.get_Name() + } + if ($expected.get_Attributes()[$i].get_Value() -ne $actual.get_Attributes()[$i].get_Value()) { + throw "attribute value mismatch for actual=" + $actual.get_Name() + } + } + } + + if (($actual.get_HasAttributes() -and !$expected.get_HasAttributes()) -or (!$actual.get_HasAttributes() -and $expected.get_HasAttributes())) { + throw "attribute presence mismatch for actual=" + $actual.get_Name() + } + } + + ##children + if ($expected.get_ChildNodes().Count -ne $actual.get_ChildNodes().Count) { + throw "child node mismatch. for actual=" + $actual.get_Name() + } + + for ($i=0;$i -lt $expected.get_ChildNodes().Count; $i =$i+1) { + if (-not $actual.get_ChildNodes()[$i]) { + throw "actual missing child nodes. for actual=" + $actual.get_Name() + } + Compare-XmlDocs $expected.get_ChildNodes()[$i] $actual.get_ChildNodes()[$i] + } + + if ($expected.get_InnerText()) { + if ($expected.get_InnerText() -ne $actual.get_InnerText()) { + throw "inner text mismatch for actual=" + $actual.get_Name() + } + } + elseif ($actual.get_InnerText()) { + throw "actual has inner text but expected does not for actual=" + $actual.get_Name() + } +} + +function BackupFile($path) { + $backuppath = $path + "." + [DateTime]::Now.ToString("yyyyMMdd-HHmmss"); + Copy-Item $path $backuppath; + return $backuppath; +} + +$params = Parse-Args $args -supports_check_mode $true +$check_mode = Get-AnsibleParam -obj $params -name "_ansible_check_mode" -type "bool" -default $false + +$debug_level = Get-AnsibleParam -obj $params -name "_ansible_verbosity" -type "int" +$debug = $debug_level -gt 2 + +$dest = Get-AnsibleParam $params "path" -type "path" -FailIfEmpty $true -aliases "dest", "file" +$fragment = Get-AnsibleParam $params "fragment" -type "str" -FailIfEmpty $true -aliases "xmlstring" +$xpath = Get-AnsibleParam $params "xpath" -type "str" -FailIfEmpty $true +$backup = Get-AnsibleParam $params "backup" -type "bool" -Default $false +$type = Get-AnsibleParam $params "type" -type "str" -Default "element" -ValidateSet "element", "attribute", "text" +$attribute = Get-AnsibleParam $params "attribute" -type "str" -FailIfEmpty ($type -eq "attribute") +$state = Get-AnsibleParam $params "state" -type "str" -Default "present" + +$result = @{ + changed = $false +} + +If (-Not (Test-Path -Path $dest -PathType Leaf)){ + Fail-Json $result "Specified path $dest does not exist or is not a file." +} + +[xml]$xmlorig = $null +Try { + [xml]$xmlorig = Get-Content -Path $dest +} +Catch { + Fail-Json $result "Failed to parse file at '$dest' as an XML document: $($_.Exception.Message)" +} + +$namespaceMgr = New-Object System.Xml.XmlNamespaceManager $xmlorig.NameTable +$namespace = $xmlorig.DocumentElement.NamespaceURI +$localname = $xmlorig.DocumentElement.LocalName + +$namespaceMgr.AddNamespace($xmlorig.$localname.SchemaInfo.Prefix, $namespace) + +if ($type -eq "element") { + $xmlchild = $null + Try { + $xmlchild = [xml]$fragment + } Catch { + Fail-Json $result "Failed to parse fragment as XML: $($_.Exception.Message)" + } + + $child = $xmlorig.CreateElement($xmlchild.get_DocumentElement().get_Name(), $xmlorig.get_DocumentElement().get_NamespaceURI()) + Copy-Xml $child $xmlchild.DocumentElement $xmlorig + + $node = $xmlorig.SelectSingleNode($xpath, $namespaceMgr) + if ($node.get_NodeType() -eq "Document") { + $node = $node.get_DocumentElement() + } + $elements = $node.get_ChildNodes() + [bool]$present = $false + [bool]$changed = $false + if ($elements.get_Count()) { + if ($debug) { + $err = @() + $result.err = {$err}.Invoke() + } + foreach ($element in $elements) { + try { + Compare-XmlDocs $child $element + $present = $true + break + } catch { + if ($debug) { + $result.err.Add($_.Exception.ToString()) + } + } + } + if (!$present -and ($state -eq "present")) { + [void]$node.AppendChild($child) + $result.msg = "xml added" + $changed = $true + } elseif ($present -and ($state -eq "absent")) { + [void]$node.RemoveChild($element) + $result.msg = "xml removed" + $changed = $true + } + } else { + if ($state -eq "present") { + [void]$node.AppendChild($child) + $result.msg = "xml added" + $changed = $true + } + } + + if ($changed) { + $result.changed = $true + if (!$check_mode) { + if ($backup) { + $result.backup = BackupFile($dest) + } + $xmlorig.Save($dest) + } else { + $result.msg += " check mode" + } + } else { + $result.msg = "not changed" + } +} elseif ($type -eq "text") { + $node = $xmlorig.SelectSingleNode($xpath, $namespaceMgr) + [bool]$add = ($node.get_InnerText() -ne $fragment) + if ($add) { + $result.changed = $true + if (-Not $check_mode) { + if ($backup) { + $result.backup = BackupFile($dest) + } + $node.set_InnerText($fragment) + $xmlorig.Save($dest) + $result.msg = "text changed" + } else { + $result.msg = "text changed check mode" + } + } else { + $result.msg = "not changed" + } +} elseif ($type -eq "attribute") { + $node = $xmlorig.SelectSingleNode($xpath, $namespaceMgr) + [bool]$add = !$node.HasAttribute($attribute) -Or ($node.$attribute -ne $fragment) + if ($add -And ($state -eq "present")) { + $result.changed = $true + if (-Not $check_mode) { + if ($backup) { + $result.backup = BackupFile($dest) + } + if (!$node.HasAttribute($attribute)) { + $node.SetAttributeNode($attribute, $xmlorig.get_DocumentElement().get_NamespaceURI()) + } + $node.SetAttribute($attribute, $fragment) + $xmlorig.Save($dest) + $result.msg = "text changed" + } else { + $result.msg = "text changed check mode" + } + } elseif (!$add -And ($state -eq "absent")) { + $result.changed = $true + if (-Not $check_mode) { + if ($backup) { + $result.backup = BackupFile($dest) + } + $node.RemoveAttribute($attribute) + $xmlorig.Save($dest) + $result.msg = "text changed" + } + } else { + $result.msg = "not changed" + } +} + +Exit-Json $result \ No newline at end of file diff --git a/lib/ansible/modules/windows/win_xml.py b/lib/ansible/modules/windows/win_xml.py new file mode 100644 index 0000000000..08bf23aea2 --- /dev/null +++ b/lib/ansible/modules/windows/win_xml.py @@ -0,0 +1,90 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2018, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +# this is a windows documentation stub. actual code lives in the .ps1 +# file of the same name + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community'} + +DOCUMENTATION = r''' +--- +module: win_xml +version_added: "2.7" +short_description: Add XML fragment to an XML parent +description: + - Adds XML fragments formatted as strings to existing XML on remote servers. +options: + path: + description: + - The path of remote servers XML. + required: true + aliases: [ dest, file ] + fragment: + description: + - The string representation of the XML fragment to be added. + required: true + aliases: [ xmlstring ] + xpath: + description: + - The node of the remote server XML where the fragment will go. + required: true + backup: + description: + - Whether to backup the remote server's XML before applying the change. + type: bool + default: 'no' + type: + description: + - The type of XML you are working with. + required: yes + default: element + choices: + - element + - attribute + - text + attribute: + description: + - The attribute name if the type is 'attribute'. Required if C(type=attribute). + +author: + - Richard Levenberg (@richardcs) +''' + +EXAMPLES = r''' +# Apply our filter to Tomcat web.xml +- win_xml: + path: C:\apache-tomcat\webapps\myapp\WEB-INF\web.xml + fragment: 'MyFiltercom.example.MyFilter' + xpath: '/*' + +# Apply sslEnabledProtocols to Tomcat's server.xml +- win_xml: + path: C:\Tomcat\conf\server.xml + xpath: '//Server/Service[@name="Catalina"]/Connector[@port="9443"]' + attribute: 'sslEnabledProtocols' + fragment: 'TLSv1,TLSv1.1,TLSv1.2' + type: attribute +''' + +RETURN = r''' +msg: + description: what was done + returned: always + type: string + sample: "xml added" +err: + description: xml comparison exceptions + returned: always, for type element and -vvv or more + type: list + sample: attribute mismatch for actual=string +backup: + description: name of the backup file, if created + returned: changed + type: string + sample: C:\config.xml.19700101-000000 +''' diff --git a/test/integration/targets/win_xml/aliases b/test/integration/targets/win_xml/aliases new file mode 100644 index 0000000000..4cd27b3cb2 --- /dev/null +++ b/test/integration/targets/win_xml/aliases @@ -0,0 +1 @@ +shippable/windows/group1 diff --git a/test/integration/targets/win_xml/files/config.xml b/test/integration/targets/win_xml/files/config.xml new file mode 100644 index 0000000000..68a6cdca47 --- /dev/null +++ b/test/integration/targets/win_xml/files/config.xml @@ -0,0 +1,4 @@ + + + bar + diff --git a/test/integration/targets/win_xml/files/log4j.xml b/test/integration/targets/win_xml/files/log4j.xml new file mode 100644 index 0000000000..54b76cf7f2 --- /dev/null +++ b/test/integration/targets/win_xml/files/log4j.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/integration/targets/win_xml/meta/main.yml b/test/integration/targets/win_xml/meta/main.yml new file mode 100644 index 0000000000..d328716dfa --- /dev/null +++ b/test/integration/targets/win_xml/meta/main.yml @@ -0,0 +1,2 @@ +dependencies: + - prepare_win_tests diff --git a/test/integration/targets/win_xml/tasks/main.yml b/test/integration/targets/win_xml/tasks/main.yml new file mode 100644 index 0000000000..112d86cccb --- /dev/null +++ b/test/integration/targets/win_xml/tasks/main.yml @@ -0,0 +1,73 @@ +# test code for the Windows xml module +# (c) 2017, Richard Levenberg + +# 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 . + +- name: copy a test .xml file + win_copy: + src: config.xml + dest: "{{win_output_dir}}\\config.xml" + +- name: add an element that only has a text child node + win_xml: + path: "{{win_output_dir}}\\config.xml" + fragment: '42' + xpath: '/config' + register: element_add_result + +- name: check element add result + assert: + that: + - element_add_result is changed + +- name: try to add the element that only has a text child node again + win_xml: + path: "{{win_output_dir}}\\config.xml" + fragment: '42' + xpath: '/config' + register: element_add_result_second + +- name: check element add result + assert: + that: + - not element_add_result_second is changed + +- name: copy a test log4j.xml + win_copy: + src: log4j.xml + dest: "{{win_output_dir}}\\log4j.xml" + +- name: change an attribute to fatal logging + win_xml: + path: "{{win_output_dir}}\\log4j.xml" + xpath: '/log4j:configuration/logger[@name="org.apache.commons.digester"]/level' + type: attribute + attribute: 'value' + fragment: 'FATAL' + +- name: try to change the attribute again + win_xml: + path: "{{win_output_dir}}\\log4j.xml" + xpath: '/log4j:configuration/logger[@name="org.apache.commons.digester"]/level' + type: attribute + attribute: 'value' + fragment: 'FATAL' + register: attribute_changed_result + +- name: check attribute change result + assert: + that: + - not attribute_changed_result is changed