Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

update community.routeros.api query functionality #63

Merged
merged 28 commits into from
May 23, 2022

Conversation

NikolayDachev
Copy link
Collaborator

@NikolayDachev NikolayDachev commented Jan 12, 2022

SUMMARY

Right now community.routeros.api query - WHERE is limited to single criteria.
We should update it to use all librouteos functionality. Also this will help with routeros/ansible idempotent functionality.
For understandable reasons RouterOS allow to add multiple configs with same value (for some configurations), in result when we add something with API we are not able to do correct match if the required config already exist or not which will make duplicated config entries!.

For example 'add' task can be executed only if 'query' do not return .id for existing config which match multiple criteria. etc.

ISSUE TYPE
  • Feature Pull Request
COMPONENT NAME

community.routeros.api

ADDITIONAL INFORMATION

libouteros: https://github.com/luqasz/librouteros
librouteros docs: https://librouteros.readthedocs.io/en/latest/query.html#

What we missing is AND, OR for query-where

Test playbook

- name: ros query example
  hosts: ros_testing
  gather_facts: no
  connection: local

  tasks:
  - name: query in {{ ros_query_path }} for {{ ros_query_for }}
    vars:
      ros_query_path: "{{ ros_testing_query_path }}"
      ros_query_for: "{{ ros_testing_query }}"
    community.routeros.api:
      hostname: "{{ ros_hostname }}"
      ssl: "{{ ros_ssl }}"
      password: "{{ ros_password }}"
      username: "{{ ros_username }}"
      path: "{{ ros_query_path }}"
      query: "{{ ros_query_for }}"
    register: rosquery
    delegate_to: localhost

RouterOS (CHR 7.2rc1) config

[admin@eve-mk1] > /ip/address/export 
# jan/12/2022 12:15:29 by RouterOS 7.2rc1
# software id = 
#
/ip address
add address=192.168.58.254/24 interface=ether2 network=192.168.58.0
add address=192.168.58.1/24 interface=ether2 network=192.168.58.0
add address=192.168.58.2/24 interface=ether2 network=192.168.58.0

Testing before change:

  1. Whiout WHERE - get all
$ ansible-playbook playbooks/ros/testing/ros_query_devel.yml --ask-vault-pass -v -e ros_testing_query="'.id network address'"
Using /opt/gitea/ansible/ansible.cfg as config file
Vault password: 
[WARNING]: An error occurred while calling ansible.utils.display.initialize_locale (unsupported locale setting). This may result in incorrectly calculated text widths that can cause Display to print
incorrect line lengths

PLAY [ros query example] ***********************************************************************************************************************************************************************************

TASK [query in ip address for .id network address] *********************************************************************************************************************************************************
ok: [10.20.36.253 -> localhost] => {"changed": false, "msg": [{".id": "*1", "address": "10.20.36.253/24", "network": "10.20.36.0"}, {".id": "*3", "address": "192.168.58.254/24", "network": "192.168.58.0"}, {".id": "*4", "address": "192.168.58.1/24", "network": "192.168.58.0"}, {".id": "*5", "address": "192.168.58.2/24", "network": "192.168.58.0"}]}

PLAY RECAP *************************************************************************************************************************************************************************************************
10.20.36.253               : ok=1    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
  1. WHERE with single criteria
$ ansible-playbook playbooks/ros/testing/ros_query_devel.yml --ask-vault-pass -v -e ros_testing_query="'.id network address WHERE network == 192.168.58.0'"
Using /opt/gitea/ansible/ansible.cfg as config file
Vault password: 
[WARNING]: An error occurred while calling ansible.utils.display.initialize_locale (unsupported locale setting). This may result in incorrectly calculated text widths that can cause Display to print
incorrect line lengths

PLAY [ros query example] ***********************************************************************************************************************************************************************************

TASK [query in ip address for .id network address WHERE network == 192.168.58.0] ***************************************************************************************************************************
ok: [10.20.36.253 -> localhost] => {"changed": false, "msg": [{".id": "*3", "address": "192.168.58.254/24", "network": "192.168.58.0"}, {".id": "*4", "address": "192.168.58.1/24", "network": "192.168.58.0"}, {".id": "*5", "address": "192.168.58.2/24", "network": "192.168.58.0"}]}

PLAY RECAP *************************************************************************************************************************************************************************************************
10.20.36.253               : ok=1    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
  1. WHERE with multiple criterias (this we want to change)
$ ansible-playbook playbooks/ros/testing/ros_query_devel.yml --ask-vault-pass -v -e ros_testing_query="'.id network address WHERE network == 192.168.58.0, address != 192.168.58.254/24'"
Using /opt/gitea/ansible/ansible.cfg as config file
Vault password: 
[WARNING]: An error occurred while calling ansible.utils.display.initialize_locale (unsupported locale setting). This may result in incorrectly calculated text widths that can cause Display to print
incorrect line lengths

PLAY [ros query example] ***********************************************************************************************************************************************************************************

