Skip to content

Commit

Permalink
feat: Add httpapi connection (#223)
Browse files Browse the repository at this point in the history
* Change panos_config_element to only support httpapi connection.
* Fixed idempotence for set action with single element, added to unit test.
  • Loading branch information
Michael Richardson authored May 10, 2021
1 parent 82523e8 commit 5d11cfc
Show file tree
Hide file tree
Showing 10 changed files with 1,177 additions and 78 deletions.
Empty file added plugins/httpapi/__init__.py
Empty file.
637 changes: 637 additions & 0 deletions plugins/httpapi/panos.py

Large diffs are not rendered by default.

82 changes: 81 additions & 1 deletion plugins/module_utils/panos.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,12 @@

__metaclass__ = type


import re
import time
from functools import reduce

from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils.connection import Connection, ConnectionError

_MIN_VERSION_ERROR = "{0} version ({1}) < minimum version ({2})"
HAS_PANDEVICE = True
Expand Down Expand Up @@ -848,3 +852,79 @@ def get_connection(
helper.argument_spec = spec
helper.required_one_of = req
return helper


class PanOSAnsibleModule(AnsibleModule):
def __init__(
self,
argument_spec,
api_endpoint=None,
with_state=False,
with_enabled_state=False,
*args,
**kwargs
):
spec = {}

self.api_endpoint = api_endpoint

if with_state:
spec["state"] = {"default": "present", "choices": ["present", "absent"]}

if with_enabled_state:
spec["state"] = {
"default": "present",
"choices": ["present", "absent", "enabled", "disabled"],
}

argument_spec.update(spec)

super().__init__(argument_spec, *args, **kwargs)

self.connection = Connection(self._socket_path)


def cmd_xml(cmd):
def _cmd_xml(args, obj):
if not args:
return
arg = args.pop(0)
if args:
result = re.search(r'^"(.*)"$', args[0])
if result:
obj.append("<%s>" % arg)
obj.append(result.group(1))
obj.append("</%s>" % arg)
args.pop(0)
_cmd_xml(args, obj)
else:
obj.append("<%s>" % arg)
_cmd_xml(args, obj)
obj.append("</%s>" % arg)
else:
obj.append("<%s>" % arg)
_cmd_xml(args, obj)
obj.append("</%s>" % arg)

args = cmd.split()
obj = []
_cmd_xml(args, obj)
xml = "".join(obj)

return xml


def get_nested_key(d, key_list):
"""
Access a nested key within a dictionary safely.
Example:
For the dictionary d = {'one': {'two': {'three': 'four'}}},
get_nested_key(d, ['one', 'two', 'three']) will return 'four'.
:param d: Dictionary
:param key_list: List of keys, in decending order.
"""

return reduce(lambda val, key: val.get(key) if val else None, key_list, d)
96 changes: 33 additions & 63 deletions plugins/modules/panos_config_element.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-

# Copyright 2019 Palo Alto Networks, Inc
# Copyright 2020 Palo Alto Networks, Inc
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.


from __future__ import absolute_import, division, print_function

Expand All @@ -27,16 +27,15 @@
- This module allows the user to modify an element in the PAN-OS configuration
by specifying an element and its location in the configuration (xpath).
author:
- 'Nathan Embery (@nembery)'
- 'Michael Richardson (@mrichardson03)'
- 'Nathan Embery (@nembery)'
version_added: '2.7.0'
requirements:
- pan-os-python
requirements: []
notes:
- This module only supports the httpapi connection plugin.
- Checkmode is supported.
- Panorama is supported.
extends_documentation_fragment:
- paloaltonetworks.panos.fragments.provider
- paloaltonetworks.panos.fragments.state
options:
xpath:
Expand Down Expand Up @@ -64,13 +63,11 @@
vars:
banner_text: 'Authorized Personnel Only!'
panos_config_element:
provider: '{{ provider }}'
xpath: '/config/devices/entry[@name="localhost.localdomain"]/deviceconfig/system'
element: '<login-banner>{{ banner_text }}</login-banner>'
- name: Create address object
panos_config_element:
provider: '{{ provider }}'
xpath: "/config/devices/entry[@name='localhost.localdomain']/vsys/entry[@name='vsys1']/address"
element: |
<entry name="Test-One">
Expand All @@ -79,7 +76,6 @@
- name: Delete address object 'Test-One'
panos_config_element:
provider: '{{ provider }}'
xpath: "/config/devices/entry[@name='localhost.localdomain']/vsys/entry[@name='vsys1']/address/entry[@name='Test-One']"
state: 'absent'
"""
Expand All @@ -105,19 +101,11 @@

import xml.etree.ElementTree

from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils.connection import ConnectionError
from ansible_collections.paloaltonetworks.panos.plugins.module_utils.panos import (
get_connection,
PanOSAnsibleModule,
)

try:
from panos.errors import PanDeviceError
except ImportError:
try:
from pandevice.errors import PanDeviceError
except ImportError:
pass


def xml_compare(one, two, excludes=None):
"""
Expand Down Expand Up @@ -219,9 +207,6 @@ def xml_contained(big, small):

for element, path in iterpath(small):

if element.tag == "wrapped":
continue

# Elements with "member" children must have all their children be equal.
if element.find("*/member/..") is not None:
big_element = big.find(path)
Expand All @@ -232,11 +217,7 @@ def xml_contained(big, small):
# Elements with no children at the same point in the tree must match
# exactly.
elif len(element) == 0 and (element.tag != "member"):
if path != ".":
big_element = big.find(path)
else:
# handle case where small is only a single tag, thus the path ends up as '.'
big_element = big.find("./{0}".format(element.tag))
big_element = big.find(path)

if not xml_compare(big_element, element):
return False
Expand All @@ -245,33 +226,24 @@ def xml_contained(big, small):


def main():
helper = get_connection(
with_classic_provider_spec=False,
module = PanOSAnsibleModule(
argument_spec=dict(
xpath=dict(required=True),
element=dict(required=False),
edit=dict(type="bool", default=False, required=False),
),
with_state=True,
)

module = AnsibleModule(
argument_spec=helper.argument_spec,
supports_check_mode=True,
required_one_of=helper.required_one_of,
with_state=True,
)

parent = helper.get_pandevice_parent(module)

xpath = module.params["xpath"]
element_xml = module.params["element"]
edit = module.params["edit"]
state = module.params["state"]

try:
existing_element = parent.xapi.get(xpath)
existing_xml = parent.xapi.xml_document
existing = existing_element.find("./result/")
existing_xml = module.connection.get(xpath)
existing = xml.etree.ElementTree.fromstring(existing_xml).find("./result/")

changed = False
diff = {}
Expand All @@ -289,23 +261,21 @@ def main():
changed = True

if not module.check_mode: # pragma: no cover
parent.xapi.edit(xpath, element_xml)
module.connection.edit(xpath, element_xml)

else:
# When using set action, element can be an invalid XML document.
# Wrap it in a dummy tag if so.
try:
element = xml.etree.ElementTree.fromstring(element_xml)
except xml.etree.ElementTree.ParseError:
element = xml.etree.ElementTree.fromstring(
"<wrapped>" + element_xml + "</wrapped>"
)
# When using set action, element needs to be wrapped in the
# last tag in the xpath.
outer = xpath.split("/")[-1]
element = xml.etree.ElementTree.fromstring(
f"<{outer}>" + element_xml + f"</{outer}>"
)

if not xml_contained(existing, element):
changed = True

if not module.check_mode: # pragma: no cover
parent.xapi.set(xpath, element_xml)
module.connection.set(xpath, element_xml)

diff = {
"before": existing_xml,
Expand All @@ -319,7 +289,7 @@ def main():
changed = True

if not module.check_mode: # pragma: no cover
parent.xapi.delete(xpath)
module.connection.delete(xpath)

diff = {"before": existing_xml, "after": ""}

Expand All @@ -329,9 +299,9 @@ def main():

module.exit_json(changed=changed, diff=diff)

except PanDeviceError as e: # pragma: no cover
except ConnectionError as e: # pragma: no cover
module.fail_json(msg="{0}".format(e))


if __name__ == "__main__":
if __name__ == "__main__": # pragma: no cover
main()
10 changes: 2 additions & 8 deletions tests/integration/firewall/test_panos_config_element.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
---
- import_tasks: 'reset.yml'
# - import_tasks: 'reset.yml'

- name: test_panos_config_element - Simple Set
vars:
banner_text: new banner
panos_config_element:
provider: '{{ device }}'
xpath: '/config/devices/entry[@name="localhost.localdomain"]/deviceconfig/system'
element: '<login-banner>{{ banner_text }}</login-banner>'
register: result
Expand All @@ -16,11 +15,10 @@
- result is success
- result is changed

- name: test_panos_config_element - Simple Create (idempotence)
- name: test_panos_config_element - Simple Set (idempotence)
vars:
banner_text: new banner
panos_config_element:
provider: '{{ device }}'
xpath: '/config/devices/entry[@name="localhost.localdomain"]/deviceconfig/system'
element: '<login-banner>{{ banner_text }}</login-banner>'
register: result
Expand All @@ -35,7 +33,6 @@
vars:
banner_text: another banner
panos_config_element:
provider: '{{ device }}'
xpath: '/config/devices/entry[@name="localhost.localdomain"]/deviceconfig/system'
element: '<login-banner>{{ banner_text }}</login-banner>'
register: result
Expand All @@ -48,7 +45,6 @@

- name: test_panos_config_element - Delete
panos_config_element:
provider: '{{ device }}'
xpath: '/config/devices/entry[@name="localhost.localdomain"]/deviceconfig/system/login-banner'
state: absent
register: result
Expand All @@ -66,7 +62,6 @@
NTP_1: 1.ntp.org
NTP_2: 2.ntp.org
panos_config_element:
provider: '{{ device }}'
xpath: /config/devices/entry[@name='localhost.localdomain']/deviceconfig/system
element: |
<update-schedule>
Expand Down Expand Up @@ -144,7 +139,6 @@
NTP_1: 1.ntp.org
NTP_2: 2.ntp.org
panos_config_element:
provider: '{{ device }}'
xpath: /config/devices/entry[@name='localhost.localdomain']/deviceconfig/system
element: |
<update-schedule>
Expand Down
12 changes: 9 additions & 3 deletions tests/integration/inventory.example
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
[firewalls]
panos-10 ip_address=192.168.55.10
panos-10 ansible_host=192.168.55.10

[panoramas]
panorama-10 ip_address=192.168.55.5

[all:vars]
username=admin
password=P4loalto!
ansible_user=admin
ansible_password=P4loalto!

ansible_network_os=mrichardson03.panos.panos
ansible_connection=httpapi
ansible_httpapi_port=443
ansible_httpapi_use_ssl=True
ansible_httpapi_validate_certs=False
Empty file added tests/unit/httpapi/__init__.py
Empty file.
Loading

0 comments on commit 5d11cfc

Please sign in to comment.