From 2fef412c370f2426f9d19197885573e6c26d424e Mon Sep 17 00:00:00 2001 From: Joey Espinosa Date: Wed, 20 May 2020 00:17:27 -0400 Subject: [PATCH] feat: add json_patch module for JSON patching --- .github/BOTMETA.yml | 3 + plugins/modules/files/json_patch.py | 580 ++++++++++++++++++ plugins/modules/json_patch.py | 1 + tests/integration/targets/json_patch/aliases | 1 + .../targets/json_patch/files/test.json | 1 + .../targets/json_patch/meta/main.yml | 20 + .../targets/json_patch/tasks/main.yml | 28 + .../targets/json_patch/tasks/tests.yml | 268 ++++++++ .../plugins/modules/files/test_json_patch.py | 318 ++++++++++ 9 files changed, 1220 insertions(+) create mode 100644 plugins/modules/files/json_patch.py create mode 120000 plugins/modules/json_patch.py create mode 100644 tests/integration/targets/json_patch/aliases create mode 100644 tests/integration/targets/json_patch/files/test.json create mode 100644 tests/integration/targets/json_patch/meta/main.yml create mode 100644 tests/integration/targets/json_patch/tasks/main.yml create mode 100644 tests/integration/targets/json_patch/tasks/tests.yml create mode 100644 tests/unit/plugins/modules/files/test_json_patch.py diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index ffe7d39d944..7754c85f83c 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -596,6 +596,9 @@ files: authors: jpmens noseka1 $modules/files/iso_extract.py: authors: dagwieers jhoekx ribbons + $modules/files/json_patch.py: + authors: particledecay + labels: json $modules/files/read_csv.py: authors: dagwieers $modules/files/xattr.py: diff --git a/plugins/modules/files/json_patch.py b/plugins/modules/files/json_patch.py new file mode 100644 index 00000000000..8e15d46751f --- /dev/null +++ b/plugins/modules/files/json_patch.py @@ -0,0 +1,580 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2019, Joey Espinosa +# Copyright: (c) 2019, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +DOCUMENTATION = r''' +--- +module: json_patch +author: "Joey Espinosa (@particledecay)" +short_description: Patch JSON documents +version_added: 0.2.0 +description: + - Patch JSON documents using JSON Patch standard. + - "RFC 6901: U(https://tools.ietf.org/html/rfc6901)" + - "RFC 6902: U(https://tools.ietf.org/html/rfc6902)" +options: + src: + description: + - The path to the source JSON file. + required: yes + type: str + dest: + description: + - The path to the destination JSON file. + type: str + operations: + description: + - A list of operations to perform on the JSON document. + required: yes + type: list + elements: dict + suboptions: + op: + description: + - The type of operation to perform. + required: yes + type: str + choices: [add, remove, replace, move, copy, test] + path: + description: + - Path to the JSON object in JSON Pointer format (U(https://tools.ietf.org/html/rfc6901)). + required: yes + type: str + value: + description: + - Value to apply with the operation. + type: raw + backup: + description: + - Copy the targeted file to a backup prior to patch. + type: bool + unsafe_writes: + description: + - Allow Ansible to fall back to unsafe methods of writing files (some systems do not support atomic operations). + type: bool + pretty: + description: + - Write pretty-print JSON when file is changed. + type: bool + create: + description: + - Create a file if it does not already exist. + type: bool + create_type: + description: + - Initialize a newly created JSON file as an object or array. + choices: [array, object] + default: "object" + type: str +''' + +EXAMPLES = r''' +# These examples are using the following JSON: +# +# [ +# { +# "foo": { +# "one": 1, +# "two": 2, +# "three": 3 +# }, +# "enabled": true +# }, +# { +# "bar": { +# "one": 1, +# "two": 2, +# "three": 3 +# }, +# "enabled": false +# }, +# { +# "baz": [ +# { +# "foo": "apples", +# "bar": "oranges" +# }, +# { +# "foo": "grapes", +# "bar": "oranges" +# }, +# { +# "foo": "bananas", +# "bar": "potatoes" +# } +# ], +# "enabled": true +# } +# ] +# +- name: Add a fourth element to the "foo" object + json_patch: + src: "test.json" + dest: "test2.json" + operations: + - op: add + path: "/0/foo/four" + value: 4 + +- name: Remove the first object in the "baz" list of fruits + json_patch: + src: "test.json" + pretty: yes + operations: + - op: remove + path: "/2/baz/0" + +- name: Move the "potatoes" value from the "baz" list to the "foo" object + json_patch: + src: "test.json" + backup: yes + operations: + - op: move + from: "/2/baz/2/bar" + path: "/0/foo/bar" + +- name: Test that the "foo" object has three members + json_patch: + src: "test.json" + operations: + - op: test + path: "/0/foo/one" + value: 1 + - op: test + path: "/0/foo/two" + value: 2 + - op: test + path: "/0/foo/three" + value: 3 +''' + +RETURN = r''' +tested: + description: The result of any included test operation. + returned: when test operation exists + type: bool +backup: + description: The name of the backed up file. + returned: when backup is true + type: str +dest: + description: The name of the file that was written. + returned: changed + type: str +created: + description: Whether the file was newly created. + returned: always + type: bool +''' + +import json +import os +import tempfile + +from ansible.module_utils import basic +from ansible.module_utils._text import to_bytes, to_native + + +def set_module_args(args): + """For dynamic module args (such as for testing).""" + args = json.dumps({'ANSIBLE_MODULE_ARGS': args}) + basic._ANSIBLE_ARGS = to_bytes(args) + + +class PathError(Exception): + """Raised when no valid JSON object or property is found for a given path.""" + pass + + +class PatchManager(object): + """Manage the Ansible portion of JSONPatcher.""" + + def __init__(self, module): + self.module = module + self.create = self.module.params.get('create', False) + self.create_type = self.module.params.get('create_type', 'object').lower() + empty = False + + # validate file + self.src = self.module.params['src'] + if not os.path.isfile(self.src): + if not self.create: + self.module.fail_json(msg="could not find file at `%s`" % self.src) + empty = True + + # use 'src' as the output file, unless 'dest' is provided + self.outfile = self.src + self.dest = self.module.params.get('dest') + if self.dest is not None: + self.outfile = self.dest + + try: + self.json_doc = open(self.src).read() if not empty else "" + except IOError as e: + self.module.fail_json(msg="could not read file at `%s`: %s" % (self.src, to_native(e))) + + # create empty JSON if requested + if self.json_doc == "" and self.create: + if self.create_type == "object": + self.json_doc = "{}" + elif self.create_type == "array": + self.json_doc = "[]" + else: + self.module.fail_json(msg="invalid option for 'create_type': %s" % self.create_type) + + self.operations = self.module.params['operations'] + try: + self.patcher = JSONPatcher(self.json_doc, *self.operations) + except Exception as e: + self.module.fail_json(msg=to_native(e)) + + self.do_backup = self.module.params.get('backup', False) + self.pretty_print = self.module.params.get('pretty', False) + + def run(self): + changed, tested = self.patcher.patch() + result = {'changed': changed} + if tested is not None: + result['tested'] = tested + if result['changed']: # let's write the changes + dump_kwargs = {} + if self.pretty_print: + dump_kwargs.update({'indent': 4, 'separators': (',', ': ')}) + + result['diff'] = dict( + before=self.json_doc, + after=json.dumps(self.patcher.obj, **dump_kwargs), + before_header='%s (content)' % self.module.params['src'], + after_header='%s (content)' % self.module.params['src'], + ) + result.update(self.write()) + return result + + def backup(self): + """Create a backup copy of the JSON file.""" + return {'backup': self.module.backup_local(self.outfile)} + + def write(self): + result = {'dest': self.outfile} + + if self.module.check_mode: # stop here before doing anything permanent + return result + + dump_kwargs = {} + if self.pretty_print: + dump_kwargs.update({'indent': 4, 'separators': (',', ': ')}) + + if self.do_backup: # backup first if needed + result.update(self.backup()) + + tmpfd, tmpfile = tempfile.mkstemp() + with open(tmpfile, "w") as f: + f.write(json.dumps(self.patcher.obj, **dump_kwargs)) + + self.module.atomic_move( + tmpfile, + to_native( + os.path.realpath(to_bytes(self.outfile, errors='surrogate_or_strict')), errors='surrogate_or_strict' + ), + unsafe_writes=self.module.params['unsafe_writes'] + ) + + return result + + +class JSONPatcher(object): + """Patch JSON documents according to RFC 6902.""" + + def __init__(self, json_doc, *operations): + try: + self.obj = json.loads(json_doc) # let this fail if it must + except (ValueError, TypeError): + raise Exception("invalid JSON found") + self.operations = operations + + # validate all operations + for op in self.operations: + self.validate_operation(op) + + def validate_operation(self, members): + """Validate that an operation is in compliance with RFC 6902. + + Args: + members(dict): a dict of members that comprise one patch operation + """ + if 'op' not in members: + raise ValueError("'%s' is missing an 'op' member" % repr(members)) + + allowed_ops = ('add', 'remove', 'replace', 'move', 'copy', 'test') + if members['op'] not in allowed_ops: + raise ValueError("'%s' is not a valid patch operation" % members['op']) + + if 'path' not in members: + raise ValueError("'%s' is missing a 'path' member" % repr(members)) + + if members['op'] == 'add': + if 'value' not in members: + raise ValueError("'%s' is an 'add' but does not have a 'value'" % repr(members)) + + def patch(self): + """Perform all of the given patch operations.""" + modified = None # whether we modified the object after all operations + test_result = None + for patch in self.operations: + op = patch['op'] + del patch['op'] + + # we can't accept 'from' as a dict key + if patch.get('from') is not None: + patch['from_path'] = patch['from'] + del patch['from'] + + # attach object to patch operation (helpful for recursion) + patch['obj'] = self.obj + new_obj, changed, tested = getattr(self, op)(**patch) + if changed is not None or op == "remove": # 'remove' will fail if we don't actually remove anything + modified = bool(changed) + if modified is True: + self.obj = new_obj + if tested is not None: + test_result = False if test_result is False else tested # one false test fails everything + return modified, test_result + + def _get(self, path, obj, **discard): + """Return a value at 'path'.""" + elements = path.lstrip('/').split('/') + next_obj = obj + for idx, elem in enumerate(elements): + try: + next_obj = next_obj[elem] + except KeyError: + if idx == (len(elements) - 1): # this helps us stay idempotent + return None + raise PathError("'%s' was not found in the JSON object" % path) # wrong path specified + except TypeError: # it's a list + if not elem.isdigit(): + raise PathError("'%s' is not a valid index for a JSON array" % path) + try: + next_obj = next_obj[int(elem)] + except IndexError: + if idx == (len(elements) - 1): # this helps us stay idempotent + return None + raise PathError("specified index '%s' was not found in JSON array" % path) + return next_obj + + # https://tools.ietf.org/html/rfc6902#section-4.1 + def add(self, path, value, obj, **discard): + """Perform an 'add' operation.""" + chg = False + path = path.lstrip('/') + if "/" not in path: # recursion termination + if isinstance(obj, dict): + old_value = obj.get(path) + obj[path] = value + if obj[path] != old_value: + chg = True + return obj, chg, None + elif isinstance(obj, list): + if path == "-": # points to end of list + obj.append(value) + chg = True + elif not path.isdigit(): + raise PathError("'%s' is not a valid index for a JSON array" % path) + else: + idx = int(path) + if idx > len(obj): # violation of rfc 6902 + raise PathError( + "specified index '%s' cannot be greater than the number of elements in JSON array" % path + ) + obj.insert(idx, value) + chg = True + return obj, chg, None + else: # traverse obj until last path member + elements = path.split('/') + path, remaining = elements[0], '/'.join(elements[1:]) + + next_obj = None + if isinstance(obj, dict): + try: + next_obj = obj[path] + except KeyError: + raise PathError("could not find '%s' member in JSON object" % path) + obj[path], chg, tst = self.add(remaining, value, next_obj) + elif isinstance(obj, list): + if not path.isdigit(): + raise PathError("'%s' is not a valid index for a JSON array" % path) + try: + next_obj = obj[int(path)] + except IndexError: + if int(path) > len(obj): # violation of rfc 6902 + raise PathError( + "specified index '%s' cannot be greater than the number of elements in JSON array" % path + ) + else: + raise PathError("could not find index '%s' in JSON array" % path) + obj[int(path)], chg, tst = self.add(remaining, value, next_obj) + return obj, chg, None + + # https://tools.ietf.org/html/rfc6902#section-4.2 + def remove(self, path, obj, **discard): + """Perform a 'remove' operation.""" + removed = None + path = path.lstrip('/') + if "/" not in path: # recursion termination + try: + removed = obj.pop(path) + except KeyError: + return obj, None, None + except TypeError: # it's a list + if not path.isdigit(): + raise PathError("'%s' is not a valid index for a JSON array" % path) + try: + removed = obj.pop(int(path)) + except IndexError: + return obj, None, None + return obj, removed, None + else: # traverse obj until last path member + elements = path.split('/') + path, remaining = elements[0], '/'.join(elements[1:]) + + next_obj = None + if isinstance(obj, dict): + try: + next_obj = obj[path] + except KeyError: + raise PathError("could not find '%s' member in JSON object" % path) + obj[path], removed, tst = self.remove(remaining, next_obj) + elif isinstance(obj, list): + if not path.isdigit(): + raise PathError("'%s' is not a valid index for a JSON array" % path) + try: + next_obj = obj[int(path)] + except IndexError: + if int(path) > len(obj): # violation of rfc 6902 + raise PathError( + "specified index '%s' cannot be greater than the number of elements in JSON array" % path + ) + else: + raise PathError("could not find index '%s' in JSON array" % path) + obj[int(path)], removed, tst = self.remove(remaining, next_obj) + return obj, removed, None + + # https://tools.ietf.org/html/rfc6902#section-4.3 + def replace(self, path, value, obj, **discard): + """Perform a 'replace' operation.""" + old_value = self._get(path, obj) + if old_value == value: + return obj, False, None + if old_value is None: # the target location must exist for operation to be successful + raise PathError("could not find '%s' member in JSON object" % path) + new_obj, dummy, tst = self.remove(path, obj) + new_obj, chg, tst = self.add(path, value, new_obj) + return new_obj, chg, None + + # https://tools.ietf.org/html/rfc6902#section-4.4 + def move(self, from_path, path, obj, **discard): + """Perform a 'move' operation.""" + chg = False + new_obj, removed, tst = self.remove(from_path, obj) + if removed is not None: # don't inadvertently add 'None' as a value somewhere + new_obj, chg, tst = self.add(path, removed, new_obj) + return new_obj, chg, None + + # https://tools.ietf.org/html/rfc6902#section-4.5 + def copy(self, from_path, path, obj, **discard): + """Perform a 'copy' operation.""" + value = self._get(from_path, obj) + if value is None: + raise PathError("could not find '%s' member in JSON object" % path) + new_obj, chg, tst = self.add(path, value, obj) + return new_obj, chg, None + + # https://tools.ietf.org/html/rfc6902#section-4.6 + def test(self, path, value, obj, **discard): + """Perform a 'test' operation. + + This operation supports an additional feature not outlined in + RFC 6901 (https://tools.ietf.org/html/rfc6901): The ability to + reference every member of an array by using an asterisk (*). + + In such a case, each member of the array at that point in the + path will be tested sequentially for the given value, and if + a matching value is found, the method will return immediately + with a True value. + + Example: + {"op": "test", "path": "/array/*/member/property", "value": 2} + + If the object to be tested looked like... + { + "array": [ + {"member": {"property": 1}}, + {"member": {"property": 2}} + ] + } + ... the result would be True, because an object exists within + "array" that has the matching path and value. + """ + elements = path.lstrip('/').split('/') + next_obj = obj + for idx, elem in enumerate(elements): + if elem == "*": # wildcard + if not isinstance(next_obj, list): + return obj, None, False + for sub_obj in next_obj: + dummy, chg, found = self.test('/'.join(elements[(idx + 1):]), value, sub_obj) + if found: + return obj, None, found + return obj, None, False + try: + next_obj = next_obj[elem] + except KeyError: + return obj, None, False + except TypeError: # it's a list + if not elem.isdigit(): + return obj, None, False + try: + next_obj = next_obj[int(elem)] + except IndexError: + return obj, None, False + return obj, None, next_obj == value + + +def main(): + # Parsing argument file + module = basic.AnsibleModule( + argument_spec=dict( + src=dict(required=True, type='str'), + dest=dict(required=False, type='str'), + operations=dict( + required=True, + type='list', + elements=dict( + op=dict(required=True, type='str', choices=['add', 'remove', 'replace', 'move', 'copy', 'test']), + path=dict(required=True, type='str'), + value=dict(required=False, type='raw'), + ) + ), + backup=dict(required=False, default=False, type='bool'), + unsafe_writes=dict(required=False, default=False, type='bool'), + pretty=dict(required=False, default=False, type='bool'), + create=dict(required=False, default=False, type='bool'), + create_type=dict(required=False, default='object', type='str', choices=['object', 'array']), + ), + supports_check_mode=True + ), + + manager = PatchManager(module) + result = manager.run() + + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/json_patch.py b/plugins/modules/json_patch.py new file mode 120000 index 00000000000..a3b992f1405 --- /dev/null +++ b/plugins/modules/json_patch.py @@ -0,0 +1 @@ +./files/json_patch.py \ No newline at end of file diff --git a/tests/integration/targets/json_patch/aliases b/tests/integration/targets/json_patch/aliases new file mode 100644 index 00000000000..e2dcf795c00 --- /dev/null +++ b/tests/integration/targets/json_patch/aliases @@ -0,0 +1 @@ +shippable/posix/group2 \ No newline at end of file diff --git a/tests/integration/targets/json_patch/files/test.json b/tests/integration/targets/json_patch/files/test.json new file mode 100644 index 00000000000..daa5692abb9 --- /dev/null +++ b/tests/integration/targets/json_patch/files/test.json @@ -0,0 +1 @@ +{"foo": {"one": 1, "bar": "baz"}} \ No newline at end of file diff --git a/tests/integration/targets/json_patch/meta/main.yml b/tests/integration/targets/json_patch/meta/main.yml new file mode 100644 index 00000000000..bfd7639e0cb --- /dev/null +++ b/tests/integration/targets/json_patch/meta/main.yml @@ -0,0 +1,20 @@ +# test code for json_patch module +# (c) 2019, Joey Espinosa + +# 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 . + +dependencies: + - setup_remote_tmp_dir diff --git a/tests/integration/targets/json_patch/tasks/main.yml b/tests/integration/targets/json_patch/tasks/main.yml new file mode 100644 index 00000000000..473f1952653 --- /dev/null +++ b/tests/integration/targets/json_patch/tasks/main.yml @@ -0,0 +1,28 @@ +- set_fact: + virtualenv: "{{ remote_tmp_dir }}/virtualenv" + virtualenv_command: "{{ ansible_python_interpreter }} -m virtualenv" + +- set_fact: + virtualenv_interpreter: "{{ virtualenv }}/bin/python" + +- pip: + name: virtualenv + +# need this for integration tests... only way to test JSON before this module existed +- pip: + name: + - jmespath + - coverage + virtualenv: "{{ virtualenv }}" + virtualenv_command: "{{ virtualenv_command }}" + virtualenv_site_packages: no + +- include_tasks: tests.yml + vars: + ansible_python_interpreter: "{{ virtualenv_interpreter }}" + output_dir: "{{ remote_tmp_dir }}" + +# cleanup +- file: + path: "{{ virtualenv }}" + state: absent \ No newline at end of file diff --git a/tests/integration/targets/json_patch/tasks/tests.yml b/tests/integration/targets/json_patch/tasks/tests.yml new file mode 100644 index 00000000000..663924a56b8 --- /dev/null +++ b/tests/integration/targets/json_patch/tasks/tests.yml @@ -0,0 +1,268 @@ +# test code for the json_patch module +# (c) 2019, Joey Espinosa + +# 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: Deploy the test json file + copy: + src: test.json + dest: "{{ output_dir }}/test.json" + register: result + +- name: Store the checksum of the test json file + set_fact: + test_json_checksum: "91b69ff162bb0805386ae0d57a30a6b992bc5ecc" + +- name: Assert that the test json file was deployed + assert: + that: + - result is changed + - result.checksum == test_json_checksum + - result.state == "file" + +- name: Run an add operation in check mode + json_patch: + src: "{{ output_dir }}/test.json" + dest: "{{ output_dir }}/test2.json" + operations: + - op: add + path: "/foo/quz" + value: "toto" + check_mode: yes + register: check_result1 + +- name: Stat the initial file again + stat: + path: "{{ output_dir }}/test.json" + register: result2 + +- name: Stat the (hopefully non-existent) destination file + stat: + path: "{{ output_dir }}/test2.json" + register: dest_nonexistent + +- name: Assert that the check mode operation did nothing + assert: + that: + - result2.stat.checksum == test_json_checksum + - check_result1 is not changed + - not dest_nonexistent.stat.exists + +- name: Add a 'qux' member to the json object, and back it up + json_patch: + src: "{{ output_dir }}/test.json" + operations: + - op: add + path: "/foo/qux" + value: "quux" + backup: yes + register: add_result1 + +- name: Assert that the 'qux' member was added and backup is valid + assert: + that: + - add_result1 is changed + - "lookup('file', output_dir + '/test.json') | from_json | json_query('foo.qux') == 'quux'" + +- name: Stat the backed up file + stat: + path: "{{ add_result1.backup }}" + register: backed_up + +- name: Assert that backup is valid + assert: + that: + - backed_up.stat.checksum == test_json_checksum + +- name: Add the 'qux' member to the json object again + json_patch: + src: "{{ output_dir }}/test.json" + operations: + - op: add + path: "/foo/qux" + value: "quux" + register: add_result2 + +- name: Assert that the add did not change this time + assert: + that: + - add_result2 is not changed + +- name: Remove the 'bar' member from the json object + json_patch: + src: "{{ output_dir }}/test.json" + operations: + - op: remove + path: "/foo/bar" + register: remove_result1 + +- name: Assert that the 'bar' member was removed + assert: + that: + - remove_result1 is changed + - "not lookup('file', output_dir + '/test.json') | from_json | json_query('foo.bar')" + +- name: Remove the 'bar' member from the json object again + json_patch: + src: "{{ output_dir }}/test.json" + operations: + - op: remove + path: "/foo/bar" + register: remove_result2 + +- name: Assert that the remove did not change this time + assert: + that: + - remove_result2 is not changed + - "not lookup('file', output_dir + '/test.json') | from_json | json_query('foo.bar')" + +- name: Restore the backup json file + copy: + src: "{{ add_result1.backup }}" + dest: "{{ output_dir }}/test.json" + register: restored_file + +- name: Assert that the backup file was successfully deployed + assert: + that: + - restored_file is changed + - restored_file.checksum == test_json_checksum + - restored_file.state == "file" + +- name: Move the 'one' member to 'uno' + json_patch: + src: "{{ output_dir }}/test.json" + operations: + - op: move + from: "/foo/one" + path: "/foo/uno" + register: move_result1 + +- name: Assert that the 'one' member was moved + assert: + that: + - move_result1 is changed + - "not lookup('file', output_dir + '/test.json') | from_json | json_query('foo.one')" + - "lookup('file', output_dir + '/test.json') | from_json | json_query('foo.uno') == 1" + +- name: Move the 'one' member to 'uno' again + json_patch: + src: "{{ output_dir }}/test.json" + operations: + - op: move + from: "/foo/one" + path: "/foo/uno" + register: move_result2 + +- name: Assert that the move did not change this time + assert: + that: + - move_result2 is not changed + - "not lookup('file', output_dir + '/test.json') | from_json | json_query('foo.one')" + - "lookup('file', output_dir + '/test.json') | from_json | json_query('foo.uno') == 1" + +- name: Replace the 'uno' member with a new value + json_patch: + src: "{{ output_dir }}/test.json" + operations: + - op: replace + path: "/foo/uno" + value: "one" + register: replace_result1 + +- name: Assert that the 'uno' member's value was replaced + assert: + that: + - replace_result1 is changed + - "lookup('file', output_dir + '/test.json') | from_json | json_query('foo.uno') == 'one'" + +- name: Replace the 'uno' member's value again + json_patch: + src: "{{ output_dir }}/test.json" + operations: + - op: replace + path: "/foo/uno" + value: "one" + register: replace_result2 + +- name: Assert that the replace did not change this time + assert: + that: + - replace_result2 is not changed + - "lookup('file', output_dir + '/test.json') | from_json | json_query('foo.uno') == 'one'" + +- name: Copy the 'uno' member into a new member and change its value + json_patch: + src: "{{ output_dir }}/test.json" + operations: + - op: copy + from: "/foo/uno" + path: "/foo/dos" + register: copy_result1 + +- name: Assert that the copy duplicated the value + assert: + that: + - copy_result1 is changed + - "lookup('file', output_dir + '/test.json') | from_json | json_query('foo.dos') == 'one'" + +- name: Copy the 'uno' member into a new member again + json_patch: + src: "{{ output_dir }}/test.json" + operations: + - op: copy + from: "/foo/uno" + path: "/foo/dos" + register: copy_result2 + +- name: Assert that the copy did not change this time + assert: + that: + - copy_result2 is not changed + - "lookup('file', output_dir + '/test.json') | from_json | json_query('foo.dos') == 'one'" + +- name: Test the value of 'dos' is 'two' + json_patch: + src: "{{ output_dir }}/test.json" + operations: + - op: test + path: "/foo/dos" + value: "two" + register: test_result1 + +- name: Assert that the test did not pass + assert: + that: + - test_result1 is not changed + - not test_result1.tested + - "lookup('file', output_dir + '/test.json') | from_json | json_query('foo.dos') == 'one'" + +- name: Add a 'qux' member into another json file + json_patch: + src: "{{ output_dir }}/test.json" + dest: "{{ output_dir }}/test2.json" + operations: + - op: add + path: "/foo/qux" + value: "quux" + register: dest_result1 + +- name: Assert that the member was added to a new file and only that file + assert: + that: + - dest_result1 is changed + - "lookup('file', output_dir + '/test2.json') | from_json | json_query('foo.qux') == 'quux'" + - "not lookup('file', output_dir + '/test.json') | from_json | json_query('foo.qux')" diff --git a/tests/unit/plugins/modules/files/test_json_patch.py b/tests/unit/plugins/modules/files/test_json_patch.py new file mode 100644 index 00000000000..27c930f6531 --- /dev/null +++ b/tests/unit/plugins/modules/files/test_json_patch.py @@ -0,0 +1,318 @@ +from __future__ import (absolute_import, division, print_function) +import json +import pytest + +from ansible_collections.community.general.plugins.modules.files.json_patch import JSONPatcher, PathError + +__metaclass__ = type + +sample_json = json.dumps( + [ + { + "foo": { + "one": 1, + "two": 2, + "three": 3 + }, + "enabled": True + }, { + "bar": { + "one": 1, + "two": 2, + "three": 3 + }, + "enabled": False + }, { + "baz": + [ + { + "foo": "apples", + "bar": "oranges" + }, { + "foo": "grapes", + "bar": "oranges" + }, { + "foo": "bananas", + "bar": "potatoes" + } + ], + "enabled": False + } + ] +) + + +# OPERATION: ADD +def test_op_add_foo_four(): + """Should add a `four` member to the first object.""" + patches = [{"op": "add", "path": "/0/foo/four", "value": 4}] + jp = JSONPatcher(sample_json, *patches) + changed, tested = jp.patch() + assert changed is True + assert tested is None + assert jp.obj[0]['foo']['four'] == 4 + + +def test_op_add_object_list(): + """Should add a new first object to the 'baz' list.""" + patches = [{"op": "add", "path": "/2/baz/0", "value": {"foo": "kiwis", "bar": "strawberries"}}] + jp = JSONPatcher(sample_json, *patches) + changed, tested = jp.patch() + assert changed is True + assert tested is None + assert jp.obj[2]['baz'][0] == patches[0]['value'] + + +def test_op_add_object_end_of_list(): + """should add a new last object to the 'baz' list.""" + patches = [{"op": "add", "path": "/2/baz/-", "value": {"foo": "raspberries", "bar": "blueberries"}}] + jp = JSONPatcher(sample_json, *patches) + changed, tested = jp.patch() + assert changed is True + assert tested is None + assert jp.obj[2]['baz'][-1] == patches[0]['value'] + + +def test_op_add_replace_existing_value(): + """Should find an existing property and replace its value.""" + patches = [{"op": "add", "path": "/1/bar/three", "value": 10}] + jp = JSONPatcher(sample_json, *patches) + changed, tested = jp.patch() + assert changed is True + assert tested is None + assert jp.obj[1]['bar']['three'] == 10 + + +def test_op_add_ignore_existing_value(): + """Should ignore an existing property with the same value.""" + patches = [{"op": "add", "path": "/1/bar/one", "value": 1}] + jp = JSONPatcher(sample_json, *patches) + changed, tested = jp.patch() + assert changed is False + assert tested is None + assert jp.obj[1]['bar']['one'] == 1 + + +# OPERATION: REMOVE +def test_op_remove_foo_three(): + """Should remove the 'three' member from the first object.""" + patches = [{"op": "remove", "path": "/0/foo/three"}] + jp = JSONPatcher(sample_json, *patches) + changed, tested = jp.patch() + assert changed is True + assert tested is None + assert 'three' not in jp.obj[0]['foo'] + + +def test_op_remove_baz_list_member(): + """Should remove the last fruit item from the 'baz' list.""" + patches = [{"op": "remove", "path": "/2/baz/2"}] + jp = JSONPatcher(sample_json, *patches) + changed, tested = jp.patch() + assert changed is True + assert tested is None + for obj in jp.obj[2]['baz']: + assert obj['foo'] != 'bananas' + assert obj['bar'] != 'potatoes' + + +def test_op_remove_fail_on_nonexistent_path(): + """Should raise an exception if referencing a non-existent tree to remove.""" + patches = [{"op": "remove", "path": "/0/qux/one"}] + jp = JSONPatcher(sample_json, *patches) + with pytest.raises(PathError): + jp.patch() + + +def test_op_remove_unchanged_on_nonexistent_member(): + """Should not raise an exception if referencing a non-existent leaf to remove.""" + patches = [{"op": "remove", "path": "/0/foo/four"}] + jp = JSONPatcher(sample_json, *patches) + changed, tested = jp.patch() + assert changed is False + assert tested is None + + +# OPERATION: REPLACE +def test_op_replace_foo_three(): + """Should replace the value for the 'three' member in 'foo'.""" + patches = [{"op": "replace", "path": "/0/foo/three", "value": "booyah"}] + jp = JSONPatcher(sample_json, *patches) + changed, tested = jp.patch() + assert changed is True + assert tested is None + assert jp.obj[0]['foo']['three'] == 'booyah' + + +def test_op_replace_fail_on_nonexistent_path_or_member(): + """Should raise an exception if any part of the referenced path does not exist (RFC 6902).""" + patches = [{"op": "replace", "path": "/0/foo/four", "value": 4}] + jp = JSONPatcher(sample_json, *patches) + with pytest.raises(PathError): + jp.patch() + + +# OPERATION: MOVE +def test_op_move_foo_three_bar_four(): + """Should move the 'three' property from 'foo' to 'bar'.""" + patches = [{"op": "move", "from": "/0/foo/three", "path": "/1/bar/four"}] + jp = JSONPatcher(sample_json, *patches) + changed, tested = jp.patch() + assert changed is True + assert tested is None + assert jp.obj[0]['foo'].get('three', 'DUMMY VALUE') == 'DUMMY VALUE' + assert jp.obj[1]['bar']['four'] == 3 + + +def test_op_move_baz_list_foo(): + """Should move the 'baz' list of fruits to 'foo' object.""" + patches = [{"op": "move", "from": "/2/baz", "path": "/0/foo/fruits"}] + jp = JSONPatcher(sample_json, *patches) + changed, tested = jp.patch() + assert changed is True + assert tested is None + assert jp.obj[2].get('baz', 'DUMMY VALUE') == 'DUMMY VALUE' + assert len(jp.obj[0]['foo']['fruits']) == 3 + + +def test_op_move_unchanged_on_nonexistent(): + """Should not raise an exception if moving a non-existent object member.""" + patches = [{"op": "move", "from": "/0/foo/four", "path": "/1/bar/four"}] + jp = JSONPatcher(sample_json, *patches) + changed, tested = jp.patch() + assert changed is False + assert tested is None + + +def test_op_move_foo_object_end_of_list(): + """Should move the 'three' member in 'foo' to the end of the 'baz' list.""" + patches = [{"op": "move", "from": "/0/foo/three", "path": "/2/baz/-"}] + jp = JSONPatcher(sample_json, *patches) + changed, tested = jp.patch() + assert changed is True + assert tested is None + assert jp.obj[0]['foo'].get('three', 'DUMMY VALUE') == 'DUMMY VALUE' + assert jp.obj[2]['baz'][-1] == 3 + + +# OPERATION: COPY +def test_op_copy_foo_three_bar_four(): + """Should copy the 'three' member in 'foo' to the 'bar' object.""" + patches = [{"op": "copy", "from": "/0/foo/three", "path": "/1/bar/four"}] + jp = JSONPatcher(sample_json, *patches) + changed, tested = jp.patch() + assert changed is True + assert tested is None + assert jp.obj[0]['foo']['three'] == 3 + assert jp.obj[1]['bar']['four'] == 3 + + +def test_op_copy_baz_list_bar(): + """Should copy the 'baz' list of fruits to 'foo' object.""" + patches = [{"op": "copy", "from": "/2/baz", "path": "/0/foo/fruits"}] + jp = JSONPatcher(sample_json, *patches) + changed, tested = jp.patch() + assert changed is True + assert tested is None + assert len(jp.obj[2]['baz']) == 3 + assert len(jp.obj[0]['foo']['fruits']) == 3 + + +def test_op_copy_fail_on_nonexistent_member(): + """Should raise an exception when copying a non-existent member.""" + patches = [{"op": "copy", "from": "/1/bar/four", "path": "/0/foo/fruits"}] + jp = JSONPatcher(sample_json, *patches) + with pytest.raises(PathError): + jp.patch() + + +# OPERATION: TEST +def test_op_test_string_equal(): + """Should return True that two strings are equal.""" + patches = [{"op": "test", "path": "/2/baz/0/foo", "value": "apples"}] + jp = JSONPatcher(sample_json, *patches) + changed, tested = jp.patch() + assert changed is None + assert tested is True + + +def test_op_test_string_unequal(): + """Should return False that two strings are unequal.""" + patches = [{"op": "test", "path": "/2/baz/0/foo", "value": "bananas"}] + jp = JSONPatcher(sample_json, *patches) + changed, tested = jp.patch() + assert changed is None + assert tested is False + + +def test_op_test_number_equal(): + """Should return True that two numbers are equal.""" + patches = [{"op": "test", "path": "/0/foo/one", "value": 1}] + jp = JSONPatcher(sample_json, *patches) + changed, tested = jp.patch() + assert changed is None + assert tested is True + + +def test_op_test_number_unequal(): + """Should return False that two numbers are unequal.""" + patches = [{"op": "test", "path": "/0/foo/one", "value": "bananas"}] + jp = JSONPatcher(sample_json, *patches) + changed, tested = jp.patch() + assert changed is None + assert tested is False + + +def test_op_test_list_equal(): + """Should return True that two lists are equal.""" + patches = [ + { + "op": "add", + "path": "/0/foo/compare", + "value": [1, 2, 3] + }, { + "op": "test", + "path": "/0/foo/compare", + "value": [1, 2, 3] + } + ] + jp = JSONPatcher(sample_json, *patches) + changed, tested = jp.patch() + assert changed is True + assert tested is True + + +def test_op_test_wildcard(): + """Should find an element in the 'baz' list with the matching value.""" + patches = [{"op": "test", "path": "/2/baz/*/foo", "value": "grapes"}] + jp = JSONPatcher(sample_json, *patches) + changed, tested = jp.patch() + assert changed is None + assert tested is True + + +def test_op_test_wildcard_not_found(): + """Should return False on not finding an element with the given value.""" + patches = [{"op": "test", "path": "/2/baz/*/bar", "value": "rocks"}] + jp = JSONPatcher(sample_json, *patches) + changed, tested = jp.patch() + assert changed is None + assert tested is False + + +def test_op_test_multiple_tests(): + """Should return False if at least one test returns False.""" + patches = [{"op": "test", "path": "/0/foo/one", "value": 2}, {"op": "test", "path": "/1/bar/one", "value": 1}] + jp = JSONPatcher(sample_json, *patches) + changed, tested = jp.patch() + assert changed is None + assert tested is False + + +def test_op_test_nonexistent_member(): + """Should return False even if path does not exist.""" + patches = [{"op": "test", "path": "/10/20/foo", "value": "bar"}] + jp = JSONPatcher(sample_json, *patches) + changed, tested = jp.patch() + assert changed is None + assert tested is False