TASK [query in ip address for .id network address WHERE network == 192.168.58.0, address != 192.168.58.254/24] *********************************************************************************************
An exception occurred during task execution. To see the full traceback, use -vvv. The error was: AttributeError: 'ROS_api_module' object has no attribute 'result'
fatal: [10.20.36.253 -> localhost]: FAILED! => {"changed": false, "module_stderr": "Traceback (most recent call last):\n  File \"/tmp/ansible_community.routeros.api_payload_wgp1_1x6/ansible_community.routeros.api_payload.zip/ansible_collections/community/routeros/plugins/modules/api.py\", line 354, in __init__\n  File \"/tmp/ansible_community.routeros.api_payload_wgp1_1x6/ansible_community.routeros.api_payload.zip/ansible_collections/community/routeros/plugins/module_utils/quoting.py\", line 110, in parse_argument_value\nansible_collections.community.routeros.plugins.module_utils.quoting.ParseError: Unexpected data at end of value\n\nDuring handling of the above exception, another exception occurred:\n\nTraceback (most recent call last):\n  File \"/home/dako/.ansible/tmp/ansible-tmp-1641990267.2573767-3558077-34968152862448/AnsiballZ_api.py\", line 100, in <module>\n    _ansiballz_main()\n  File \"/home/dako/.ansible/tmp/ansible-tmp-1641990267.2573767-3558077-34968152862448/AnsiballZ_api.py\", line 92, in _ansiballz_main\n    invoke_module(zipped_mod, temp_path, ANSIBALLZ_PARAMS)\n  File \"/home/dako/.ansible/tmp/ansible-tmp-1641990267.2573767-3558077-34968152862448/AnsiballZ_api.py\", line 40, in invoke_module\n    runpy.run_module(mod_name='ansible_collections.community.routeros.plugins.modules.api', init_globals=dict(_module_fqn='ansible_collections.community.routeros.plugins.modules.api', _modlib_path=modlib_path),\n  File \"/usr/lib/python3.9/runpy.py\", line 210, in run_module\n    return _run_module_code(code, init_globals, run_name, mod_spec)\n  File \"/usr/lib/python3.9/runpy.py\", line 97, in _run_module_code\n    _run_code(code, mod_globals, init_globals,\n  File \"/usr/lib/python3.9/runpy.py\", line 87, in _run_code\n    exec(code, run_globals)\n  File \"/tmp/ansible_community.routeros.api_payload_wgp1_1x6/ansible_community.routeros.api_payload.zip/ansible_collections/community/routeros/plugins/modules/api.py\", line 550, in <module>\n  File \"/tmp/ansible_community.routeros.api_payload_wgp1_1x6/ansible_community.routeros.api_payload.zip/ansible_collections/community/routeros/plugins/modules/api.py\", line 546, in main\n  File \"/tmp/ansible_community.routeros.api_payload_wgp1_1x6/ansible_community.routeros.api_payload.zip/ansible_collections/community/routeros/plugins/modules/api.py\", line 357, in __init__\n  File \"/tmp/ansible_community.routeros.api_payload_wgp1_1x6/ansible_community.routeros.api_payload.zip/ansible_collections/community/routeros/plugins/modules/api.py\", line 497, in errors\nAttributeError: 'ROS_api_module' object has no attribute 'result'\n", "module_stdout": "", "msg": "MODULE FAILURE\nSee stdout/stderr for the exact error", "rc": 1}

PLAY RECAP *************************************************************************************************************************************************************************************************
10.20.36.253               : ok=0    changed=0    unreachable=0    failed=1    skipped=0    rescued=0    ignored=0  

Testing with current change (not completed read more!):

  1. Whiout WHERE - get all
$ ansible-playbook playbooks/ros/testing/ros_query_devel.yml --ask-vault-pass -v -e ros_testing_query="'.id network address'"
Using /opt/gitea/ansible/ansible.cfg as config file
Vault password: 
[WARNING]: An error occurred while calling ansible.utils.display.initialize_locale (unsupported locale setting). This may result in incorrectly calculated text widths that can cause Display to print
incorrect line lengths

PLAY [ros query example] ***********************************************************************************************************************************************************************************

TASK [query in ip address for .id network address] *********************************************************************************************************************************************************
ok: [10.20.36.253 -> localhost] => {"changed": false, "msg": [{".id": "*1", "address": "10.20.36.253/24", "network": "10.20.36.0"}, {".id": "*3", "address": "192.168.58.254/24", "network": "192.168.58.0"}, {".id": "*4", "address": "192.168.58.1/24", "network": "192.168.58.0"}, {".id": "*5", "address": "192.168.58.2/24", "network": "192.168.58.0"}]}

PLAY RECAP *************************************************************************************************************************************************************************************************
10.20.36.253               : ok=1    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0  
  1. WHERE with single criteria
$ ansible-playbook playbooks/ros/testing/ros_query_devel.yml --ask-vault-pass -v -e ros_testing_query="'.id network address WHERE network == 192.168.58.0'"
Using /opt/gitea/ansible/ansible.cfg as config file
Vault password: 
[WARNING]: An error occurred while calling ansible.utils.display.initialize_locale (unsupported locale setting). This may result in incorrectly calculated text widths that can cause Display to print
incorrect line lengths

PLAY [ros query example] ***********************************************************************************************************************************************************************************

TASK [query in ip address for .id network address WHERE network == 192.168.58.0] ***************************************************************************************************************************
ok: [10.20.36.253 -> localhost] => {"changed": false, "msg": [{".id": "*3", "address": "192.168.58.254/24", "network": "192.168.58.0"}, {".id": "*4", "address": "192.168.58.1/24", "network": "192.168.58.0"}, {".id": "*5", "address": "192.168.58.2/24", "network": "192.168.58.0"}]}

PLAY RECAP *************************************************************************************************************************************************************************************************
10.20.36.253               : ok=1    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
  1. WHERE with multiple criterias (fixed for libouteros WHERE-AND only - read more)
$ ansible-playbook playbooks/ros/testing/ros_query_devel.yml --ask-vault-pass -v -e ros_testing_query="'.id network address WHERE network == 192.168.58.0, address != 192.168.58.254/24'"
Using /opt/gitea/ansible/ansible.cfg as config file
Vault password: 
[WARNING]: An error occurred while calling ansible.utils.display.initialize_locale (unsupported locale setting). This may result in incorrectly calculated text widths that can cause Display to print
incorrect line lengths

PLAY [ros query example] ***********************************************************************************************************************************************************************************

TASK [query in ip address for .id network address WHERE network == 192.168.58.0, address != 192.168.58.254/24] *********************************************************************************************
ok: [10.20.36.253 -> localhost] => {"changed": false, "msg": [{".id": "*4", "address": "192.168.58.1/24", "network": "192.168.58.0"}, {".id": "*5", "address": "192.168.58.2/24", "network": "192.168.58.0"}]}

PLAY RECAP *************************************************************************************************************************************************************************************************
10.20.36.253               : ok=1    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   

READ MORE:

I try to not breake additional functionality around api_query() and def init() (if self.query:), however in order to achive this I move self.wehre to self.where_list.
As general idea is to take ansible argument, pars evrething after 'WHERE' and make it as proper list format. After that those criteria are processed as list of lists (check api_query())

What I test so far look ok. This code add only AND functionality (aka - a == b and c == d and y == z .. etc.), however I want to add librouteros OR as well, before I add OR I want to have a review to the current changes since OR code will be added around current change.

note: several small fixes are intruduced as well more related to return messages via ansible
note: '.id network address WHERE network == 192.168.58.0, address != 192.168.58.254/24' -> ',' is used to split criteria
note: 'ip address' is used only for simplicity ! (routeros will not allow ducplicated ip addresses). Example for allowed duplicated configs can be firewall rules, ipsec policy, capsman provisioning .. etc.

Please check the code changes, I will appreciate any idea / improvement / bug fix in the current code .. etc.
If something is not clear/well understandable in the code, let me know.

TODO:

  • wait for init review
  • add librouteros OR
  • update api documentation
  • unitest

@codecov
Copy link

codecov bot commented Jan 12, 2022

Codecov Report

Merging #63 (fe2a114) into main (5f912da) will increase coverage by 0.26%.
The diff coverage is 79.72%.

@@            Coverage Diff             @@
##             main      #63      +/-   ##
==========================================
+ Coverage   82.31%   82.58%   +0.26%     
==========================================
  Files          21       21              
  Lines        1866     1981     +115     
  Branches      296      321      +25     
==========================================
+ Hits         1536     1636     +100     
- Misses        256      264       +8     
- Partials       74       81       +7     
Impacted Files Coverage Δ
tests/unit/plugins/modules/fake_api.py 91.89% <62.50%> (-3.70%) ⬇️
plugins/modules/api.py 77.98% <67.08%> (-0.07%) ⬇️
tests/unit/plugins/modules/test_api.py 100.00% <100.00%> (ø)

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 5f912da...fe2a114. Read the comment docs.

Copy link
Collaborator

@felixfontein felixfontein left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for your contribution! This should have a changelog fragment.

where = self.query[where_index + len(' WHERE '):]
# create a list of WHERE arguments
for wl in where.split(','):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, this is a bit complicated. A comma could show up in a value. For example if WHERE foo == "bar, baz", it would split this into two elements foo == "bar and baz, and this will result in an error.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agree probably ',' can be changed to 'AND' ?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It will have the same problem: WHERE foo == "bar AND baz" :) I'm afraid we have to properly parse this, instead of simply doing a split with simple string operations.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe it would be better to allow to pass the where clauses in as structural data, so we don't have to parse it? Something like:

  where:
    - what: foo
      op: '=='
      value: bar AND baz
    - what: something else
      op: '<'
      value: 4

Copy link
Collaborator Author

@NikolayDachev NikolayDachev Jan 17, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is not bad idea however we should structure it simple as possible
may be something like

query:
  items: 
    - .id
    - address
  where:
    address:
      eq: 
       - "1.1.1.1"
       - "2.2.2.2"
    network:
      not:
       - "255.255.255.0"
    Or:
      address:
        eq:
          - "3.3.3.3"
          - "4.4.4.4"

It will be more easy to be coded, however Or: "is in" where() and not sure how will be more proper to be as yml input (dict structure)
To be key of "where:" or key of "query:"

https://librouteros.readthedocs.io/en/latest/query.html#advanced-usage

also "In" must be considered

eq: ==
not: !=
less: <
more: >
In: vlaue1 value2 in key

or whatever are the proper words for < and > :)

Also I'm not sure if case with same key multiple values is valid query at all :|
If I not mistake librouteros.query is https://wiki.mikrotik.com/wiki/Manual:API#Queries

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

query:
  items:
    - .id
    - network
    - address
  where:
    - network:
       is: "192.168.58.0"
    - address:
       not: "192.168.58.254/24"
  or:
    - address:
      not: "192.168.58.1/24"

? I think looks good (not sure if multiple where: and or: are good combination but will test it ) will fix the code

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I change the code with the new structure (not completed), however:
If we have same conditions keys with different values we should us librouteros.where Or / In
For example

        items:
          - .id
          - network
          - address
        where:
          - network:
             is: "192.168.58.0"
          - address:
             is: "192.168.58.254/24"
          - address:
             is: "192.168.58.1/24"

This will not return result since (a == 1 and a == 2 ) 'which a ?' in this case we should use or/in (a == 1 or a == 2 ) - first match win

For reference

        items:
          - .id
          - network
          - address
        where:
          - network:
             is: "192.168.58.0"
          - address:
             not: "192.168.58.254/24"
          - address:
             not: "192.168.58.1/24"

This will return all other ip addresses which are NOT , for example (1,2,3,4 in a) (a != 1, a !=3) will return (2,4).
Also if (a == 1, a != 3) will return only (1) etc.

What I'm not sure (specially for OR).
If we have (1,2,3,4) (a > 2 or a > 3), this must return (3,4) if OR is valid not only for '=='
This is important since we can change yml to

       items:
         - .id
         - network
         - address
       where:
         - network:
            is: "192.168.58.0"
         - address:
            is: 
              - "192.168.58.254/24"
              - "192.168.58.1/24"

if type('is') is list:
... use or in query

However if we should mix for example '<, >, ==, !=' the yml must be something like (ignore ip values this is a structure example)

        items:
          - .id
          - network
          - address
        where:
          - network:
             is: "192.168.58.0"
          - address:
             or: 
               more: "192.168.58.254/24"
               less: "192.168.58.1/24"
               is: "192.168.58.2/24"

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO the new structure should be something that can be validated with the argument spec. (And I would really avoid putting data into dictionary keys if possible, so instead of <operation>: <value> it should be something like operation: <operation> / value: <value>. That makes it easier to process and easier to automatically generate IMO.)

How about limiting it to a or/and structure, so you can have a list of "or" conditions, each containing a list of "and" conditions?

@@ -303,7 +303,7 @@ def __init__(self):
remove=dict(type='str'),
update=dict(type='str'),
cmd=dict(type='str'),
query=dict(type='str'),
query=dict(type='dict'),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a backwards-compatibility breaking change. The old form needs to continue to work as well.

How about adding a new parameter for this, making the two mutually exclusive, and have some code that transforms the old query content to the new parameter?

Copy link
Collaborator Author

@NikolayDachev NikolayDachev Jan 19, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think how to solve the same, something like "extended_query" ? This will need some additional changes, will try to add the new code without change the existing one.
I'm still working on the code to add Or and In in the query, will push it when is ready for final review
If everything look ok will fix the change log, docs, unites etc.

Please give me some time to add the final code for review and we can continue the discussion

Copy link
Collaborator Author

@NikolayDachev NikolayDachev Jan 19, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As yml structure, code will find 'or:' and build the query , will be same for where(key.In)
Also I'm adding additional checks if the whole dict is structure correctly

        items:
          - .id
          - network
          - address
        where:
          - network:
             is: "192.168.58.0"
          - address:
             not: "192.168.58.254/24"
          - address:
             or: 
               - is: "192.168.58.1/24"
               - not: "192.168.58.2/24"

Bur probably will try to change it, but not sure how good look :|

'is': '=='
'not': '!='
'more': '>'
'less': '<'
'or': 'or'
'in': 'in'

        items:
          - .id
          - network
          - address
        where:
          - network:
             '==': "192.168.58.0"
          - address:
             '==': "192.168.58.254/24"
          - address:
             or: 
               - '==': "192.168.58.1/24"
               - '!=': "192.168.58.2/24"

Copy link
Collaborator Author

@NikolayDachev NikolayDachev Jan 19, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

actually will do both :)

          - network:
             '==': "192.168.58.0"
          - network:
             is: "192.168.58.0"

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

push new code which have query and extended_query (code is still in progress) just for info!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

build_api_extended_query() is very bulky ..but work fine, I will try to fix it when I finish the main functionalities with 'Or/In' queries

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extended_query code is ready for review !

extended_query structure is:

extended_query:
    items:
      - ros_attribute1
      - ros_attribute2
      - ros_attribute3
    where:
      - ros_attribute1:
          operator: "value"
      - ros_attribute2:
          or:
           - operator1: "value1"
           - operator2: "value2"
      - ros_attribute3:
          in:
            - "value1"
            - "value2"

items: type(list) order is not important

'extended_query':can work only with 'items' (same as normal 'query') -> 'where' is not mandatory

where: type(list) order is important for routeros api query result

Any 'where' ros_attribute must be in 'items' !

where sub items:
ros_attribute: type(dict) single operator(key) / vlaue

operator:
'==' or 'is'
'!=' or 'not'
'>' or 'more'
'<' or 'less'

'or': type(list) (value is same as ros_attribute except 'or', 'in') order is important - depend on all other query attribute, first match win
'in': type(list) (value is a list) order is not important if any of ros_attribute is in 'in list': true

Example (ignore example values ):

extended_query:
  items:
    - network
    - address
  where:
    - network:
       is: "192.168.58.0"
    - address:
       less: "192.168.58.1/24"
    - address:
       or: 
         - more: "192.168.58.1/24"
         - '==': "192.168.58.2/24"
    - address:
       in:
         - "10.20.36.253/24"
         - "192.168.58.2/24"

where = self.query[where_index + len(' WHERE '):]
# create a list of WHERE arguments
for wl in where.split(','):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO the new structure should be something that can be validated with the argument spec. (And I would really avoid putting data into dictionary keys if possible, so instead of <operation>: <value> it should be something like operation: <operation> / value: <value>. That makes it easier to process and easier to automatically generate IMO.)

How about limiting it to a or/and structure, so you can have a list of "or" conditions, each containing a list of "and" conditions?

NikolayDachev pushed a commit to NikolayDachev/community.routeros that referenced this pull request Jan 21, 2022
@NikolayDachev
Copy link
Collaborator Author

extended_query code is ready for review !

@felixfontein
For extended_query yml structure I hope is acceptable, I think cover your suggestion except operators. I know the code is with bigger suffice for mistakes however with this structure we "save input efforts"

I prefer to have acceptable review for the code and extended_query yml structure, after that I will start to work on docs and unit test

For 'where:in'
https://github.com/luqasz/librouteros/blob/master/CHANGELOG.rst
'In' operator is new for librouteros (v3.1.0) .
I know we should mention that but I'm not sure what will be the best approach

  • docs info for librouteros version and 'where:in' (add a check for librouteros version and 'where:in' )
  • force api.py with librouteros >= v3.1.0

TODO:

  • docs
  • uniti test

extended_query structure is:

extended_query:
    items:
      - ros_attribute1
      - ros_attribute2
      - ros_attribute3
    where:
      - ros_attribute1:
          operator: "value"
      - ros_attribute2:
          or:
           - operator1: "value1"
           - operator2: "value2"
      - ros_attribute3:
          in:
            - "value1"
            - "value2"

items: type(list) order is not important

'extended_query':can work only with 'items' (same as normal 'query') -> 'where' is not mandatory

where: type(list) order is important for routeros api query result

Any 'where' ros_attribute must be in 'items' !

where sub items:
ros_attribute: type(dict) single operator(key) / vlaue

operator:
'==' or 'is'
'!=' or 'not'
'>' or 'more'
'<' or 'less'

'or': type(list) (value is same as ros_attribute except 'or', 'in') order is important - depend on all other query attribute, first match win
'in': type(list) (value is a list) order is not important if any of ros_attribute is in 'in list': true

Example (ignore example values ):

extended_query:
  items:
    - network
    - address
  where:
    - network:
       is: "192.168.58.0"
    - address:
       less: "192.168.58.1/24"
    - address:
       or: 
         - more: "192.168.58.1/24"
         - '==': "192.168.58.2/24"
    - address:
       in:
         - "10.20.36.253/24"
         - "192.168.58.2/24"

@felixfontein
Copy link
Collaborator

How about changing:

extended_query:
    items:
      - ros_attribute1
      - ros_attribute2
      - ros_attribute3
    where:
      - ros_attribute1:
          operator: "value"
      - ros_attribute2:
          or:
           - operator1: "value1"
           - operator2: "value2"
      - ros_attribute3:
          in:
            - "value1"
            - "value2"

to something like:

extended_query:
    items:
      - ros_attribute1
      - ros_attribute2
      - ros_attribute3
    where:
      - attribute: ros_attribute1:
        operator: "value"
      - attribute: ros_attribute2:
        or:
           - operator: operator1
             value: "value1"
           - operator: operator2
             value: "value2"
      - attribute: ros_attribute3:
        in:
          - "value1"
          - "value2"

That way you can template everything (you cannot template dictionary keys in Ansible!) and Ansible can validate the structure for you.

One thing about or: I guess all extra comparisons in here are for the same attribute? What if you want to have an or condition with a different attribute? Adding an optional (or required?) attribute: to them makes this more flexible.

This structure (you have a global and, and every of the and terms can be an or term) basically means you allow the user to specify a conjunctive normal form of the expression.

I've also been thinking about how to do this in a more flexible way. I haven't really found a good solution yet, at least none that can get completely validated by ansible :-) How about this:

A generic expression dictionary can take one of the following forms:

  1. Basic attribute-operator-value:

    attribute: ...
    operator: ...
    value: ...
  2. and with a list of expression dictionaries that will be combined to an and condition:

    and:
      - (another expression dictionary)
      - (another expression dictionary)
      - ...
  3. or with a list of expression dictionaries that will be combined to an or condition:

    or:
      - (another expression dictionary)
      - (another expression dictionary)
      - ...
  4. not with a expression dictionary that will be combined to a not condition (not sure if RouterOS actually supports this):

    not:
      (another expression dictionary)

Then you can freely nest every possible condition together without any restrictions.

What do you think?

@NikolayDachev
Copy link
Collaborator Author

NikolayDachev commented Jan 22, 2022

Yep I agree we allow user to make conjunctive input, from what I test routeros return 'no results' in such cases, my point is .. should we limit it in some way or we should leave it to user decision ?!
Not sure for that as well, for some cases combination for multiple AND , OR also IN probably will make sense for routeros .. (or may be not).

I'm not sure how yml structure can be done properly as " completely validated by ansible" since is "unknown" input however if we can do that .. will be the best approach for sure, we are not in rush, so we can try to find a solution !

I do not insist for the structure which I suggest in any way!
Just I feel "operator" key somehow awkward, value is something which always will match with "==, !=, >, <" or str values "is, not, more, less" aka, input of matematic operatoros as value of dedicated key.

{key:value, key:"==", key:value}

(actually 'is' can be reanmed to 'eq', no idea why I type it "is' :) )

What we know for sure

  1. items - tye(list) actually must be renamed to "attributes"
  2. any "attribute" under "wehre" must exist in "attributes"
  3. IN - is actually (single attribute)
if  'attribute1' in (whatever1, whatever2, whatever3 ..)
  1. AND - is default function of "where" .. aka (a = 1, c = 2, d = 3)
  2. everething under "where" - must be procesed one by one aka must be a type(list) (same for "wehre:or")
  3. everething under "or" must have the same structure as "where"!

OR - I belive I make a mistake with it!

Right now is "single attribute" Or(attribute1 = 3, attribute1 > 4, et. )
I belive (must double chekc) is Or(attribute1 = 8, attribute2 > 4, atribue3 < 6) so must be able to contain all "attributes" not only single with diffrent checks

Something like (please focus on structure example not on values also not in spefici order of "wehre" items :) ):

attributes:
 - .id
 - address
 - network
where:
 - attribute: ".id"
   is: "*234"
-  attribute: "netowrk"
   is: "1.1.1.0/24"
- or:
    -: "address"
      more: "1.1.1.34/24"
    - atribue: "address"
      less: "1.1.1.123/24"
   -  attribute: ".id"
      eq: "*12"
- attribute:"address"
   in:
     - "1.1.1.67/24"
     - "1.1.1.56/24"

Aka structure in this example is tricky !
My point is

where = [{'attribute':'attribute1', 'is':'whatever'},
             {'attribute':'attribute2', 'is':'whatever'},,
             {'or':[{'attribute':'attribute1', 'more':'whatever'},
                     {'attribute':'attribute2', 'not':'whatever'}]},
              {'attribute':'attribute1', 'in':[1,2,3,4]}]

or if we use "attribute","operator", "value" key/vlaue (whcih will be the easy way to be processed no 2 opinions on that)

where = [{'attribute':'attribute1','operator':'is', 'value','whatever'},
             {'attribute':'attribute2', 'operator':'is', 'value':'whatever'},,
             {'or':[{'attribute':'attribute1',  'operator':'more', value:'whatever'},
                     {'attribute':'attribute2',  'operator':'not', 'value':'whatever'}]},
              {'attribute':'attribute1',  'operator':'in', 'value': [1,2,3,4]}]

whatever we decide, this 'or' somehow not fit !!!! From every list element we expect the same dic, but not for 'or'
I believe we should think on it as well, we can always do something like
[{}, {}, 'attribute':'or', value: [{},{},{}]]

or :D
[{}, {}, 'attribute':'or', 'operator':None ,value: [{},{},{}]]

but in this case 'or' will be treated as routeros attribute which I'm not sure if is a good idea

or :D

we can use 'attribute':'OR' (to mark it is as special attribute .. similar to what we do with api.py old 'query' and 'WHERE') .

btw: https://librouteros.readthedocs.io/en/3.2.0/query.html

note: sorry on some places I type 'eq' in other 'is' -- this is the same no idea why I continue to do that :)

note: the input structure for libouteros.query I think look ok, however this "UGLINESS" which I create with those nested loops for yml input must be rewrite .. how is depend on overall yml input

@NikolayDachev
Copy link
Collaborator Author

NikolayDachev commented Jan 24, 2022

I think this will be a good yml structure (also for code)

  1. Basic attribute-operator-value:
attribute: ...
is: ...
value: ...
  1. valid matematical operators 'is:'
'==' : 'eq'
'!=' : 'not'
'>'  : 'more'
'<'  : 'less'

3.example

extended_query:
    attributes:
      - "ros_attribute1"
      - "ros_attribute2"
      - "ros_attribute3"
    where:
      - attribute: "ros_attribute1"
        is: "=="
        value: "value"
      - attribute: "ros_attribute2"
        is: "in"
        value:
          - "value1"
          - "value2"
      - attribute: "ros_attribute3"
        is: "=="
        value: "value"
      - or:
          - attribute: "ros_attribute2"
            is: ">"
            value: "value"
          - attribute: "ros_attribute1"
            is: "!="
            value: "value"
      - attribute: "ros_attribute3"
        is: "!="
        value: "value"
      - attribute: "ros_attribute2"
        is: "in"
        value:
          - "value1"
          - "value2"
  1. With 'is' we make the condition more native for input, so
      - attribute: "ros_attribute1"
        is: "=="
        value: "value"

will be readed as:

ros_attribute is equivalent to value
  1. "extended_query:" can have
  • multiple conditions
  • all conditions are in order defined by the user
  • we can have multiple "AND - default", "OR", "IN" ..

We should not limit users since we cannot cover all condition combination, avoidin "conjunctive expression" must depend on user input (routeros will return "no result")

  1. Still I'm not sure how to fit "OR" under "where:"
    I think for something like:
      - attribute: "OR
          - attribute: "ros_attribute2"
            is: ">"
            value: "value"
          - ...

However this look like is not a valid YML syntax

Also

      - attribute_or:
          - attribute: "ros_attribute2"
            is: ">"
            value: "value"
          - ...

But from code prospective 'attribute_or' is same as 'or' - key name, so something like

      - or:
          - attribute: "ros_attribute2"
            is: ">"
            value: "value"
          - ...

Other suggestion can be:

      - attribute: "OR"
        is: "None" <- proper way to handle default "is" value as "None" when is under "OR" ?
        value:
          - attribute: "ros_attribute2"
            is: ">"
            value: "value"
          - ...
  1. With sugested yml structure we get predictable input which is the same under 'where'
    also under 'where:or:'

extended_query = [{'attribute':'*', 'is':'*', 'value':'*'}, 
                  {'attribute':'*', 'in':'*', 'value':[1,2,3]}, 
                  {'attribute':'*', 'or':{[{'attribute':'*', 'is':'*', 'value':'*'}, 
                                           {'attribute':'*', 'is':'*', 'value':'*'}
                                           {...}]},
                   {...}]

  1. Is there a good way ansible to do check for "extended_query" structure, keep in mind, "extended_query" is dynamic input ?

  2. If we agree as general for this xml structure,
    I'm open for any suggestions how to fit 'or'

@felixfontein
Copy link
Collaborator

Yep I agree we allow user to make conjunctive input, from what I test routeros return 'no results' in such cases, my point is .. should we limit it in some way or we should leave it to user decision ?!
Not sure for that as well, for some cases combination for multiple AND , OR also IN probably will make sense for routeros .. (or may be not).

For some use-cases it will make sense, for some not. It's really hard to say. If we restrict it, sooner or later someone will complain ;-)

I'm not sure how yml structure can be done properly as " completely validated by ansible" since is "unknown" input however if we can do that .. will be the best approach for sure, we are not in rush, so we can try to find a solution !

"Completely validated by ansible" will only work for some restricted form.

Just I feel "operator" key somehow awkward, value is something which always will match with "==, !=, >, <" or str values "is, not, more, less" aka, input of matematic operatoros as value of dedicated key.

The main point for the operator being a dict value (that can be templated) instead of a dict key (that cannot be templated) is that it allows templating. Like if you want to search for a value that has a certain property if x, and does not have the property if not x, you can write operator: "{{ '==' if x else '!=' }}". The downside of this complexity is that most of the time you don't need it, but when you need it you will curse a lot if you cannot do it ;-)

BTW, how about renaming where: to where_all:, and or: to where_any:?

whatever we decide, this 'or' somehow not fit !!!! From every list element we expect the same dic, but not for 'or'

Yes, that's true. I don't think there is a way around it.

(You can still let Ansible validate it though, by using required_together, required_one_of, and mutually_exclusive.)

note: sorry on some places I type 'eq' in other 'is' -- this is the same no idea why I continue to do that :)

No problem, I understand what you mean ;)

      - attribute: "ros_attribute1"
        is: "=="
        value: "value"

The cool thing about this is that if you let something reformat the YAML and sort every dictionary by keys, exactly this order is kept :)

How about the following, which is a slightly modified version of your schema:

extended_query:
    attributes:
      - "ros_attribute1"
      - "ros_attribute2"
      - "ros_attribute3"
    where_all:   # <-- 'where_all' instead of 'and' or the original 'where'
      - attribute: "ros_attribute1"
        is: "=="
        value: "value"
      - attribute: "ros_attribute2"
        is: "in"
        value:
          - "value1"
          - "value2"
      - attribute: "ros_attribute3"
        is: "=="
        value: "value"
      - where_any:   # <-- 'where_any' instead of 'or'
          - attribute: "ros_attribute2"
            is: ">"
            value: "value"
          - attribute: "ros_attribute1"
            is: "!="
            value: "value"
          - where_all:   # a nested 'and' :)
              - attribute: "ros_attribute1"
                is: "=="
                value: "foo"
              - attribute: "ros_attribute2"
                is: "!="
                value: "bar"
      - attribute: "ros_attribute3"
        is: "!="
        value: "value"
      - attribute: "ros_attribute2"
        is: "in"
        values:   # <-- I changed 'value' to 'values' here
          - "value1"
          - "value2"

(If we want to do negation at some point (assuming RouterOS and librouteros support it), we can call it where_not:.)

Is there a good way ansible to do check for "extended_query" structure, keep in mind, "extended_query" is dynamic input ?

No, but it is not that hard to implement something that rigorously checks this and converts it to a librouteros expression. I can help with that if you don't want to implement that (I've written way too many of such things in my life, so another one won't hurt :) ).

@NikolayDachev
Copy link
Collaborator Author

NikolayDachev commented Jan 28, 2022

@felixfontein

  1. Or() can include list with attribute.IN(1,2,3), or nested Or()

  2. where() is a function -> We should do more complicated code with some sort of where() recursion ..

  • not sure if make sens
  • not sure if will work as expected
  1. operator: "{{ '==' if x else '!=' }}" <- cool :)

  2. I'm ok with latest proposed yml structure (except recursive where())

  3. "where_all" , "where_any", "value vs. values for In()" IMO!

where_any - Or() is in same group of "operators" (or, and, in) for where()
so

whrer_all( a=1, where_any( a=b, a.IN(1,2,3) ) , a.In(1,2,3), .... )
vs.
where( a=1, Or( a=b, a.IN(1,2,3), Or(...), ... ) , a.In(1,2,3), .... )

I do not insist Or() key to be 'or' .. just I believe will be more practical where() and where(Or()) to be with not similar key names.

a.In() - 'value' vs. 'values' - I don't think is a problem, except if we want to check user input (not a big deal but )

if all 3 keys are consistent we will be more easy to do user input check, if we have 'values' instance 'value' for Is then we should do additional check if is.key == 'is' then if 'values' in 'keys()' <- this is logical check example not python :)

  1. If we make final agreement for this yml structure (I'm still open for suggestions .. as I say this is something important and we are not in rush :) ) , I can change the code with the basic , after that you or someone else can review it and fix it /update it if is needed :)

Also can be the opposite, someone else to do the code I can review it, is not a problem .. just we should avoid double work :)

Regards,

note: https://github.com/luqasz/librouteros/blob/master/librouteros/query.py

@NikolayDachev
Copy link
Collaborator Author

Since no objections for a week, I will start to work on the new code this days

@NikolayDachev
Copy link
Collaborator Author

Latest code with new yml structure for extended_query is ready for review

  1. YML structure
extended_query:
  attributes:
    - ROS attribute for query
    - ROS attribute for query
    - ...
  where:
    - attribute: " ROS attribute for query "
      is: " Logical operator"
      value: " value to be check "
    - ...
    - or:
      - attribute: " ROS attribute for query "
        is: " Logical operator"
        value: " value to be check "
      - ...
    - ...

attributes

  • top ROS attributes to be query
  • type(list)
    where - top where separator (multiple logics)
    where:or - logical OR with multiple atributes

Under where

  • we have multiple checks with default logical "AND"
  • where is type(list)
  • order is important for the end result (depend on user input)
    Under where:or we have multiple checks, same structure as 'where'
  • nested 'or' are not allowed
  • where:or is type(list)

where - attributes: { attribute: '', is: '', value: '' }
attribute

  • can be any valid ROS attribute
  • must exist in top 'attributes'
    'is'
  • valid operators
    • 'eq','=='
    • 'not','!='
    • 'more','>'
    • 'less','<'
    • 'in' - note: 'value is type(list)'
      'value'
  • can be any
  • type(string)
  1. Code notes

Ansible builtin check for 'attributes' and 'where'

extended_query=dict(type='dict', options=dict(
    attributes=dict(type='list', required=True),
    where=dict(type='list')

)),

check_extended_query() - validate extended_query:attributes, extended_query:where and extended_query:where:or input
check_extended_query_syntax() - help(used in loop) function for check_extended_query()

api_extended_query() - main extended_query function
build_api_extended_query() - help funtion to buld librouteros query for api_extended_query

Other

api_query() - extend support for str operators (docs are updated) - C(==) or C(eq), C(!=) or C(not), C(>) or C(more), C(<) or C(less).

After code review TODO

  • docs for extended_query (plus example)
  • unit test

@felixfontein
Copy link
Collaborator

(Just wanted to say that I'm not actively ignoring you and this PR, I'm just too busy at the moment to be able to spend any cycles for this :-( )

@NikolayDachev
Copy link
Collaborator Author

@felixfontein
Do not worry, no rush here.

Personally I don't have a free time the last several weeks on top of that I broke a leg.

My point is .. when we have a time then we will continue on this PR.

side note: I add just several peoples from the community for review (I just was not sure if is convenient to add all), however if any one can help .. feel free to do that. Testing, or code review whatever

I still no work on docs and unity test .. no point to do that before final confirmation (aka yml structure change or any code related etc. )

Regards to all :)

@NikolayDachev
Copy link
Collaborator Author

Any update here, I think to start working on docs/uniti if there is no majors objections for this pr

@felixfontein
Copy link
Collaborator

@NikolayDachev I'll try to take a look at this PR during this weekend.

@NikolayDachev
Copy link
Collaborator Author

Let me know if you need additional info ...

changelogs/fragments/63-add-extended_query.yml Outdated Show resolved Hide resolved
changelogs/fragments/63-add-extended_query.yml Outdated Show resolved Hide resolved
plugins/modules/api.py Outdated Show resolved Hide resolved
plugins/modules/api.py Outdated Show resolved Hide resolved
plugins/modules/api.py Outdated Show resolved Hide resolved
plugins/modules/api.py Outdated Show resolved Hide resolved
plugins/modules/api.py Outdated Show resolved Hide resolved
plugins/modules/api.py Outdated Show resolved Hide resolved
if len(self.result['message']) < 1:
msg = "no results for '%s 'query' %s" % (' '.join(self.path),
self.module.params['extended_query'])
self.result['message'].append(msg)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While this is what api_query() also does, I would vote to not add this if the result set is empty. That way the result is a lot easier to process, since you can better distinguish between "there was no answer" and "there was one answer".

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed this in 579ffcb.

plugins/modules/api.py Outdated Show resolved Hide resolved
@felixfontein
Copy link
Collaborator

I think this looks great! I've added some comments on how to improve some details, but I really like the general way it works! (Sorry if I screwed up some of the comments, I'm writing this with a very bad internet connection, and the GitHub UI is sooooo sluggish...)

@NikolayDachev
Copy link
Collaborator Author

Let me know if you need additional info ...

Will check this days

@NikolayDachev
Copy link
Collaborator Author

I will need a some time to check all suggestions , the other option is to commit them all and fix the rest of the code

@felixfontein
Copy link
Collaborator

@NikolayDachev if you want I can also create a commit out of my suggestions (and the other changes needed due to them) and push them to your branch.

@NikolayDachev
Copy link
Collaborator Author

Hi,

I have zero time to work on this change the last couple of week's :( , I guess after this week I will start to work on.
If you have a time, please commit them, I will check them/review/change etc

@felixfontein
Copy link
Collaborator

@NikolayDachev I've added some commits and tested the result. What do you think?

@NikolayDachev
Copy link
Collaborator Author

I see we have 3 new pull request which are more or less related to api module, let's commit them first and merge here before we continue

@felixfontein
Copy link
Collaborator

@NikolayDachev sounds good to me. They need reviews as well though :)

I'm currently working on some more new modules for working with the API btw.

@NikolayDachev
Copy link
Collaborator Author

Latest changes look ok, I want to check also several other thinks,
Thanks for the improvements!

TODO:

  • docs
  • unit test

note: sorry I was in not healthy condition this days

@NikolayDachev
Copy link
Collaborator Author

Ouuu I miss the tests !!
Thanks will check them as well !!

@felixfontein
Copy link
Collaborator

note: sorry I was in not healthy condition this days

I hope you're doing better now!

Ouuu I miss the tests !!

Haha, no problem ;) There aren't that many yet, feel free to add more :)

@NikolayDachev
Copy link
Collaborator Author

Extended query docs are updated (very small update)
Also simplifying the examples for routeros api !

@felixfontein let me know what you thinks for examples, if is ok I believe we can push this PR !

@NikolayDachev
Copy link
Collaborator Author

NikolayDachev commented May 22, 2022

Actually I can try to clear unit test warnings, however I have some dilemma

for example

codecov
/ codecov/patch
plugins/modules/api.py#L251
Added line #L251 was not covered by tests

I guess warning is for "Or" but

    from librouteros.query import Key, Or

any idea ?

plugins/modules/api.py Outdated Show resolved Hide resolved
plugins/modules/api.py Outdated Show resolved Hide resolved
ip1: "1.1.1.1/32"
ip2: "2.2.2.2/32"
ip3: "3.3.3.3/32"

tasks:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not remove this one as well, and remove the four leading spaces from all tasks?

- name: Dump "{{ path }} print" output
ansible.builtin.debug:
msg: '{{ print_path }}'
path: "ip address"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Without register and potential debug task this is not really useful. Most users don't run their playbooks with enough verbosity to see that something happened.

plugins/modules/api.py Show resolved Hide resolved
is: "in"
value:
- "10.20.36.0"
- "192.168.255.0"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here.

update: >-
.id=*14
address=192.168.255.20/24
comment={{ 'Update 192.168.255.10/24 with .id=*14 to 192.168.255.20/24 on ether2' | community.routeros.quote_argument_value }}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hardcoding temporary IDs in comments is not a good idea :)

Suggested change
comment={{ 'Update 192.168.255.10/24 with .id=*14 to 192.168.255.20/24 on ether2' | community.routeros.quote_argument_value }}
comment={{ 'Update 192.168.255.10/24 to 192.168.255.20/24 on ether2' | community.routeros.quote_argument_value }}

plugins/modules/api.py Show resolved Hide resolved
@felixfontein
Copy link
Collaborator

I guess warning is for "Or" but

    from librouteros.query import Key, Or

any idea ?

You can ignore that one, Key and Or are replaced by fake one in the tests anyway.

(Actually this line isn't running since librouteros is no longer present in the unit tests - it was basically only used for this single line, but for nothing else.)

Copy link
Collaborator

@felixfontein felixfontein left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great! Feel free to merge if you're happy :)

@NikolayDachev NikolayDachev merged commit d57de11 into ansible-collections:main May 23, 2022
@NikolayDachev NikolayDachev deleted the extend-query branch May 23, 2022 11:44
@NikolayDachev
Copy link
Collaborator Author

Merged,

@felixfontein bazillion number of thanks for the help with this PR

@felixfontein
Copy link
Collaborator

@NikolayDachev thanks a lot for working on this one, and sorry again that reviewing it took so long! I'm really glad we managed to complete it, and I'm really happy about its final form :)

@NikolayDachev
Copy link
Collaborator Author

No problems, main issue was my health conditions which was in series, nothing covid related but still ..
I hope the rest of the year to be better and to has more time for the community 😉

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants