diff --git a/Pipfile b/Pipfile index fe807011c..c26ba880a 100644 --- a/Pipfile +++ b/Pipfile @@ -12,6 +12,10 @@ funcsigs = "*" mock = "*" pathlib2 = "*" scandir = "*" +pytest-datadir = "*" +pytest-cov = "*" +tox = "*" +sphinx = "*" [packages] psutil = "*" diff --git a/Pipfile.lock b/Pipfile.lock index 99cbb5f35..a3aa1c752 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "6041fd080b632cf05417e388df3f3246eac0be580c2ce8934b348e490fc0c122" + "sha256": "d971b5850b5e07395ead2a278f89f9b09e7e677f7f068b369902e1fb852998b7" }, "pipfile-spec": 6, "requires": {}, @@ -96,6 +96,13 @@ } }, "develop": { + "alabaster": { + "hashes": [ + "sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359", + "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02" + ], + "version": "==0.7.12" + }, "atomicwrites": { "hashes": [ "sha256:03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4", @@ -110,6 +117,27 @@ ], "version": "==19.3.0" }, + "babel": { + "hashes": [ + "sha256:af92e6106cb7c55286b25b38ad7695f8b4efb36a90ba483d7f7a6628c46158ab", + "sha256:e86135ae101e31e2c8ec20a4e0c5220f4eed12487d5cf3f78be7e98d3a57fc28" + ], + "version": "==2.7.0" + }, + "certifi": { + "hashes": [ + "sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3", + "sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f" + ], + "version": "==2019.11.28" + }, + "chardet": { + "hashes": [ + "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", + "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" + ], + "version": "==3.0.4" + }, "configparser": { "hashes": [ "sha256:254c1d9c79f60c45dfde850850883d5aaa7f19a23f13561243a050d5a7c3fe4c", @@ -126,6 +154,51 @@ "markers": "python_version < '3'", "version": "==0.6.0.post1" }, + "coverage": { + "hashes": [ + "sha256:08907593569fe59baca0bf152c43f3863201efb6113ecb38ce7e97ce339805a6", + "sha256:0be0f1ed45fc0c185cfd4ecc19a1d6532d72f86a2bac9de7e24541febad72650", + "sha256:141f08ed3c4b1847015e2cd62ec06d35e67a3ac185c26f7635f4406b90afa9c5", + "sha256:19e4df788a0581238e9390c85a7a09af39c7b539b29f25c89209e6c3e371270d", + "sha256:23cc09ed395b03424d1ae30dcc292615c1372bfba7141eb85e11e50efaa6b351", + "sha256:245388cda02af78276b479f299bbf3783ef0a6a6273037d7c60dc73b8d8d7755", + "sha256:331cb5115673a20fb131dadd22f5bcaf7677ef758741312bee4937d71a14b2ef", + "sha256:386e2e4090f0bc5df274e720105c342263423e77ee8826002dcffe0c9533dbca", + "sha256:3a794ce50daee01c74a494919d5ebdc23d58873747fa0e288318728533a3e1ca", + "sha256:60851187677b24c6085248f0a0b9b98d49cba7ecc7ec60ba6b9d2e5574ac1ee9", + "sha256:63a9a5fc43b58735f65ed63d2cf43508f462dc49857da70b8980ad78d41d52fc", + "sha256:6b62544bb68106e3f00b21c8930e83e584fdca005d4fffd29bb39fb3ffa03cb5", + "sha256:6ba744056423ef8d450cf627289166da65903885272055fb4b5e113137cfa14f", + "sha256:7494b0b0274c5072bddbfd5b4a6c6f18fbbe1ab1d22a41e99cd2d00c8f96ecfe", + "sha256:826f32b9547c8091679ff292a82aca9c7b9650f9fda3e2ca6bf2ac905b7ce888", + "sha256:93715dffbcd0678057f947f496484e906bf9509f5c1c38fc9ba3922893cda5f5", + "sha256:9a334d6c83dfeadae576b4d633a71620d40d1c379129d587faa42ee3e2a85cce", + "sha256:af7ed8a8aa6957aac47b4268631fa1df984643f07ef00acd374e456364b373f5", + "sha256:bf0a7aed7f5521c7ca67febd57db473af4762b9622254291fbcbb8cd0ba5e33e", + "sha256:bf1ef9eb901113a9805287e090452c05547578eaab1b62e4ad456fcc049a9b7e", + "sha256:c0afd27bc0e307a1ffc04ca5ec010a290e49e3afbe841c5cafc5c5a80ecd81c9", + "sha256:dd579709a87092c6dbee09d1b7cfa81831040705ffa12a1b248935274aee0437", + "sha256:df6712284b2e44a065097846488f66840445eb987eb81b3cc6e4149e7b6982e1", + "sha256:e07d9f1a23e9e93ab5c62902833bf3e4b1f65502927379148b6622686223125c", + "sha256:e2ede7c1d45e65e209d6093b762e98e8318ddeff95317d07a27a2140b80cfd24", + "sha256:e4ef9c164eb55123c62411f5936b5c2e521b12356037b6e1c2617cef45523d47", + "sha256:eca2b7343524e7ba246cab8ff00cab47a2d6d54ada3b02772e908a45675722e2", + "sha256:eee64c616adeff7db37cc37da4180a3a5b6177f5c46b187894e633f088fb5b28", + "sha256:ef824cad1f980d27f26166f86856efe11eff9912c4fed97d3804820d43fa550c", + "sha256:efc89291bd5a08855829a3c522df16d856455297cf35ae827a37edac45f466a7", + "sha256:fa964bae817babece5aa2e8c1af841bebb6d0b9add8e637548809d040443fee0", + "sha256:ff37757e068ae606659c28c3bd0d923f9d29a85de79bf25b2b34b148473b5025" + ], + "version": "==4.5.4" + }, + "docutils": { + "hashes": [ + "sha256:6c4f696463b79f1fb8ba0c594b63840ebd41f059e92b31957c46b74a4599b6d0", + "sha256:9e4d7ecfc600058e07ba661411a2b7de2fd0fafa17d1a7f7361cd47b1175c827", + "sha256:a2aeea129088da402665e92e0b25b04b073c04b2dce4ab65caaa38b7ce2e1a99" + ], + "version": "==0.15.2" + }, "entrypoints": { "hashes": [ "sha256:589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19", @@ -143,6 +216,13 @@ "markers": "python_version < '3.4'", "version": "==1.1.6" }, + "filelock": { + "hashes": [ + "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59", + "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836" + ], + "version": "==3.0.12" + }, "flake8": { "hashes": [ "sha256:45681a117ecc81e870cbf1262835ae4af5e7a8b08e40b944a8a6e6b895914cfb", @@ -167,6 +247,20 @@ "markers": "python_version < '3.2'", "version": "==3.2.3.post2" }, + "idna": { + "hashes": [ + "sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407", + "sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c" + ], + "version": "==2.8" + }, + "imagesize": { + "hashes": [ + "sha256:3f349de3eb99145973fefb7dbe38554414e5c30abd0c8e4b970a7c9d09f3a1d8", + "sha256:f3832918bc3c66617f92e35f5d70729187676313caa60c187eb0f28b8fe5e3b5" + ], + "version": "==1.1.0" + }, "importlib-metadata": { "hashes": [ "sha256:3a8b2dfd0a2c6a3636e7c016a7e54ae04b997d30e69d5eacdca7a6c2221a1402", @@ -175,6 +269,46 @@ "markers": "python_version < '3.8'", "version": "==1.2.0" }, + "jinja2": { + "hashes": [ + "sha256:74320bb91f31270f9551d46522e33af46a80c3d619f4a4bf42b3164d30b5911f", + "sha256:9fe95f19286cfefaa917656583d020be14e7859c6b0252588391e47db34527de" + ], + "version": "==2.10.3" + }, + "markupsafe": { + "hashes": [ + "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473", + "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161", + "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235", + "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5", + "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff", + "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b", + "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1", + "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e", + "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183", + "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66", + "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1", + "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1", + "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e", + "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b", + "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905", + "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735", + "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d", + "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e", + "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d", + "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c", + "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21", + "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2", + "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5", + "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b", + "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6", + "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f", + "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f", + "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7" + ], + "version": "==1.1.1" + }, "mccabe": { "hashes": [ "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", @@ -199,6 +333,13 @@ "index": "pypi", "version": "==5.0.0" }, + "packaging": { + "hashes": [ + "sha256:28b924174df7a2fa32c1953825ff29c61e2f5e082343165438812f00d3a7fc47", + "sha256:d9551545c6d761f3def1677baf08ab2a3ca17c56879e70fecba2fc4dde4ed108" + ], + "version": "==19.2" + }, "pathlib2": { "hashes": [ "sha256:0ec8205a157c80d7acc301c0b18fbd5d44fe655968f5d947b6ecef5290fc35db", @@ -241,6 +382,20 @@ ], "version": "==2.1.1" }, + "pygments": { + "hashes": [ + "sha256:2a3fe295e54a20164a9df49c75fa58526d3be48e14aceba6d6b1e8ac0bfd6f1b", + "sha256:98c8aa5a9f778fcd1026a17361ddaf7330d1b7c62ae97c3bb0ae73e0b9b6b0fe" + ], + "version": "==2.5.2" + }, + "pyparsing": { + "hashes": [ + "sha256:20f995ecd72f2a1f4bf6b072b63b22e2eb457836601e76d6e5dfcd75436acc1f", + "sha256:4ca62001be367f01bd3e92ecbb79070272a9d4964dce6a48a82ff0b8bc7e683a" + ], + "version": "==2.4.5" + }, "pytest": { "hashes": [ "sha256:13c5e9fb5ec5179995e9357111ab089af350d788cbc944c628f3cde72285809b", @@ -249,6 +404,29 @@ "index": "pypi", "version": "==4.4.0" }, + "pytest-cov": { + "hashes": [ + "sha256:cc6742d8bac45070217169f5f72ceee1e0e55b0221f54bcf24845972d3a47f2b", + "sha256:cdbdef4f870408ebdbfeb44e63e07eb18bb4619fae852f6e760645fa36172626" + ], + "index": "pypi", + "version": "==2.8.1" + }, + "pytest-datadir": { + "hashes": [ + "sha256:1847ed0efe0bc54cac40ab3fba6d651c2f03d18dd01f2a582979604d32e7621e", + "sha256:d3af1e738df87515ee509d6135780f25a15959766d9c2b2dbe02bf4fb979cb18" + ], + "index": "pypi", + "version": "==1.3.1" + }, + "pytz": { + "hashes": [ + "sha256:1c557d7d0e871de1f5ccd5833f60fb2550652da6be2693c1e02300743d21500d", + "sha256:b02c06db6cf09c12dd25137e563b31700d3b80fcc4ad23abb7a315f2789819be" + ], + "version": "==2019.3" + }, "pyyaml": { "hashes": [ "sha256:0e7f69397d53155e55d10ff68fdfb2cf630a35e6daf65cf0bdeaf04f127c09dc", @@ -266,6 +444,13 @@ "index": "pypi", "version": "==5.2" }, + "requests": { + "hashes": [ + "sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4", + "sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31" + ], + "version": "==2.22.0" + }, "scandir": { "hashes": [ "sha256:2586c94e907d99617887daed6c1d102b5ca28f1085f90446554abf1faf73123e", @@ -291,6 +476,43 @@ "index": "pypi", "version": "==1.13.0" }, + "snowballstemmer": { + "hashes": [ + "sha256:209f257d7533fdb3cb73bdbd24f436239ca3b2fa67d56f6ff88e86be08cc5ef0", + "sha256:df3bac3df4c2c01363f3dd2cfa78cce2840a79b9f1c2d2de9ce8d31683992f52" + ], + "version": "==2.0.0" + }, + "sphinx": { + "hashes": [ + "sha256:9f3e17c64b34afc653d7c5ec95766e03043cc6d80b0de224f59b6b6e19d37c3c", + "sha256:c7658aab75c920288a8cf6f09f244c6cfdae30d82d803ac1634d9f223a80ca08" + ], + "index": "pypi", + "version": "==1.8.5" + }, + "sphinxcontrib-websupport": { + "hashes": [ + "sha256:1501befb0fdf1d1c29a800fdbf4ef5dc5369377300ddbdd16d2cd40e54c6eefc", + "sha256:e02f717baf02d0b6c3dd62cf81232ffca4c9d5c331e03766982e3ff9f1d2bc3f" + ], + "version": "==1.1.2" + }, + "toml": { + "hashes": [ + "sha256:229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c", + "sha256:235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e" + ], + "version": "==0.10.0" + }, + "tox": { + "hashes": [ + "sha256:7efd010a98339209f3a8292f02909b51c58417bfc6838ab7eca14cf90f96117a", + "sha256:8dd653bf0c6716a435df363c853cad1f037f9d5fddd0abc90d0f48ad06f39d03" + ], + "index": "pypi", + "version": "==3.14.2" + }, "typing": { "hashes": [ "sha256:91dfe6f3f706ee8cc32d38edbbf304e9b7583fb37108fef38229617f8b3eba23", @@ -300,6 +522,20 @@ "markers": "python_version < '3.5'", "version": "==3.7.4.1" }, + "urllib3": { + "hashes": [ + "sha256:a8a318824cc77d1fd4b2bec2ded92646630d7fe8619497b142c84a9e6f5a7293", + "sha256:f3c5fd51747d450d4dcf6f923c81f78f811aab8205fda64b0aba34a4e48b0745" + ], + "version": "==1.25.7" + }, + "virtualenv": { + "hashes": [ + "sha256:116655188441670978117d0ebb6451eb6a7526f9ae0796cc0dee6bd7356909b0", + "sha256:b57776b44f91511866594e477dd10e76a6eb44439cdd7f06dcd30ba4c5bd854f" + ], + "version": "==16.7.8" + }, "yamllint": { "hashes": [ "sha256:0260121ed6a428b98bbadb7b6b66e9cd00114382e3d7ad06fa80e0754414cf15", diff --git a/ansible_runner/helpers.py b/ansible_runner/helpers.py new file mode 100644 index 000000000..4adc5b2e3 --- /dev/null +++ b/ansible_runner/helpers.py @@ -0,0 +1,119 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +import importlib + +from six import string_types + + +PYTHON_RESERVED = frozenset([ + 'and', 'assert', 'in', 'del', 'else', 'raise', 'from', 'if', 'continue', + 'not', 'pass', 'finally', 'while', 'yield', 'is', 'as', 'break', 'return', + 'elif', 'except', 'def', 'global', 'import', 'for', 'or', 'print', + 'lambda', 'with', 'class', 'try', 'exec' +]) + + +def to_list(o): + """Convert value to a list + + :param o: any valid object + :type o: object + + :returns: a list object + :rtype: list + """ + if isinstance(o, (list, tuple, set)): + return list(o) + elif o is not None: + return [o] + else: + return list() + + +def isvalidattrname(v): + """Checks if value is a valid method name + + This function will check the name of the argument and + return either True if the value can be used as an instance + method or False if can cannot be used. + + :param v: the value to check + :type v: str + + :returns: True or False + :rtype: bool + """ + if v in PYTHON_RESERVED: + raise ValueError("value is a reserved word") + if not v[0].isalpha(): + raise ValueError("name must start with an alpha character") + return True + + +def load_module(name): + """Loads the named module + + :param name: the fully qualified module name + :type name: str + + :returns: the loaded class from the module + :rtype: object + """ + mod, cls = name.split(':') if ':' in name else (name, None) + mod = importlib.import_module(mod) + if cls is not None and cls in dir(mod): + mod = getattr(mod, cls) + return mod + + +def make_attr(attrtype, **kwargs): + """Helper function to load attributes for Objects + + Loads the attribute class by name and sets the default + value for `serialize_when` to `SERIALIZE_WHEN_PRESENT` if + a more specific value is not specified. + + :param attrype: the attribute type to create + :type attrtype: ``str`` + + :param kwargs: keyword arguments passed to the attribute class + + :returns: an instance of the attribute class + :rtype: ``ansible_runner.types.attrs.Attribute`` + """ + attrs = load_module('ansible_runner.types.attrs') + + if kwargs.pop('lazy', None) is True: + kwargs['attrtype'] = attrtype + attrtype = 'ansible_runner.types.attrs:LazyAttribute' + + if isinstance(attrtype, string_types): + if ':' not in attrtype: + attrsdir = dir(attrs) + attributes = dict(zip([n.lower() for n in attrsdir], attrsdir)) + cls = getattr(attrs, attributes[attrtype]) + else: + cls = load_module(attrtype) + else: + cls = attrtype + + if 'required' not in kwargs: + kwargs['serialize_when'] = attrs.SERIALIZE_WHEN_PRESENT + + return cls(**kwargs) diff --git a/ansible_runner/inventory/__init__.py b/ansible_runner/inventory/__init__.py new file mode 100644 index 000000000..6f0ee7a68 --- /dev/null +++ b/ansible_runner/inventory/__init__.py @@ -0,0 +1,232 @@ +# -*- coding: utf-8 -*- +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +from ansible_runner.types.objects import Object +from ansible_runner.helpers import make_attr +from ansible_runner.types.validators import PortValidator + + +class AnsibleVars(object): + """Base class with Ansible inventory attributes + + This class provides a common implementation of Ansible + inventory attributes. This class is designed to be a mixin + and should not need to be directly instantiated. + + :param ansible_connection: + Connection type to the host. This can be the name of any of + Ansible’s connection plugins. + :type ansible_connection: str + + :param ansible_port: + The connection port number, if not the default (22 for ssh) + :type ansible_port: int + + :param ansible_user: + The user name to use when connecting to the host + :type ansible_user: str + + :param ansible_password: + The password to use to authenticate to the host + :type ansible_password: str + + :param ansible_ssh_private_key_file: + Private key file used by ssh. Useful if using multiple keys and + you don’t want to use SSH agent. + :type ansible_ssh_private_key_file: str + + :param ansible_ssh_common_args: + This setting is always appended to the default command line for + sftp, scp, and ssh. Useful to configure a ProxyCommand for a + certain host (or group). + :type ansible_ssh_common_args: str + + :param ansible_sftp_extra_args: + This setting is always appended to the default sftp command line. + :type ansible_sftp_extra_args: str + + :param ansible_scp_extra_args: + This setting is always appended to the default scp command line. + :type ansible_scp_extra_args: str + + :param ansible_ssh_pipelining: + Determines whether or not to use SSH pipelining. This can + override the pipelining setting in ansible.cfg. + :type ansible_ssh_pipelining: bool + + :param ansible_ssh_executable: + This setting overrides the default behavior to use the system + ssh. This can override the ssh_executable setting in ansible.cfg. + :type ansible_ssh_executable: str + + :param ansible_become: + Equivalent to ansible_sudo or ansible_su, allows to force + privilege escalation + :type ansible_become: bool + + :param ansible_become_method: + Allows to set privilege escalation method + :type ansible_become_method: str + + :param ansible_become_user: + Equivalent to ansible_sudo_user or ansible_su_user, allows to + set the user you become through privilege escalation + :type ansible_become_user: str + + :param ansible_become_password: + Equivalent to ansible_sudo_password or ansible_su_password, allows + you to set the privilege escalation password + :type ansible_become_password: str + + :param ansible_become_exe: + Equivalent to ansible_sudo_exe or ansible_su_exe, allows you to + set the executable for the escalation method selected + :type ansible_become_exec: str + + :param ansible_become_flags: + Equivalent to ansible_sudo_flags or ansible_su_flags, allows you + to set the flags passed to the selected escalation method. This + can be also set globally in ansible.cfg in the sudo_flags option + :type ansible_become_flags: str + + :param ansible_shell_type: + The shell type of the target system. You should not use this + setting unless you have set the ansible_shell_executable to a + non-Bourne (sh) compatible shell. By default commands are formatted + using sh-style syntax. Setting this to csh or fish will cause + commands executed on target systems to follow those shell’s + syntax instead. + :type ansible_shell_type: str + + :param ansible_python_interpreter: + The target host python path. This is useful for systems with + more than one Python or not located at /usr/bin/python such as + *BSD, or where /usr/bin/python is not a 2.X series Python. + :type ansible_python_interpreter: str + + :param ansible_shell_executable: + This sets the shell the ansible controller will use on the target + machine, overrides executable in ansible.cfg which defaults to + /bin/sh. You should really only change it if is not possible to + use /bin/sh (i.e. /bin/sh is not installed on the target machine + or cannot be run from sudo.). + :type ansible_shell_executable: str + + :param ansible_docker_extra_args: + Could be a string with any additional arguments understood by + Docker, which are not command specific. This parameter is mainly + used to configure a remote Docker daemon to use. + :type ansible_docker_extra_args: str + + :param ansible_network_os: + Sets the target device network OS value which allows the + connection plugin to load the current plugin for the network + device. + :type ansible_network_os: str + """ + + ansible_connection = make_attr('string') + ansible_port = make_attr('integer', validators=(PortValidator(),)) + ansible_user = make_attr('string', aliases=('ansible_ssh_user',)) + ansible_password = make_attr('string', aliases=('ansible_ssh_pass',)) + ansible_ssh_private_key_file = make_attr('string') + ansible_ssh_common_args = make_attr('string') + ansible_sftp_extra_args = make_attr('string') + ansible_scp_extra_args = make_attr('string') + ansible_ssh_pipelining = make_attr('boolean') + ansible_ssh_executable = make_attr('string') + ansible_become = make_attr('boolean') + ansible_become_method = make_attr('string') + ansible_become_user = make_attr('string') + ansible_become_password = make_attr('string') + ansible_become_exe = make_attr('string') + ansible_become_flags = make_attr('string') + ansible_shell_type = make_attr('string') + ansible_python_interpreter = make_attr('string') + ansible_shell_executable = make_attr('string') + ansible_docker_extra_args = make_attr('string') + ansible_network_os = make_attr('string') + + +class Inventory(Object): + """Provides an implementation of Ansible inventory + + This class provides a top level object for building an + inventory that can be used by Ansible. The inventory object + provides attributes for creating hosts, groups (children) and + host variables. + + This model provided by this class implements the Ansible + YAML inventory plugin. See the Ansible documentation for + details on the final inventory structure. + + Use the inventory class to create a new inventory. + + >>> from ansible_runner.inventory import Inventory + >>> inventory = Inventory() + >>> host = inventory.hosts.new('localhost') + >>> host.ansible_user = 'admin' + >>> host['key'] = 'value' + >>> child = inventory.children.new('all') + >>> child['key'] = 'value' + + The inventory class also supports assiging variables directly + to the object. + + >>> inv['key'] = 'value' + + :param hosts: + List of hosts in the top level inventory + :type hosts: ``MapContainer`` + + :param children: + List of children supported for this inventory + :type children: ``MapContainer`` + + :param vars: + Arbitrary set of key/value pairs stored in inventory + :type vars: dict + """ + + hosts = make_attr('map', cls='ansible_runner.inventory.hosts:Host') + children = make_attr('map', cls='ansible_runner.inventory.children:Child') + vars = make_attr('dict') + + def __init__(self, **kwargs): + kwargs = kwargs.get('all', {}) + super(Inventory, self).__init__(**kwargs) + + def serialize(self): + """Overrides the implementation from ``Object`` + + This method overrides the base class implementation to handle + the injection of the "all" top level key in the final object + that is returned to the caller. + """ + obj = super(Inventory, self).serialize() + return {'all': obj} + + def deserialize(self, ds): + """Overrides the implementation from ``Object`` + + This method overrides the base class implementation to handle + the removal of the "all" top levelkey in the provided data + structure. + """ + super(Inventory, self).deserialize(ds['all']) diff --git a/ansible_runner/inventory/children.py b/ansible_runner/inventory/children.py new file mode 100644 index 000000000..eeffd4e56 --- /dev/null +++ b/ansible_runner/inventory/children.py @@ -0,0 +1,90 @@ +# -*- coding: utf-8 -*- +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +from ansible_runner.types.objects import Object +from ansible_runner.types.objects import MapObject +from ansible_runner.helpers import make_attr +from ansible_runner.inventory import AnsibleVars + + +class Vars(AnsibleVars, MapObject): + """Implements well-known Ansible varialbles for Child vars + + An instance of this class provides access to the Ansible + well known Ansible inventory varialbles asl well as allows + for the assigning of arbitrary key / value pairs that will be + associated with the child instance. + """ + pass + + +class Child(Object): + """Provides an implementation of a Child inventory object + + This class implements an Ansible child group in the inventory. + Child objects are effectively ways to group related hosts and + variables together in children. + + >>> from ansible_runner.inventory.children import Child + >>> child = Child() + >>> child.vars.ansible_connection = 'local' + >>> child.vars['key'] = 'value' + + Child objects can be added to Children on an Ansible inventory object. + + >>> from ansible_runner.inventory import Inventory + >>> inventory = Inventory() + >>> inventory.children['group_x'] = child + + Child objects can also be created from children attributes on the + inventory object. + + >>> child_2 = inventory.children.new('group_y') + >>> child_2.vars.ansible_user = 'admin' + + Children properties are fully recursive for building nested groups + in the inventory. + + :param hosts: + The set of hosts associated with this child + :type hosts: MapContainer + + :param children: + List of children supported for this inventory + :type children: MapContainer + + :param vars: + Arbitrary set of key/value pairs associated with this child + :type vars: dict + """ + + hosts = make_attr('map', cls='ansible_runner.inventory.hosts:Host') + vars = make_attr('any', cls='ansible_runner.inventory.children:Vars') + children = make_attr( + 'map', + cls='ansible_runner.inventory.children:Child', + lazy=True + ) + + def __init__(self, **kwargs): + childvars = kwargs.pop('vars', None) + if childvars and isinstance(childvars, dict): + kwargs['vars'] = Vars(**childvars) + super(Child, self).__init__(**kwargs) + diff --git a/ansible_runner/inventory/hosts.py b/ansible_runner/inventory/hosts.py new file mode 100644 index 000000000..e511d172e --- /dev/null +++ b/ansible_runner/inventory/hosts.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +from ansible_runner.types.objects import MapObject +from ansible_runner.helpers import make_attr +from ansible_runner.inventory import AnsibleVars + + +class Host(AnsibleVars, MapObject): + """Represents an inventory host + + The implementation of this class provides an Ansible inventory + host. Host objects provide a typed set of properties as well + as implement a 'dict-like' interface for handling arbitrary + key / value pairs. + + >>> from ansible_runner.inventory.hosts import Host + >>> host = Host() + >>> host.name = 'localhost' + >>> host.ansible_connection = 'local' + >>> host['key'] = 'value' + + Host instances can be assign directly to the inventory or to + children in the inventory. + + >>> from ansible_runner.inventory import Inventory + >>> inventory = Inventory() + >>> inventory.hosts['localhost'] = host + + Host entries can also be created from an instance of ``Inventory``. + + >>> host = inventory.hosts.new('localhost') + + :param ansible_host: + The name of the host to connect to, if different from the alias + you wish to give to it. + :type ansible_host: str + """ + + ansible_host = make_attr('string') diff --git a/ansible_runner/playbook/__init__.py b/ansible_runner/playbook/__init__.py new file mode 100644 index 000000000..7b0b2c79f --- /dev/null +++ b/ansible_runner/playbook/__init__.py @@ -0,0 +1,228 @@ +# -*- coding: utf-8 -*- +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +from ansible_runner.helpers import make_attr +from ansible_runner.types.validators import PortValidator +from ansible_runner.types.containers import IndexContainer +from ansible_runner.helpers import load_module + + +class Base(object): + """The ``Base`` class provides attributes that commonly implemented + + All of the attributes in ``Base`` are implemented in all playbook + instances. This class should be treated as a mixin class to other + more specific implementations of playbook components (Play, Task, Role). + + :param any_errors_fatal: + Force any un-handled task errors on any host to propagate to all + hosts and end the play. + :type any_errors_fatal: bool + + :param become: + Boolean that controls if privilege escalation is used or not + on Task execution. + :type become: bool + + :param become_exe: + UNDOCUMENTED!! + :type become_exec: str + + :param become_flags: + A string of flag(s) to pass to the privilege escalation program when + become is True. + :type become_flags: str + + :param become_method: + Which method of privilege escalation to use (such as sudo or su). + :type become_flags: + + :param become_user: + User that you ‘become’ after using privilege escalation. The + remote/login user must have permissions to become this user. + :type become_user: str + + :param check_mode: + A boolean that controls if a task is executed in ‘check’ mode + :type check_mmode: bool + + :param collections: + UNDOCUMENTED!! + :type collections: IndexContainer + + :param connection: + Allows you to change the connection plugin used for tasks to + execute on the target. + :type connection: str + + :param debugger: + Enable debugging tasks based on state of the task result. + :type debugger: str + + :param diff: + Toggle to make tasks return ‘diff’ information or not. + :type diff: bool + + :param environment: + A dictionary that gets converted into environment vars to be + provided for the task upon execution. This cannot affect Ansible + itself nor its configuration, it just sets the variables for the + code responsible for executing the task. + :type environment: dict + + :param ignore_errors: + Boolean that allows you to ignore task failures and continue with + play. It does not affect connection errors. + :type ignore_errors: bool + + :param ignore_unreachable: + Boolean that allows you to ignore unreachable hosts and continue + with play. This does not affect other task errors (see ignore_errors) + but is useful for groups of volatile/ephemeral hosts. + :type ignore_unreachable: bool + + :param module_defaults: + Specifies default parameter values for modules. + :type module_defaults: dict + + :param name: + Identifier. Can be used for documentation, in or tasks/handlers. + :type name: str + + :param no_log: + Boolean that controls information disclosure. + :type no_log: bool + + :param port: + Used to override the default port used in a connection. + :type port: int + + :param remote_user: + User used to log into the target via the connection plugin. + :type remote_user: str + + :param run_once: + Boolean that will bypass the host loop, forcing the task to + attempt to execute on the first host available and afterwards + apply any results and facts to all active hosts in the same batch. + :type run_once: bool + + :param tags: + Tags applied to the task or included tasks, this allows selecting + subsets of tasks from the command line. + :type tags: IndexContainer + + :param throttle: + Limit number of concurrent task runs on task, block and playbook + level. This is independent of the forks and serial settings, but + cannot be set higher than those limits. For example, if forks is set + to 10 and the throttle is set to 15, at most 10 hosts will be + operated on in parallel. + :type throttle: int + + :param vars: + Dictionary/map of variables + :type vars: dict + """ + any_errors_fatal = make_attr('boolean') + become = make_attr('boolean') + become_exe = make_attr('string') + become_flags = make_attr('string') + become_method = make_attr('string') + become_user = make_attr('string') + check_mode = make_attr('boolean') + collections = make_attr('index', cls=str, unique=True) + connection = make_attr('string') + debugger = make_attr('string') + diff = make_attr('boolean') + environment = make_attr('dict') + ignore_errors = make_attr('boolean') + ignore_unreachable = make_attr('boolean') + module_defaults = make_attr('dict') + name = make_attr('string') + no_log = make_attr('boolean') + port = make_attr('integer', validators=(PortValidator(),)) + remote_user = make_attr('string') + run_once = make_attr('boolean') + tags = make_attr('index', cls=str, unique=True) + throttle = make_attr('integer') + vars = make_attr('dict') + + +class Delegate(object): + """The ``Delegate`` class provides attributes for delegation + + This class provides a consistent implementation of attributes that + are only implemented for ``Task`` and ``Role`` but not ``Play``. This + class should be treated as a mixin class and does not provide a + standalone implementation. + + :param delegate_facts: + Boolean that allows you to apply facts to a delegated host + instead of inventory_hostname. + :type delegate_fats: bool + + :param delegate_to: + Host to execute task instead of the target (inventory_hostname). + Connection vars from the delegated host will also be used for + the task. + :type delegate_to: str + """ + delegate_facts = make_attr('boolean') + delegate_to = make_attr('string') + + +class Conditional(object): + """ The ``Conditional`` class provides attributes for conditionals + + This class provides a consistent implementation for using conditionals + with ``Task``, and ``Role`` classes. This class is a mixin class and + does not provide a standalone implementation. + + :param when: + Conditional expression, determines if an iteration of a task + is run or not. + :type when: list + """ + when = make_attr('list', coerce=True) + + +class Playbook(IndexContainer): + """The ``Playbook`` class is the top most class for Ansible Playbooks + + This class provides the topmost implementation for organizing + plays, tasks and roles. It provides a complete Ansible Playbook + implementation. + + To create a new Ansible playbook, start by creating an instance + of ``Playbook`` and create a new ``Play``. + + >>> from ansible_runner.playbook import Playbook + >>> pb = Playbook() + >>> play = pb.new() + + Once the playbook has been fully configured, use the ``serialize()`` + instance method to generate a JSON representation of the Ansible + playbook that can be used with the ``ansible-playbook`` command. + """ + + def __init__(self, *args, **kwargs): + super(Playbook, self).__init__( + load_module('ansible_runner.playbook.plays:Play') + ) diff --git a/ansible_runner/playbook/plays.py b/ansible_runner/playbook/plays.py new file mode 100644 index 000000000..2e5e73115 --- /dev/null +++ b/ansible_runner/playbook/plays.py @@ -0,0 +1,144 @@ +# -*- coding: utf-8 -*- +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +from ansible_runner.types.objects import Object +from ansible_runner.types.validators import ChoiceValidator +from ansible_runner.helpers import make_attr +from ansible_runner.playbook import Base + + +VALID_ORDER_VALUES = ChoiceValidator(frozenset([ + 'inventory', 'sorted', 'reverse_sorted', 'reverse_inventory', 'shuffle' +])) + + +class Play(Base, Object): + """Provides a model for dynamically creating Ansible plays + + This class provides an implementation that represents an + Ansible play. An instance of this class can be directly + injected into an Ansible playbook. + + The following is an example play. + + >>> from ansible_runner.playbook.plays import Play + >>> p = Play() + >>> p.gather_facts = False + >>> p.connection = 'local' + + The newly created instance of ``Play`` can be added to an existing + playbook instance. + + >>> from ansible_runner.playbook import Playbook + >>> pb = Playbook() + >>> pb.append(p) + + A new instance of ``Play`` can also be created directly from the + playbook object using the ``new()`` method. + + >>> new_play = pb.new() + + :param force_handlers: + Will force notified handler execution for hosts even if they + failed during the play. Will not trigger if the play itself fails. + :type force_handlers: bool + + :param gather_facts: + A boolean that controls if the play will automatically run the + ‘setup’ task to gather facts for the hosts. + :type gather_facts: bool + + :param gather_subset: + Allows you to pass subset options to the fact gathering plugin + controlled by gather_facts. + :type gather_subset: list + + :param gather_timeout: + Allows you to set the timeout for the fact gathering plugin + controlled by gather_facts. + :type gather_timeout: int + + :param handlers: + A section with tasks that are treated as handlers, these won’t + get executed normally, only when notified after each section of + tasks is complete. A handler’s listen field is not templatable. + :type handlers: list + + :param hosts: + A list of groups, hosts or host pattern that translates into a + list of hosts that are the play’s target. + :type hosts: str + + :param max_fail_precentage: + can be used to abort the run after a given percentage of hosts + in the current batch has failed. + :type max_fail_precentage: float + + :param order: + Controls the sorting of hosts as they are used for executing + the play. Possible values are inventory (default), sorted, + reverse_sorted, reverse_inventory and shuffle. + :type order: str + + :param post_tasks: + A list of tasks to execute after the tasks section. + :type post_tasks: Tasks + + :param pre_tasks: + A list of tasks to execute before roles. + :type pre_tasks: Tasks + + :param roles: + List of roles to be imported into the play + :type roles: IndexContainer + + :param serial: + Explicitly define how Ansible batches the execution of the + current play on the play’s target + :type serial: int + + :param strategy: + Allows you to choose the connection plugin to use for the play. + :type strategy: str + + :param tasks: + Main list of tasks to execute in the play, they run after roles + and before post_tasks. + :type tasks: Tasks + + :param vars_prompt: + list of variables to prompt for. + :type vars_prompt: str + """ + + force_handlers = make_attr('boolean') + gather_facts = make_attr('boolean') + gather_subset = make_attr('list') + gather_timeout = make_attr('integer') + handlers = make_attr('list') + hosts = make_attr('string', default='all') + max_fail_precentage = make_attr('float') + order = make_attr('string', validators=(VALID_ORDER_VALUES,)) + post_tasks = make_attr('ansible_runner.playbook.tasks:Tasks') + pre_tasks = make_attr('ansible_runner.playbook.tasks:Tasks') + roles = make_attr('index', cls='ansible_runner.playbook.roles:Role') + serial = make_attr('integer') + strategy = make_attr('string') + tasks = make_attr('ansible_runner.playbook.tasks:Tasks') + vars_prompt = make_attr('string') diff --git a/ansible_runner/playbook/roles.py b/ansible_runner/playbook/roles.py new file mode 100644 index 000000000..c8bf38c9d --- /dev/null +++ b/ansible_runner/playbook/roles.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +from ansible_runner.types.objects import MapObject +from ansible_runner.helpers import make_attr +from ansible_runner.playbook import Base +from ansible_runner.playbook import Delegate +from ansible_runner.playbook import Conditional + + +class Role(Base, Delegate, Conditional, MapObject): + """Provides a model for creating Ansible playbook roles + + This implementation can be used to build an instance of an + Ansible role. The instance can be included in an Ansible + play using the roles attribute. + + The following is an example of how to create a role entry: + + >>> from ansible_runner.playbook.roles import Role + >>> role = Role(name='example.role') + + Variables can be directly assigned to roles using a `dict-like` + interface: + + >>> role['var1'] = 'value1' + >>> role['var2'] = 'value2' + + Once the role instance is created, it can be directly added to a + ``Play`` instance. + + >>> from ansible_runner.playbook import Playbook + >>> pb = Playbook() + >>> play = pb.new() + >>> play.roles.append(role) + + A new instance of ``Role`` can also be created by calling the ``new()`` + method on the Play.roles attribute. + + >>> second_role = play.roles.new(name='example.role2') + + .. note:: + + The ``name`` parameter is a redefinition of the same attribute + inhereted from ``Base`` except that with an instance of ``Role`` + the attribute is now required. + + :param name: + Identifier. Can be used for documentation, in or tasks/handlers. + :type name: str + """ + + name = make_attr('string', required=True) diff --git a/ansible_runner/playbook/tasks.py b/ansible_runner/playbook/tasks.py new file mode 100644 index 000000000..bc910e978 --- /dev/null +++ b/ansible_runner/playbook/tasks.py @@ -0,0 +1,313 @@ +# -*- coding: utf-8 -*- +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +from six import iteritems + +from ansible_runner.helpers import make_attr +from ansible_runner.types.attrs import Attribute +from ansible_runner.types.objects import Object +from ansible_runner.types.containers import IndexContainer +from ansible_runner.playbook import Base +from ansible_runner.playbook import Delegate +from ansible_runner.playbook import Conditional + + +def _injest_task(attrs, kwargs): + newargs = {} + + if 'block' not in kwargs and \ + ('action' not in kwargs and 'local_action' not in kwargs) and kwargs: + + for key in attrs: + value = kwargs.pop(key, None) + if value is not None: + newargs[key] = value + + assert len(kwargs) == 1 + + for action, args in iteritems(kwargs): + if isinstance(args, dict): + newargs.update({'action': action, 'args': args}) + else: + newargs.update({'action': action, 'freeform': args}) + + return newargs or kwargs + + +class Task(Base, Delegate, Conditional, Object): + """Model for dynamically creating Ansible Tasks + + The implementation of this class provides a model for + creating Ansible play tasks. A task can either be directly + instantiated and added to a play or created from a Play + object. + + >>> from ansible_runner.playbook.tasks import Task + >>> t = Task(action='debug') + >>> t.args['msg'] = 'Hello World' + + Once the task has been created, it can be added to a + play as shown below. + + >>> from ansible_runner.playbook import Playbook + >>> pb = Playbook() + >>> play = pb.new() + >>> play.tasks.append(t) + + Tasks can also be created from the play. + + >>> new_task = play.tasks.new(action='debug') + + :param action: + The ‘action’ to execute for a task, it normally translates into + a module or action plugin. This attribute is mutually exclusive + with :py:attr:`local_action`. If both attributes are configured + then :py:attr:`action` is preferred. + :type: action: str + + :param args: + A secondary way to add arguments into a task. Takes a dictionary + in which keys map to options and values. This attribute is mutually + exclusive with :py:attr:`freeform`. If both attributes are + configured on an instance, then :py:attr:`args` is preferred. + :type args: dict + + :param changed_when: + Conditional expression that overrides the task’s normal + ‘changed’ status. + :type changed_when: str + + :param delay: + Number of seconds to delay between retries. This setting is + only used in combination with until. + :type delay: int + + :param failed_when: + Conditional expression that overrides the task’s normal + ‘failed’ status. + :type failed_when: str + + :param freeform: + Single free form data value for this task. This attribute + is mutually exclusive with :py:attr:`args`. If both attributes + are provided, then :py:attr:`args` is preferred. + + :param local_action: + Same as action but also implies delegate_to: localhost + :type local_action: str + + :param loop: + Takes a list for the task to iterate over, saving each list + element into the item variable (configurable via loop_control) + :type loop: str + + :param loop_control: + Several keys here allow you to modify/set loop behaviour in a task. + :type loop_control: dict + + :param notify: + List of handlers to notify when the task returns a + ‘changed=True’ status. + :type notify: list + + :param poll: + Sets the polling interval in seconds for async tasks (default 10s). + :type poll: int + + :param register: + Name of variable that will contain task status and module return data. + :type register: str + + :param retries: + Number of retries before giving up in a until loop. This setting + is only used in combination with until. + :type retries: int + + :param until: + This keyword implies a ‘retries loop’ that will go on until the + condition supplied here is met or we hit the retries limit. + :type until: str + """ + + action = make_attr( + 'string', + require_one_of=('action', 'local_action'), + mutually_exclusive_group='action_group', + mutually_exclusive_priority=1 + ) + args = make_attr( + 'dict', + mutually_exclusive_group='arg_group', + mutually_exclusive_priority=1 + + ) + freeform = make_attr('string', mutually_exclusive_group='arg_group') + changed_when = make_attr('string') + delay = make_attr('integer') + failed_when = make_attr('string') + local_action = make_attr( + 'string', + require_one_of=('action', 'local_action'), + mutually_exclusive_group='action_group' + ) + loop = make_attr('string') + loop_control = make_attr('dict') + notify = make_attr('list') + poll = make_attr('integer') + register = make_attr('string') + retries = make_attr('integer') + until = make_attr('string') + + def __init__(self, **kwargs): + kwargs = _injest_task(self._attributes, kwargs) + super(Task, self).__init__(**kwargs) + + def serialize(self): + obj = super(Task, self).serialize() + + if 'action' in obj: + action = obj.pop('action') + + elif 'local_action' in obj: + action = obj.pop('local_action') + + if 'args' in obj: + args = obj.pop('args') + elif 'freeform' in obj: + args = obj.pop('freeform') + else: + args = {} + + obj.update({action: args}) + + return obj + + def deserialize(self, ds): + super(Task, self).deserialize(_injest_task(self._attributes, ds)) + + +class Block(Base, Delegate, Conditional, Object): + """Represents a Block entry in a Task list + + This class will create an instance of a ``Block`` object that + can be inserted into an Ansible play task list. This class + can be directly instantiated or created from a ``Play`` object. + + >>> from ansible_runner.playbook.tasks import Block + >>> b = Block() + >>> block = b.block.new(action='debug') + >>> block.args['msg'] = 'Hello World' + >>> rescue = b.resuce.new(action='debug') + >>> rescue.args['msg'] = 'Hello World' + + Block objects can also be created directly from the Ansible + playook instance as well. + + >>> from ansible_runner.playbook import Playbook + >>> pb = Playbook() + >>> play = pb.new() + >>> block_entry = play.tasks.new() + >>> task_block = block_entry.block.new(action='debug') + >>> task_block.args['msg'] = 'Hello World' + >>> rescue_block = block_entry.rescue.new(action='debug') + >>> rescue_block.args['msg'] = 'Hello World' + + :param block: + List of tasks in a block. + :type block: Task + + :param rescue: + List of tasks in a block that run if there is a task error + in the main block list. + :type rescue: Task + + :param always: + List of tasks, in a block, that execute no matter if there is + an error in the block or not. + :type rescue: Task + """ + + block = make_attr('ansible_runner.playbook.tasks:Tasks', lazy=True) + always = make_attr('ansible_runner.playbook.tasks:Tasks', lazy=True) + rescue = make_attr('ansible_runner.playbook.tasks:Tasks', lazy=True) + + +class TasksContainer(IndexContainer): + """Implements a container to handle task items + + The implementation provides a conatiner that can handle more + than one type. Since a task list can contain either a ``Task`` + instance or a ``Block`` instance, a generalized ``IndexContainer`` + cannot be used. + + The ``new()`` method of this implementation will introspect the + provided keyword arguments and return either a ``Task`` object + or a ``Block`` object. + """ + + def __init__(self, cls, unique=False): + self.types = (Block, Task) + super(TasksContainer, self).__init__(cls, unique) + + def _type_check(self, value): + if type(value) not in self.types: + raise TypeError( + "invalid type, expected one of {}, got {}".format( + ", ", type(value) + ) + ) + + def new(self, **kwargs): + """Overrides the base class method to control item creation + + The TaskContainer must support both Task and Block items + this method will decide which type of object to created based + on the provided keyword arguments. + """ + kwargs = _injest_task(Task._attributes, kwargs) + if 'action' in kwargs or 'local_action' in kwargs: + obj = Task(**kwargs) + else: + obj = Block(**kwargs) + self.append(obj) + return obj + + +class Tasks(Attribute): + """Implementation of ``Attribute`` to handle play tasks + + The ``Tasks`` object is an implementation of ``Attribute`` for + describing the tasks property for plays. It is basically an + implementation of ``Index`` with a different item class + """ + + def __init__(self, **kwargs): + self.cls = Task + kwargs['default'] = TasksContainer(self.cls) + super(Tasks, self).__init__(type=TasksContainer, **kwargs) + + def __call__(self, value): + # because a Index is serialialized as a list object, the + # deserialization process will attempt to pass a native list into the + # this method. this will attempt to recreate the Index object + if isinstance(value, list): + obj = TasksContainer(self.cls) + obj.deserialize(value) + value = obj + return super(Tasks, self).__call__(value) diff --git a/ansible_runner/types/__init__.py b/ansible_runner/types/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ansible_runner/types/attrs.py b/ansible_runner/types/attrs.py new file mode 100644 index 000000000..eaeabb9dd --- /dev/null +++ b/ansible_runner/types/attrs.py @@ -0,0 +1,254 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +from copy import deepcopy + +from six import string_types + +from ansible_runner.types.containers import IndexContainer +from ansible_runner.types.containers import MapContainer +from ansible_runner.types.validators import TypeValidator +from ansible_runner.types.validators import RequiredValueValidator +from ansible_runner.helpers import load_module +from ansible_runner.helpers import to_list +from ansible_runner.utils import ensure_str + + +SERIALIZE_WHEN_ALWAYS = 0 +SERIALIZE_WHEN_PRESENT = 1 +SERIALIZE_WHEN_NEVER = 2 + + +class Attribute(object): + """Meta data that describes an attribute associated with an Object + + Attributes are attached to Objects to create typed instances. + The meta data that handles how a particular property is evaluated + is based on an instance of Attribute + + :param type: + the Python object type for this attribute + :type type: object + + :param name: + the name of the Object attribute + :type name: str + + :param required: + whether or not the attribute is required to be set + :type required: bool + + :param validators: + an interable list of validators for the value + :type validators: tuple + + :param serialize_when: + controls when an attribute should be serialized + :type serialize_when: int + + :param aliases: + one or more alias attribute names + :type aliases: tuple + + :param mutually_exclusive_group: + assigns the named attribute to a mutally exclusive configuration + group where one and only one attribute will be used. all attributes + with the same group name will be considered. + :type mutually_exclusive_group: str + + :param mutually_exclusive_priority: + used to influence which attribute in a group is selected. all + attributes are assigned a priority of 255 by default. the lowest + priority attribute with a configured value wins. + :type mutually_exclusive_priority: int + + :param require_one_of: + set of attribute names where at most one value must be set + :type reuqired_one_of: tuple + + :returns: + an instance of Attribute + :rtype: Attribute + """ + + def __init__(self, type, name=None, default=None, required=None, + validators=None, serialize_when=None, aliases=None, + mutually_exclusive_group=None, require_one_of=None, + mutually_exclusive_priority=255): + + self.type = type + self.name = name + self.default = default + self.validators = validators or set() + self.aliases = aliases or () + self.serialize_when = serialize_when or SERIALIZE_WHEN_ALWAYS + self.mutually_exclusive_group = mutually_exclusive_group + self.mutually_exclusive_priority = int(mutually_exclusive_priority) + self.require_one_of = require_one_of + + try: + self.validators = set(self.validators) + except TypeError: + raise AttributeError("validators must be iterable") + + if serialize_when is not None: + if serialize_when not in (0, 1, 2): + raise ValueError("invalid value for serialize_when") + + self.validators.add(TypeValidator(self.type)) + + if required is True and self.require_one_of is not None: + raise AttributeError("required and require_one_of are mutually exclusive") + + if required: + self.validators.add(RequiredValueValidator()) + if self.serialize_when > 0: + raise AttributeError( + "required attributes must always be serialized" + ) + + if self.default is not None: + for item in self.validators: + item(self.default) + + def __call__(self, value): + value = value if value is not None else self.default + for item in self.validators: + item(value) + return deepcopy(value) + + +class LazyAttribute(Attribute): + + def __init__(self, **kwargs): + self.kwargs = kwargs + super(LazyAttribute, self).__init__(None) + + +class String(Attribute): + + def __init__(self, *args, **kwargs): + super(String, self).__init__(str, *args, **kwargs) + + def __call__(self, value): + if value: + value = ensure_str(value) + return super(String, self).__call__(value) + + +class Integer(Attribute): + + def __init__(self, *args, **kwargs): + return super(Integer, self).__init__(int, *args, **kwargs) + + +class Float(Attribute): + + def __init__(self, *args, **kwargs): + return super(Float, self).__init__(float, *args, **kwargs) + + +class Bytes(Attribute): + + def __init__(self, *args, **kwargs): + return super(Bytes, self).__init__(bytes, *args, **kwargs) + + +class Boolean(Attribute): + + def __init__(self, *args, **kwargs): + return super(Boolean, self).__init__(bool, *args, **kwargs) + + +class List(Attribute): + + def __init__(self, coerce=None, *args, **kwargs): + self.coerce = coerce + if kwargs.get('default') is None: + kwargs['default'] = [] + super(List, self).__init__(list, *args, **kwargs) + + def __call__(self, value): + if self.coerce: + value = to_list(value) + return super(List, self).__call__(value) + + +class Dict(Attribute): + + def __init__(self, *args, **kwargs): + if kwargs.get('default') is None: + kwargs['default'] = {} + super(Dict, self).__init__(dict, *args, **kwargs) + + +class Any(Attribute): + + def __init__(self, cls, *args, **kwargs): + if isinstance(cls, string_types): + cls = load_module(cls) + if kwargs.get('default') is None: + kwargs['default'] = cls() + kwargs['type'] = cls + super(Any, self).__init__(*args, **kwargs) + + +class Map(Attribute): + + def __init__(self, cls, *args, **kwargs): + if isinstance(cls, string_types): + cls = load_module(cls) + + self.cls = cls + + if kwargs.get('default') is None: + kwargs['default'] = MapContainer(self.cls) + + super(Map, self).__init__(MapContainer, *args, **kwargs) + + def __call__(self, value): + if isinstance(value, dict): + obj = MapContainer(self.cls) + obj.deserialize(value) + value = obj + return super(Map, self).__call__(value) + + +class Index(Attribute): + + def __init__(self, cls, unique=False, *args, **kwargs): + if isinstance(cls, string_types): + cls = load_module(cls) + + self.cls = cls + self.unique = unique + + if kwargs.get('default') is None: + kwargs['default'] = IndexContainer(cls, unique) + + super(Index, self).__init__(IndexContainer, *args, **kwargs) + + def __call__(self, value): + # because a Index is serialialized as a list object, the + # deserialization process will attempt to pass a native list into the + # this method. this will attempt to recreate the Index object + if isinstance(value, list): + obj = IndexContainer(self.cls, self.unique) + obj.deserialize(value) + value = obj + return super(Index, self).__call__(value) diff --git a/ansible_runner/types/containers.py b/ansible_runner/types/containers.py new file mode 100644 index 000000000..f933b90ff --- /dev/null +++ b/ansible_runner/types/containers.py @@ -0,0 +1,161 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +import json + +from collections import MutableMapping, MutableSequence + +from six import iteritems + + +class Container(object): + + def __init__(self, cls): + self.cls = cls + self.store = None + + def __repr__(self): + return json.dumps(self.serialize()) + + def __eq__(self, other): + if hasattr(other, 'serialize'): + other = other.serialize() + return self.serialize() == other + + def __neq__(self, other): + return not self.__eq__(other) + + def __cmp__(self, other): + return self.__eq__(other) + + def __deepcopy__(self, memo): + kwargs = self.serialize() + o = type(self)(self.cls) + o.deserialize(kwargs) + return o + + def _type_check(self, value): + if not isinstance(value, self.cls): + raise TypeError( + "invalid type, got {}, expected {}".format( + type(value), type(self.cls) + ) + ) + + def new(self, **kwargs): + raise NotImplementedError + + def serialize(self): + raise NotImplementedError + + def deserialize(self, ds): + raise NotImplementedError + + +class IndexContainer(MutableSequence, Container): + + def __init__(self, cls, unique=False): + super(IndexContainer, self).__init__(cls) + self.store = list() + self.unique = unique + + def __getitem__(self, index): + return self.store[index] + + def __setitem__(self, index, value): + self._type_check(value) + if not self.unique or (self.unique and value not in self.store): + self.store[index] = value + + def __delitem__(self, index): + del self.store[index] + + def __len__(self): + return len(self.store) + + def insert(self, index, value): + self._type_check(value) + self.store.insert(index, value) + + def append(self, value): + self._type_check(value) + if not self.unique or (self.unique and value not in self.store): + super(IndexContainer, self).append(value) + + def new(self, **kwargs): + obj = self.cls(**kwargs) + self.append(obj) + return obj + + def __deepcopy__(self, memo): + kwargs = self.serialize() + o = type(self)(self.cls, self.unique) + o.deserialize(kwargs) + return o + + def serialize(self): + objects = list() + for item in self.store: + if hasattr(item, 'serialize'): + objects.append(item.serialize()) + else: + objects.append(item) + return objects + + def deserialize(self, ds): + assert isinstance(ds, list), "argument must be of type 'list'" + for item in ds: + self.new(**item) + + +class MapContainer(MutableMapping, Container): + + def __init__(self, cls): + super(MapContainer, self).__init__(cls) + self.store = {} + + def __getitem__(self, index): + return self.store[index] + + def __setitem__(self, index, value): + self._type_check(value) + self.store[index] = value + + def __delitem__(self, index): + del self.store[index] + + def __len__(self): + return len(self.store) + + def __iter__(self): + return iter(self.store) + + def new(self, _key, **kwargs): + if _key in self.store: + raise ValueError("item with key {} already exists".format(_key)) + obj = self.cls(**kwargs) + self[_key] = obj + return obj + + def serialize(self): + return dict([(k, v.serialize()) for k, v in iteritems(self.store)]) + + def deserialize(self, ds): + assert isinstance(ds, dict), "argument must be of type 'dict'" + for key, value in iteritems(ds): + self.new(key, **value) diff --git a/ansible_runner/types/objects.py b/ansible_runner/types/objects.py new file mode 100644 index 000000000..6e1083435 --- /dev/null +++ b/ansible_runner/types/objects.py @@ -0,0 +1,254 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +import json + +from six import with_metaclass, iteritems +from six import itervalues + +from ansible_runner.types.attrs import Attribute +from ansible_runner.types.attrs import LazyAttribute +from ansible_runner.types.attrs import SERIALIZE_WHEN_NEVER +from ansible_runner.helpers import isvalidattrname +from ansible_runner.helpers import make_attr + + +class BaseMeta(type): + + def __new__(cls, name, parents, dct): + dct['_attributes'] = {} + + def _create_attrs(attr_dct): + for attr_name in attr_dct: + attr = attr_dct[attr_name] + if isinstance(attr, Attribute): + attr.name = attr_name + + isvalidattrname(attr_name) + dct['_attributes'][attr_name] = attr + + removed = set() + + for entry in list(attr.aliases): + if entry not in attr_dct: + isvalidattrname(entry) + dct['_attributes'][entry] = attr + elif entry in attr_dct: + removed.add(entry) + + attr.aliases = tuple(set(attr.aliases).difference(removed)) + + # process parents first to allow more specific overrides + for parent in parents: + _create_attrs(parent.__dict__) + + _create_attrs(dct) + + return super(BaseMeta, cls).__new__(cls, name, parents, dct) + + +class Object(with_metaclass(BaseMeta)): + """The base class for all typed instances + + This class provides the base implementation from which all + typed classes derive from. The Object class handles setting + up and validating configured attributes (properties). Typically + this class should not be directly instantiated. + """ + + def __init__(self, **kwargs): + require_one_of = list() + + for key, attr in iteritems(self._attributes.copy()): + if isinstance(attr, LazyAttribute): + attr.kwargs['name'] = key + attr = make_attr(**attr.kwargs) + self._attributes[attr.name] = attr + self.__dict__[attr.name] = attr + + value = kwargs.pop(key, attr.default) + + if attr.require_one_of is not None: + require_one_of.append(attr) + + if key == attr.name: + attrval = getattr(self, key) + if attrval is None or isinstance(attrval, Attribute): + setattr(self, key, value) + elif key in attr.aliases: + setattr(self, attr.name, value) + + if kwargs: + raise AttributeError("unknown keyword argument") + + for attr in require_one_of: + for name in attr.require_one_of: + if getattr(self, name) is not None: + break + else: + raise ValueError("missing required_one_of value") + + def __repr__(self): + return json.dumps(self.serialize()) + + def __setattr__(self, key, value): + if key in self._attributes: + attr = self._attributes[key] + + value = attr(value) + + if attr.name != key: + self.__dict__[attr.name] = value + + for item in attr.aliases: + if item != key: + self.__dict__[item] = value + + elif not key.startswith('_'): + raise AttributeError("'{}' object has no attribute '{}'".format( + self.__class__.__name__, key)) + + self.__dict__[key] = value + + def __delattr__(self, key): + if key not in self._attributes and key in dir(self): + raise AttributeError("cannot delete attribute '{}'".format(key)) + self.__setattr__(key, None) + + def __eq__(self, other): + return self.serialize() == other.serialize() + + def __neq__(self, other): + return not self.__eq__(other) + + def __cmp__(self, other): + return self.__eq__(other) + + def __deepcopy__(self, memo): + return type(self)(**self.serialize()) + + def serialize(self): + obj = {} + + mutually_exclusive_check = {} + + for item, attr in iteritems(self._attributes): + value = getattr(self, item) + + if attr.type is bool and value in (True, False) and \ + attr.serialize_when < SERIALIZE_WHEN_NEVER: + obj[item] = value + group = attr.mutually_exclusive_group + if group is not None: + if group not in mutually_exclusive_check: + mutually_exclusive_check[group] = {} + group = mutually_exclusive_check[group] + priority = attr.mutually_exclusive_priority + if priority not in group: + group[priority] = set() + group[priority].add(attr) + + elif value and attr.serialize_when < SERIALIZE_WHEN_NEVER: + if hasattr(value, 'serialize'): + obj[item] = value.serialize() + else: + obj[item] = value + + group = attr.mutually_exclusive_group + if group is not None: + if group not in mutually_exclusive_check: + mutually_exclusive_check[group] = {} + group = mutually_exclusive_check[group] + priority = attr.mutually_exclusive_priority + if priority not in group: + group[priority] = set() + group[priority].add(attr) + + for group in itervalues(mutually_exclusive_check): + group_value = None + for value in itervalues(group): + for item in value: + if not group_value and item.name in obj: + group_value = item.name + if group_value != item.name and item.name in obj: + obj.pop(item.name, None) + + return obj + + def deserialize(self, ds): + assert isinstance(ds, dict), "argument must be of type 'dict'" + for key, value in iteritems(ds): + attr = getattr(self, key) + if hasattr(attr, 'deserialize'): + attr.deserialize(value) + else: + setattr(self, key, value) + + +class MapObject(Object): + """Creates an object that can have arbitrary key/value pairs + + This object acts very much like a normal Python `dict` object in + that aribtrary key/value pairs can be associated with an instance; + however, it is not a `dict` object. There are some patterns that + will not work as expected. + """ + + def __init__(self, **kwargs): + self._vars = {} + for item in set(kwargs).difference(self._attributes): + self._vars[item] = kwargs.pop(item) + super(MapObject, self).__init__(**kwargs) + + def __getitem__(self, key): + if key in self._attributes: + return getattr(self, key) + else: + return self._vars[key] + + def __setitem__(self, key, value): + if key in self._attributes: + setattr(self, key, value) + else: + self._vars[key] = value + + def __delitem__(self, key): + if key in self._attributes: + delattr(self, key) + else: + del self._vars[key] + + #def __len__(self): + # return len(self._vars) + + def __iter__(self): + return iter(self._vars) + + def serialize(self): + obj = super(MapObject, self).serialize() + obj.update(self._vars) + return obj + + def deserialize(self, ds): + assert isinstance(ds, dict), "argument must be of type 'dict'" + kwargs = {} + for name in self._attributes: + if name in ds: + kwargs[name] = ds.pop(name) + super(MapObject, self).deserialize(kwargs) + self._vars.update(ds) diff --git a/ansible_runner/types/validators.py b/ansible_runner/types/validators.py new file mode 100644 index 000000000..69ef34cbf --- /dev/null +++ b/ansible_runner/types/validators.py @@ -0,0 +1,78 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# + + +class TypeValidator(object): + """Validates the value type is the desired type + """ + + def __init__(self, type): + self.type = type + + def __call__(self, value): + if value is not None and not isinstance(value, self.type): + raise TypeError( + "value must be {}, got {}".format(self.type, type(value)) + ) + + +class RequiredValueValidator(object): + """Checks that the value is set + """ + + def __call__(self, value): + if value is None: + raise ValueError("missing required value") + + +class ChoiceValidator(object): + """Validates the provided value is one of a static + list of values + """ + + def __init__(self, choices): + self.choices = frozenset(choices) + + def __call__(self, value): + if value is not None and value not in self.choices: + msg = '{} is an invalid choice. Possible choices are: {}' + raise AttributeError(msg.format(value, self.choices)) + + +class RangeValidator(object): + """Validates that an integer value is contained within a + minimum and maximum value. + """ + + def __init__(self, minval, maxval): + self.minval = minval + self.maxval = maxval + + def __call__(self, value): + if value is not None and not self.minval <= value <= self.maxval: + raise AttributeError('invalid range') + + +class PortValidator(RangeValidator): + """Validates an integer is contained within the valid list + of TCP and/or UDP port numbers (1-65535) + """ + + def __init__(self): + super(PortValidator, self).__init__(1, 65535) diff --git a/docs/index.rst b/docs/index.rst index 300ecfcab..22922a56d 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -42,6 +42,7 @@ Examples of this could include: external_interface standalone python_interface + playbook_api container diff --git a/docs/playbook_api.rst b/docs/playbook_api.rst new file mode 100644 index 000000000..ee4999691 --- /dev/null +++ b/docs/playbook_api.rst @@ -0,0 +1,562 @@ +.. _playbook_api: + +Using the Runner as a playbook and inventory API to Ansible +============================================================ + +**Ansible Runner** provides a modeling API for programmatically building +**Ansible** playbooks and inventory objects that can be directly consumed by +**Ansible Runner**. This allows **Ansible Runner** to be easily embedded into +3rd party applications without having to explicitly write playbooks and +inventory structures upfront. + +There are two modeling APIs currently available to be consumed by Python +developers. The first one is the :class:`Playbook +` object. This object provides a +programmable model for creating **Ansible** playbooks. The second one is the +:class:`Inventory ` object. This object +provides a programmable model for creating a consumable **Ansible** inventory. + +Typed API +--------- + +Both models drive from a base set of typed models that provide an explicitly +typed model with data and type validation. The objects all extend from +:class:`Object ` and incorporiate +:class:`Attribute ` to enforce data +integrity. + +All objects built using this typed API provide a :meth:`serialize() +` and :meth:`deserialize() +` method to transform the +Python object to and from a JSON data structure. The JSON data structure can +be directly injected into the **Ansible Runner** Python interface for +consumption. The serialized JSON data structure can be direclty passed to +**Ansible Runner** :meth:`run() ` or +:meth:`run_async() ` methods. + +Usage Example +~~~~~~~~~~~~~ + +The following code block demonstrates how pass :class:`Playbook +` and :class:`Inventory +` instance to **Ansible Runner**. + +.. code-block:: python + + >>> from ansible_runner.playbook import Playbook + >>> from ansible_runner.inventory import Inventory + >>> from ansible_runner.interface import run + >>> inventory = Inventory() + >>> inventory.hosts.new('localhost', ansible_host='localhost') + {"ansible_host": "localhost"} + >>> inventory.hosts['localhost'].ansible_connection = 'local' + >>> playbook = Playbook() + >>> playbook.new() + {"hosts": "all"} + >>> playbook[0].tasks.new(action='debug', args={'msg': 'Hello world!'}) + {"debug": {"msg": "Hello world!"}} + >>> run(playbook=playbook.serialize(), inventory=inventory.serialize()) + + PLAY [all] ********************************************************************* + + TASK [Gathering Facts] ********************************************************* + ok: [localhost] + + TASK [debug] ******************************************************************* + ok: [localhost] => { + "msg": "Hello world!" + } + + PLAY RECAP ********************************************************************* + localhost : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 + + + + +``Playbook`` +------------ + +:class:`ansible_runner.playbook.Playbook` + +Provides a strongly typed programmable model for reading existing **Ansible** +playbooks and/or writing properly formatted **Ansible** playbooks in JSON. The +:class:`Playbook ` class provides a set of +attributes that implement the cooresoding **Ansible** playbook directives. + + +Usage Examples +~~~~~~~~~~~~~~ + +This section provides some common usage examples about how to implement the +:class:`Playbook ` object. + +.. code-block:: python + + >>> from ansible_runner.playbook import Playbook + >>> playbook = Playbook() + >>> play = playbook.new() + >>> play.gather_facts = False + >>> play.connection = 'local' + >>> play.hosts = 'localhost' + >>> task = play.tasks.new(action='debug') + >>> task.args['msg'] = 'Hello World!' + >>> import json + >>> print(json.dumps(playbook.serialize(), indent=4)) + [ + { + "gather_facts": false, + "connection": "local", + "tasks": [ + { + "debug": { + "msg": "Hello World!" + } + } + ], + "hosts": "localhost" + } + ] + +Adding a new task to an :class:`Playbook ` +object. + +.. code-block:: python + + >>> new_task = play.tasks.new(action='command', freeform='ls -l') + >>> print(json.dumps(playbook.serialize(), indent=4)) + [ + { + "gather_facts": false, + "connection": "local", + "tasks": [ + { + "debug": { + "msg": "Hello World!" + } + }, + { + "command": "ls -1" + } + ], + "hosts": "localhost" + } + ] + +Task lists can also contain :class:`Block +` items. To create a new task block +simply omit the `action` keyword argument. + +.. code-block:: python + + >>> block = play.tasks.new() + >>> block.block.new(action='debug', args={'msg': 'task #1 in block'}) + {"debug": {"msg": "task #1 in block"}} + >>> block.rescue.new(action='debug', args={'msg': 'task #1 in rescue'}) + {"debug": {"msg": "task #1 in rescue"}} + >>> print(json.dumps(playbook.serialize(), indent=4)) + [ + { + "gather_facts": false, + "connection": "local", + "tasks": [ + { + "debug": { + "msg": "Hello World!" + } + }, + { + "command": "ls -1" + }, + { + "rescue": [ + { + "debug": { + "msg": "task #1 in rescue" + } + } + ], + "block": [ + { + "debug": { + "msg": "task #1 in block" + } + } + ] + } + ], + "hosts": "localhost" + } + ] + + +Blocks in task lists can also be nested as deep as necessary with the +``block``, ``rescue`` and ``always`` attributes fully accessible. + +.. code-block:: python + + >>> nested_block = play.tasks.new() + >>> level2_block = nested_block.block.new() + >>> level2_block.block.new(action='debug', args={'msg': 'task #1 in nested block'}) + {"debug": {"msg": "task #1 in nested block"}} + >>> print(json.dumps(playbook.serialize(), indent=4)) + [ + { + "gather_facts": false, + "connection": "local", + "tasks": [ + { + "debug": { + "msg": "Hello World!" + } + }, + { + "command": "ls -1" + }, + { + "rescue": [ + { + "debug": { + "msg": "task #1 in rescue" + } + } + ], + "block": [ + { + "debug": { + "msg": "task #1 in block" + } + } + ] + }, + { + "block": [ + { + "block": [ + { + "debug": { + "msg": "task #1 in nested block" + } + } + ] + } + ] + } + ], + "hosts": "localhost" + } + ] + +Since both ``Tasks`` and ``Blocks`` implement the Python ``MutableSequence`` +interface, entries can be inserted, appended to, and deleted as necessary. + +.. code-block:: python + + >>> from ansible_runner.playbook.tasks import Task + >>> task = Task(action='debug', args={'msg': 'inserted task into play'}) + >>> playbook[0].tasks.insert(0, task) + >>> print(json.dumps(playbook.serialize(), indent=4)) + [ + { + "gather_facts": false, + "connection": "local", + "tasks": [ + { + "debug": { + "msg": "inserted task into play" + } + }, + { + "debug": { + "msg": "Hello World!" + } + }, + { + "command": "ls -1" + }, + { + "rescue": [ + { + "debug": { + "msg": "task #1 in rescue" + } + } + ], + "block": [ + { + "debug": { + "msg": "task #1 in block" + } + } + ] + }, + { + "block": [ + { + "block": [ + { + "debug": { + "msg": "task #1 in nested block" + } + } + ] + } + ] + } + ], + "hosts": "localhost" + } + ] + + >>> del playbook[0].tasks[3] + >>> print(json.dumps(playbook.serialize(), indent=4)) + [ + { + "gather_facts": false, + "connection": "local", + "tasks": [ + { + "debug": { + "msg": "inserted task into play" + } + }, + { + "debug": { + "msg": "Hello World!" + } + }, + { + "command": "ls -1" + }, + { + "block": [ + { + "block": [ + { + "debug": { + "msg": "task #1 in nested block" + } + } + ] + } + ] + } + ], + "hosts": "localhost" + } + ] + +Additional :class:`Play ` objects can be +added to playbook for supporting multi-play playbooks. + +.. code-block:: python + + >>> new_play = playbook.new() + >>> new_play.connection = 'ssh' + >>> new_play.roles.new(name='example.role') + {"name": "example.role"} + >>> print(json.dumps(playbook.serialize(), indent=4)) + [ + { + "gather_facts": false, + "connection": "local", + "tasks": [ + { + "debug": { + "msg": "inserted task into play" + } + }, + { + "debug": { + "msg": "Hello World!" + } + }, + { + "command": "ls -1" + }, + { + "block": [ + { + "block": [ + { + "debug": { + "msg": "task #1 in nested block" + } + } + ] + } + ] + } + ], + "hosts": "localhost" + }, + { + "connection": "ssh", + "hosts": "all", + "roles": [ + { + "name": "example.role" + } + ] + } + ] + +Existing playbooks can also be laoded into a programmable model using the +:meth:`deserialize() ` method. This +method takes a native Python data structure and builds the object. This means +that the playbook must initially be loaded and deserialized by an external +library. + +.. code-block:: python + + >>> from ansible_runner.playbook import Playbook + >>> playbook = Playbook() + >>> import yaml + >>> data = yaml.safe_load(open('demo/project/test.yml')) + >>> print(data) + [{'tasks': [{'debug': 'msg="Test!"'}], 'hosts': 'all'}] + >>> type(data) + + >>> playbook.deserialize(data) + >>> type(playbook) + + >>> import json + >>> print(json.dumps(playbook.serialize(), indent=4)) + [ + { + "tasks": [ + { + "debug": "msg=\"Test!\"" + } + ], + "hosts": "all" + } + ] + + +``Inventory`` +------------- + +:class:`ansible_runner.inventory.Inventory` + +Implements a programmable model for building supported inventories for use with +**Ansible Runner**. The :class:`ansible_runner.inventory.Inventory` supports +creating hosts, groups (children) and vars can can be serialized to a JSON data +structure that can be directly consumable by **Ansible Runner**. + +Usage Examples +~~~~~~~~~~~~~~ + +The following section provides a common usage example that demonstrates how to +implement the :class:`Inventory ` object. + +Create a new instance of :class:`Inventory +` and add a new host to the inventory with +well-known **Ansible** variables. + +.. code-block:: python + + >>> from ansible_runner.inventory import Inventory + >>> inventory = Inventory() + >>> host = inventory.hosts.new('localhost') + >>> host.ansible_host = '127.0.0.1' + >>> host.ansible_connection = 'local' + >>> import json + >>> print(json.dumps(inventory.serialize(), indent=4)) + { + "all": { + "hosts": { + "localhost": { + "ansible_connection": "local", + "ansible_host": "127.0.0.1" + } + } + } + } + + +Additional arbitrary key/value variables can be associated with the host entry +in the inventory. + +.. code-block:: python + + >>> host['key1'] = 'value1' + >>> inventory.hosts['localhost']['key2'] = 'value2' + >>> print(json.dumps(inventory.serialize(), indent=4)) + { + "all": { + "hosts": { + "localhost": { + "key2": "value2", + "key1": "value1", + "ansible_connection": "local", + "ansible_host": "127.0.0.1" + } + } + } + } + +Groups (children) can be added to the inventory. When adding a new child +object to the inventory, the name of the child group is a required positional +argument. + +.. code-block:: python + + >>> child = inventory.children.new('local') + >>> child.ansible_user = 'admin' + >>> child.ansible_password = 'password' + >>> child.ansible_become = True + >>> print(json.dumps(inventory.serialize(), indent=4)) + { + "all": { + "hosts": { + "localhost": { + "key2": "value2", + "key1": "value1", + "ansible_connection": "local", + "ansible_host": "127.0.0.1" + } + }, + "children": { + "local": { + "ansible_become": true, + "ansible_ssh_user": "admin", + "ansible_password": "password", + "ansible_ssh_pass": "password", + "ansible_user": "admin" + } + } + } + } + +Arbitrary variables can be assigned to the inventory object. + +.. code-block:: python + + >>> inventory.vars['key1'] = 'value1' + >>> inventory.vars['key2'] = 'value2' + >>> print(json.dumps(inventory.serialize(), indent=4)) + { + "all": { + "hosts": { + "localhost": { + "key2": "value2", + "key1": "value1", + "ansible_connection": "local", + "ansible_host": "127.0.0.1" + } + }, + "children": { + "local": { + "ansible_become": true, + "ansible_ssh_user": "admin", + "ansible_password": "password", + "ansible_ssh_pass": "password", + "ansible_user": "admin" + } + }, + "vars": { + "key2": "value2", + "key1": "value1" + } + } + } diff --git a/docs/source/ansible_runner.inventory.rst b/docs/source/ansible_runner.inventory.rst new file mode 100644 index 000000000..0bb535b62 --- /dev/null +++ b/docs/source/ansible_runner.inventory.rst @@ -0,0 +1,30 @@ +ansible\_runner.inventory package +================================= + +Submodules +---------- + +ansible\_runner.inventory.children module +----------------------------------------- + +.. automodule:: ansible_runner.inventory.children + :members: + :undoc-members: + :show-inheritance: + +ansible\_runner.inventory.hosts module +-------------------------------------- + +.. automodule:: ansible_runner.inventory.hosts + :members: + :undoc-members: + :show-inheritance: + + +Module contents +--------------- + +.. automodule:: ansible_runner.inventory + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/ansible_runner.playbook.rst b/docs/source/ansible_runner.playbook.rst new file mode 100644 index 000000000..00e7d320b --- /dev/null +++ b/docs/source/ansible_runner.playbook.rst @@ -0,0 +1,38 @@ +ansible\_runner.playbook package +================================ + +Submodules +---------- + +ansible\_runner.playbook.plays module +------------------------------------- + +.. automodule:: ansible_runner.playbook.plays + :members: + :undoc-members: + :show-inheritance: + +ansible\_runner.playbook.roles module +------------------------------------- + +.. automodule:: ansible_runner.playbook.roles + :members: + :undoc-members: + :show-inheritance: + +ansible\_runner.playbook.tasks module +------------------------------------- + +.. automodule:: ansible_runner.playbook.tasks + :members: + :undoc-members: + :show-inheritance: + + +Module contents +--------------- + +.. automodule:: ansible_runner.playbook + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/ansible_runner.plugins.rst b/docs/source/ansible_runner.plugins.rst new file mode 100644 index 000000000..73035282e --- /dev/null +++ b/docs/source/ansible_runner.plugins.rst @@ -0,0 +1,10 @@ +ansible\_runner.plugins package +=============================== + +Module contents +--------------- + +.. automodule:: ansible_runner.plugins + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/ansible_runner.rst b/docs/source/ansible_runner.rst index 2d11c8b37..c3f5b47d0 100644 --- a/docs/source/ansible_runner.rst +++ b/docs/source/ansible_runner.rst @@ -8,6 +8,10 @@ Subpackages ansible_runner.callbacks ansible_runner.display_callback + ansible_runner.inventory + ansible_runner.playbook + ansible_runner.plugins + ansible_runner.types Submodules ---------- @@ -20,6 +24,14 @@ ansible\_runner.exceptions module :undoc-members: :show-inheritance: +ansible\_runner.helpers module +------------------------------ + +.. automodule:: ansible_runner.helpers + :members: + :undoc-members: + :show-inheritance: + ansible\_runner.interface module -------------------------------- @@ -28,6 +40,30 @@ ansible\_runner.interface module :undoc-members: :show-inheritance: +ansible\_runner.loader module +----------------------------- + +.. automodule:: ansible_runner.loader + :members: + :undoc-members: + :show-inheritance: + +ansible\_runner.output module +----------------------------- + +.. automodule:: ansible_runner.output + :members: + :undoc-members: + :show-inheritance: + +ansible\_runner.project module +------------------------------ + +.. automodule:: ansible_runner.project + :members: + :undoc-members: + :show-inheritance: + ansible\_runner.runner module ----------------------------- diff --git a/docs/source/ansible_runner.types.rst b/docs/source/ansible_runner.types.rst new file mode 100644 index 000000000..52304aba5 --- /dev/null +++ b/docs/source/ansible_runner.types.rst @@ -0,0 +1,46 @@ +ansible\_runner.types package +============================= + +Submodules +---------- + +ansible\_runner.types.attrs module +---------------------------------- + +.. automodule:: ansible_runner.types.attrs + :members: + :undoc-members: + :show-inheritance: + +ansible\_runner.types.containers module +--------------------------------------- + +.. automodule:: ansible_runner.types.containers + :members: + :undoc-members: + :show-inheritance: + +ansible\_runner.types.objects module +------------------------------------ + +.. automodule:: ansible_runner.types.objects + :members: + :undoc-members: + :show-inheritance: + +ansible\_runner.types.validators module +--------------------------------------- + +.. automodule:: ansible_runner.types.validators + :members: + :undoc-members: + :show-inheritance: + + +Module contents +--------------- + +.. automodule:: ansible_runner.types + :members: + :undoc-members: + :show-inheritance: diff --git a/test/unit/inventory/__init__.py b/test/unit/inventory/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test/unit/inventory/test_children.py b/test/unit/inventory/test_children.py new file mode 100644 index 000000000..ad5b8e333 --- /dev/null +++ b/test/unit/inventory/test_children.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2019 Red Hat, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +from ansible_runner.inventory.children import Child + + +def test_serialization_deserialization(): + serialized = {'vars': {'ansible_connection': 'test'}} + + child = Child() + child.vars.ansible_connection = 'test' + assert child.serialize() == serialized + + child1 = Child(**serialized) + assert child1 == child + + child2 = Child() + child2.deserialize(serialized) + + assert child2 == child diff --git a/test/unit/inventory/test_hosts.py b/test/unit/inventory/test_hosts.py new file mode 100644 index 000000000..1989eed3e --- /dev/null +++ b/test/unit/inventory/test_hosts.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2019 Red Hat, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +from ansible_runner.inventory.hosts import Host + + +def test_serialization_deserialization(): + host = Host() + host.ansible_host = 'test' + host.ansible_user = 'admin' + host.ansible_password = 'admin' + + serialized = {'ansible_host': 'test', + 'ansible_user': 'admin', 'ansible_ssh_user': 'admin', + 'ansible_password': 'admin', 'ansible_ssh_pass': 'admin'} + + + assert host.serialize() == serialized + + host1 = Host(**serialized) + + assert host1 == host + + host2 = Host() + host2.deserialize(serialized) + + assert host2 == host diff --git a/test/unit/inventory/test_inventory.py b/test/unit/inventory/test_inventory.py new file mode 100644 index 000000000..76868758c --- /dev/null +++ b/test/unit/inventory/test_inventory.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2019 Red Hat, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +from ansible_runner.inventory import Inventory + + +def test_serialization_deserialization(): + inv = Inventory() + inv.hosts.new('test', ansible_host='testhost') + inv.children.new('test', vars={'ansible_connection': 'testchild'}) + inv.vars['test'] = 'testvar' + + hosts = {'test': {'ansible_host': 'testhost'}} + children = {'test': {'vars': {'ansible_connection': 'testchild'}}} + + serialized = {'all': {'hosts': hosts, + 'children': children, + 'vars': {'test': 'testvar'}}} + + assert inv.serialize() == serialized + + inv2 = Inventory(**serialized) + assert inv2 == inv + + inv3 = Inventory() + inv3.deserialize(serialized) + + assert inv3 == inv diff --git a/test/unit/playbook/__init__.py b/test/unit/playbook/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test/unit/playbook/test_playbook.py b/test/unit/playbook/test_playbook.py new file mode 100644 index 000000000..cf0ca4643 --- /dev/null +++ b/test/unit/playbook/test_playbook.py @@ -0,0 +1,30 @@ +# +# Copyright (c) 2019 Red Hat, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +from ansible_runner.playbook import Playbook +from ansible_runner.playbook.plays import Play + + +def test_playbook_init(): + pb = Playbook() + assert isinstance(pb, Playbook) + assert len(pb) == 0 + + +def test_playbook_new(): + pb = Playbook() + assert isinstance(pb.new(), Play) + diff --git a/test/unit/playbook/test_plays.py b/test/unit/playbook/test_plays.py new file mode 100644 index 000000000..eab2fcf39 --- /dev/null +++ b/test/unit/playbook/test_plays.py @@ -0,0 +1,33 @@ +# +# Copyright (c) 2019 Red Hat, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +from ansible_runner.playbook.plays import Play + + +def test_serialization_deserialization(): + play = Play() + play.gather_facts = False + play.connection = 'local' + + data = play.serialize() + + assert data == {'hosts': 'all', 'gather_facts': False, + 'connection': 'local'} + + newplay = Play() + newplay.deserialize(data) + + assert newplay == play diff --git a/test/unit/playbook/test_roles.py b/test/unit/playbook/test_roles.py new file mode 100644 index 000000000..8d78eff97 --- /dev/null +++ b/test/unit/playbook/test_roles.py @@ -0,0 +1,32 @@ +# +# Copyright (c) 2019 Red Hat, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +from ansible_runner.playbook.roles import Role + + +def test_serialization_deserialization(): + role = Role(name='test') + role.delegate_facts = True + role.delegate_to = 'test' + + data = role.serialize() + + assert data == {'name': 'test', 'delegate_facts': True, 'delegate_to': 'test'} + + newrole = Role(name='test') + newrole.deserialize(data) + + assert newrole == role diff --git a/test/unit/playbook/test_tasks.py b/test/unit/playbook/test_tasks.py new file mode 100644 index 000000000..d0d3f9646 --- /dev/null +++ b/test/unit/playbook/test_tasks.py @@ -0,0 +1,97 @@ +# +# Copyright (c) 2019 Red Hat, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +import pytest + +from ansible_runner.playbook.tasks import Task +from ansible_runner.playbook.tasks import Block +from ansible_runner.playbook.tasks import Tasks +from ansible_runner.playbook.tasks import TasksContainer + + +def test_task_init_with_action(): + task = {'action': 'test', 'args': {'one': 1, 'two': 2}, 'name': 'test'} + t = Task(**task) + assert t.serialize() == {'test': {'one': 1, 'two': 2}, 'name': 'test'} + + +def test_task_init_without_action(): + task = {'test': {'one': 1, 'two': 2}, 'name': 'test'} + t = Task(**task) + serialized_task = {'test': {'one': 1, 'two': 2}, 'name': 'test'} + assert t.serialize() == serialized_task + + +def test_task_action_mutually_exclusive(): + t = Task(action='action-test') + + assert t.action == 'action-test' + assert t.local_action is None + + t.local_action = 'local_action-test' + assert t.local_action == 'local_action-test' + + serialized = t.serialize() + assert 'action-test' in serialized + assert 'local_action-test' not in serialized + + del t.action + + serialized = t.serialize() + assert 'action-test' not in serialized + assert 'local_action-test' in serialized + + +def test_task_action_is_required(): + with pytest.raises(ValueError): + Task() + + +def test_task_serialization_deserialization(): + t = Task(action='test', args={'one': 1}) + data = t.serialize() + assert data == {'test': {'one': 1}} + t1 = Task(**data) + assert t == t1 + t2 = Task(action='test') + t2.deserialize(data) + assert t == t2 + + +def test_task_serialization_with_local_action(): + t = Task(local_action='test', args={'one': 1}) + data = t.serialize() + assert data == {'test': {'one': 1}} + + +def test_task_invalid_type(): + c = TasksContainer(Task) + with pytest.raises(TypeError): + c.append(str) + + +def test_taskcontainer_returns_task(): + c = TasksContainer(Task) + + assert isinstance(c.new(action='test'), Task) + assert isinstance(c.new(local_action='test'), Task) + assert isinstance(c.new(), Block) + + +def test_tasks_attribute(): + a = Tasks() + o = a([]) + assert isinstance(o, TasksContainer) diff --git a/test/unit/test_helpers.py b/test/unit/test_helpers.py new file mode 100644 index 000000000..b9ccd55d4 --- /dev/null +++ b/test/unit/test_helpers.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2019 Red Hat, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +import pytest + +from ansible_runner import helpers + + +def test_isvalidattrname(): + with pytest.raises(ValueError): + helpers.isvalidattrname('import') + + with pytest.raises(ValueError): + helpers.isvalidattrname('0test') + + helpers.isvalidattrname('test') + + +def test_to_list(): + assert helpers.to_list(None) == [] + assert helpers.to_list('test') == ['test'] + assert helpers.to_list(['test']) == ['test'] + assert helpers.to_list(('test',)) == ['test'] + assert helpers.to_list(set(['test'])) == ['test'] diff --git a/test/unit/types/__init__.py b/test/unit/types/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test/unit/types/test_attrs.py b/test/unit/types/test_attrs.py new file mode 100644 index 000000000..882bb66ea --- /dev/null +++ b/test/unit/types/test_attrs.py @@ -0,0 +1,209 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2019 Red Hat, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +import pytest + +from ansible_runner.types import attrs +from ansible_runner.types.objects import Object +from ansible_runner.types.attrs import Attribute +from ansible_runner.types.attrs import String, Integer, Boolean, List, Dict +from ansible_runner.types.attrs import Bytes, Float +from ansible_runner.types.attrs import Map, Index, Any +from ansible_runner.types.containers import MapContainer +from ansible_runner.types.containers import IndexContainer +from ansible_runner.playbook import Playbook + + +class Item(Object): + name = String() + + +def test_create_attribute_defaults(): + a = Attribute(type=None) + assert a.type is None + assert a.default is None + assert isinstance(a.validators, set) + assert a.aliases == tuple() + assert a.serialize_when == 0 + + +def test_str_attribute(): + a = String() + assert a.type == str + assert a.default is None + + +def test_bool_attribute(): + a = Boolean() + assert a.type == bool + assert a.default is None + + +def test_int_attribute(): + a = Integer() + assert a.type == int + assert a.default is None + + +def test_list_attribute(): + a = List() + assert a.type == list + assert a.default == [] + + +def test_dict_attribute(): + a = Dict() + assert a.type == dict + assert a.default == {} + + +def test_bytes_attribute(): + a = Bytes() + assert a.type == bytes + assert a.default is None + + +def test_float_attribute(): + a = Float() + assert a.type == float + assert a.default is None + + +def test_attribute_requires_type(): + with pytest.raises(TypeError): + Attribute() + + +def test_invalid_default_type(): + with pytest.raises(TypeError): + Attribute(type=str, default=1) + + +def test_call_list_with_default_value(): + """Ensure a copy of the default value is returned + """ + default_value = [1, 2, 3] + a = List(default=default_value) + z = a(None) + assert id(z) != id(default_value) + assert z == default_value + + +def test_call_dict_with_default_value(): + """Ensure a copy of the default value is returned + """ + default_value = {'one': 1, 'two': 2, 'three': 3} + a = Dict(default=default_value) + z = a(None) + assert id(z) != id(default_value) + assert z == default_value + + +def test_map_container(): + c = Map(Item) + assert isinstance(c, Map) + r = c(None) + assert isinstance(r, MapContainer) + + +def test_index_container(): + c = Index(Item) + assert isinstance(c, Index) + r = c(None) + assert isinstance(r, IndexContainer) + + +def test_any(): + c = Any(object) + assert isinstance(c, Any) + r = c('test') + assert isinstance(r, str) + r = c(1) + assert isinstance(r, int) + r = c(True) + assert isinstance(r, bool) + r = c(list()) + assert isinstance(r, list) + r = c({}) + assert isinstance(r, dict) + + +def test_enums(): + assert attrs.SERIALIZE_WHEN_ALWAYS == 0 + assert attrs.SERIALIZE_WHEN_PRESENT == 1 + assert attrs.SERIALIZE_WHEN_NEVER == 2 + + +def test_bad_serialize_when_value(): + with pytest.raises(ValueError): + Attribute(None, serialize_when=3) + + +def test_bad_value_type(): + with pytest.raises(TypeError): + item = Item() + item.name = 1 + + +def test_validators_raises_error(): + with pytest.raises(AttributeError): + Attribute(None, validators=1) + + +def test_required_mutually_exclusive(): + with pytest.raises(AttributeError): + Attribute(None, required=True, require_one_of=('foo', 'bar')) + + +def test_required_with_serialized_when(): + with pytest.raises(AttributeError): + Attribute(None, required=True, serialize_when=2) + + +def test_normalize_string_attribute(): + s = String() + assert s(u'test') == 'test' + + +def test_map_loads_entry_point(): + a = Map('ansible_runner.types.objects:Object') + assert a.cls == Object + + +def test_map_creates_new_instance(): + a = Map('ansible_runner.types.objects:Object') + assert isinstance(a(value={}), MapContainer) + + +def test_index_loads_entry_point(): + a = Index('ansible_runner.types.objects:Object') + assert a.cls == Object + + +def test_index_creates_new_instance(): + a = Index('ansible_runner.types.objects:Object') + assert isinstance(a(value=[]), IndexContainer) + + +def test_list_coerce(): + a = List(coerce=True) + assert a('test') == ['test'] + + +def test_any_with_entrypoint(): + a = Any('ansible_runner.playbook:Playbook') + assert isinstance(a.default, Playbook) diff --git a/test/unit/types/test_containers.py b/test/unit/types/test_containers.py new file mode 100644 index 000000000..6601b6831 --- /dev/null +++ b/test/unit/types/test_containers.py @@ -0,0 +1,160 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2019 Red Hat, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +import pytest + +from ansible_runner.types.objects import Object +from ansible_runner.types.attrs import String, Any +from ansible_runner.types.containers import Container +from ansible_runner.types.containers import IndexContainer, MapContainer + + +class ListItem(Object): + name = String() + + +class DictItem(Object): + name = String() + value = String() + nested = Any(ListItem) + + +def test_index(): + o = IndexContainer(cls=ListItem) + + assert repr(o) == str(o.serialize()) + assert o.serialize() == [] + + item = o.new() + assert o[0] == item + + item = ListItem(name='test') + o[0] = item + assert o[0] == item + + items = [{'name': 'test1'}, {'name': 'test2'}, {'name': 'test3'}] + o = IndexContainer(cls=ListItem) + o.deserialize(items) + assert o.serialize() == items + + o.insert(0, ListItem(name='test')) + + with pytest.raises(TypeError): + o.insert(0, 'foo') + + with pytest.raises(TypeError): + o[0] = 'test' + + with pytest.raises(TypeError): + o.append("foo") + + # make sure deleting an index doesn't raise an error + del o[0] + + +def test_index_comparisons(): + a = IndexContainer(cls=ListItem) + b = IndexContainer(cls=ListItem) + + assert a.__eq__(b) + assert a.__cmp__(b) + + b.new(name='test') + + assert a.__neq__(b) + + +def test_map(): + o = MapContainer(cls=DictItem) + + assert repr(o) == str(o.serialize()) + assert o.serialize() == {} + + item = DictItem(name='foo', value='bar') + o.new('foo', name='foo', value='bar') + assert o['foo'] == item + + del o['foo'] + + o = MapContainer(cls=DictItem) + o.deserialize({'foo': {'name': 'foo', 'value': 'bar'}}) + assert o['foo'] == item + + with pytest.raises(TypeError): + o['test'] = 'test' + + keys = [key for key in o] + assert keys == ['foo'] + + assert len(o) == 1 + + with pytest.raises(TypeError): + o.new(name='foo') + + +def test_map_comparisons(): + a = MapContainer(cls=DictItem) + b = MapContainer(cls=DictItem) + + assert a.__eq__(b) + assert a.__cmp__(b) + + b.new('test') + + assert a.__neq__(b) + + +def test_container_base_methods(): + c = Container(None) + for method in ('new', 'serialize'): + with pytest.raises(NotImplementedError): + getattr(c, method)() + + with pytest.raises(NotImplementedError): + c.deserialize(None) + + +def test_index_unique(): + c = IndexContainer(str, unique=True) + c.append('test') + assert len(c) == 1 + assert 'test' in c + c.append('test') + assert len(c) == 1 + c.append('test2') + assert len(c) == 2 + c.append('test') + assert len(c) == 2 + c[1] == 'test' + assert len(c) == 2 + assert c == ['test', 'test2'] + + +def test_map_container_non_unique_key(): + c = MapContainer(DictItem) + c.new('test') + assert 'test' in c + with pytest.raises(ValueError): + c.new('test') + + +def test_map_serializer(): + c = MapContainer(DictItem) + o = ListItem(name='nested') + c.new('test', name='nested test', nested=o) + assert c.serialize() == {'test': {'name': 'nested test', + 'nested': {'name': 'nested'}}} diff --git a/test/unit/types/test_objects.py b/test/unit/types/test_objects.py new file mode 100644 index 000000000..55f1ca617 --- /dev/null +++ b/test/unit/types/test_objects.py @@ -0,0 +1,434 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2019 Red Hat, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +import copy + +import pytest + +from ansible_runner.types.objects import Object, MapObject +from ansible_runner.types.attrs import String, Integer, Boolean +from ansible_runner.types.attrs import List, Dict, Any + + +class Instance(Object): + name = String() + strattr = String() + intattr = Integer() + boolattr = Boolean() + listattr = List() + dictattr = Dict() + + +class InstanceWithDefaults(Object): + name = String() + strattr = String(default='string') + intattr = Integer(default=0) + boolattr = Boolean(default=False) + listattr = List(default=[1, 2, 3]) + dictattr = Dict(default={'one': 1, 'two': 2, 'three': 3}) + anyattr = Any(Instance) + + +class InstanceWithRequiredAttr(Object): + required = String(required=True) + + +class InstanceWithProperty(Object): + name = String() + + def __init__(self, *args, **kwargs): + super(InstanceWithProperty, self).__init__(*args, **kwargs) + self._internal = 'test' + + +def test_instance(): + o = Instance() + assert repr(o) is not None + + z = Instance() + assert o.__eq__(z) + assert o.__cmp__(z) + + z.name = 'test' + assert o.__neq__(z) + + +def test_instance_with_invalid_attribute(): + with pytest.raises(AttributeError): + Instance(foo='bar') + + +def test_instance_no_attribute(): + o = Instance() + with pytest.raises(AttributeError): + o.test + + with pytest.raises(AttributeError): + o.test = 'test' + + +def test_instance_set_internal_property(): + o = InstanceWithProperty() + with pytest.raises(AttributeError): + del o._internal + + +def test_deepcopy(): + o = Instance(name='string') + c = copy.deepcopy(o) + assert o == c + + +def test_object_serialize(): + inst = Instance(name='test') + + data = {'name': 'test', 'strattr': 'strattr', 'intattr': 10, + 'boolattr': True, 'listattr': [1, 2, 3], 'dictattr': {'one': 1}, + 'anyattr': inst.serialize()} + + o = InstanceWithDefaults( + name='test', strattr='strattr', intattr=10, boolattr=True, + listattr=[1, 2, 3], dictattr={'one': 1}, anyattr=Instance(name='test') + ) + + assert data == o.serialize() + + data = {'name': 'test', 'strattr': 'strattr', 'intattr': 10, + 'boolattr': False, 'listattr': [1, 2, 3], 'dictattr': {'one': 1}} + + o = Instance(name='test', strattr='strattr', intattr=10, boolattr=False, + listattr=[1, 2, 3], dictattr={'one': 1}) + + assert data == o.serialize() + + +def test_object_deserialize(): + inst = Instance(name='test') + + data = {'name': 'test', 'strattr': 'strattr', 'intattr': 10, + 'boolattr': True, 'listattr': [1, 2, 3], 'dictattr': {'one': 1}, + 'anyattr': inst.serialize()} + + o = InstanceWithDefaults() + o.deserialize(data) + + assert data == o.serialize() + + +def test_set_strattr(): + o = Instance() + o.strattr = 'test' + with pytest.raises(TypeError): + o.strattr = 1 + o.strattr = True + o.strattr = [1, 2, 3] + o.strattr = {'one': 1, 'two': 2, 'three': 3} + + +def test_set_intattr(): + o = Instance() + o.intattr = 1 + with pytest.raises(TypeError): + o.intattr = "string" + o.intattr = True + o.intattr = [1, 2, 3] + o.intattr = {'one': 1, 'two': 2, 'three': 3} + + +def test_set_boolattr(): + o = Instance() + o.boolattr = True + o.boolattr = False + with pytest.raises(TypeError): + o.boolattr = "string" + o.boolattr = 0 + o.boolattr = 1 + o.boolattr = [1, 2, 3] + o.boolattr = {'one': 1, 'two': 2, 'three': 3} + + +def test_set_listattr(): + o = Instance() + o.listattr = [1, 2, 3] + with pytest.raises(TypeError): + o.listattr = "string" + o.listattr = 0 + o.listattr = True + o.listattr = {'one': 1, 'two': 2, 'three': 3} + + +def test_set_dictattr(): + o = Instance() + o.dictattr = {'one': 1, 'two': 2, 'three': 3} + for value in ('string', 0, True, [1, 2, 3]): + with pytest.raises(TypeError): + o.dictattr = value + + +def test_del_strattr(): + o = Instance() + assert o.strattr is None + o.strattr = 'test' + assert o.strattr == 'test' + del o.strattr + assert o.strattr is None + + +def test_del_intattr(): + o = Instance() + assert o.intattr is None + o.intattr = 0 + assert o.intattr == 0 + del o.intattr + assert o.intattr is None + + +def test_del_boolattr(): + o = Instance() + assert o.boolattr is None + o.boolattr = True + assert o.boolattr is True + del o.boolattr + assert o.boolattr is None + + +def test_del_listattr(): + o = Instance() + assert o.listattr == [] + o.listattr = [1, 2, 3] + assert o.listattr == [1, 2, 3] + del o.listattr + assert o.listattr == [] + + +def test_del_dictattr(): + o = Instance() + assert o.dictattr == {} + o.dictattr = {'one': 1, 'two': 2, 'three': 3} + assert o.dictattr == {'one': 1, 'two': 2, 'three': 3} + del o.dictattr + assert o.dictattr == {} + + +def test_strattr_with_defaults(): + o = InstanceWithDefaults() + assert o.strattr == 'string' + o.strattr = 'text' + assert o.strattr == 'text' + del o.strattr + assert o.strattr == 'string' + + +def test_intattr_with_defaults(): + o = InstanceWithDefaults() + assert o.intattr == 0 + o.intattr = 2 + assert o.intattr == 2 + del o.intattr + assert o.intattr == 0 + + +def test_boolattr_with_defaults(): + o = InstanceWithDefaults() + assert o.boolattr is False + o.boolattr = True + assert o.boolattr is True + del o.boolattr + assert o.boolattr is False + + +def test_listattr_with_defaults(): + o = InstanceWithDefaults() + assert o.listattr == [1, 2, 3] + o.listattr = [4, 5, 6] + assert o.listattr == [4, 5, 6] + del o.listattr + assert o.listattr == [1, 2, 3] + + +def test_dictattr_with_defaults(): + o = InstanceWithDefaults() + assert o.dictattr == {'one': 1, 'two': 2, 'three': 3} + o.dictattr = {'four': 4, 'five': 5, 'six': 6} + assert o.dictattr == {'four': 4, 'five': 5, 'six': 6} + del o.dictattr + assert o.dictattr == {'one': 1, 'two': 2, 'three': 3} + + +def test_object_init_with_values(): + o = Instance(strattr='string', intattr=100, boolattr=True, + listattr=[1, 2, 3], dictattr={'one': 1, 'two': 2, 'three': 3}) + assert o.strattr == 'string' + assert o.intattr == 100 + assert o.boolattr is True + assert o.listattr == [1, 2, 3] + assert o.dictattr == {'one': 1, 'two': 2, 'three': 3} + + +def test_object_with_required_attr(): + o = InstanceWithRequiredAttr(required='foo') + assert o.required == 'foo' + + with pytest.raises(ValueError): + del o.required + + with pytest.raises(ValueError): + InstanceWithRequiredAttr() + + +class Aliases(Object): + + attr1 = String( + aliases=('attr2', 'attr3') + ) + + attr2 = String() + + +def test_attr1_alias(): + o = Aliases() + + o.attr1 = 'test' + + assert o.attr1 == 'test' + assert o.attr2 is None + assert o.attr3 == 'test' + + o.attr2 = 'test2' + + assert o.attr1 == 'test' + assert o.attr2 == 'test2' + assert o.attr3 == 'test' + + del o.attr1 + + assert o.attr1 is None + assert o.attr2 == 'test2' + assert o.attr3 is None + + o.attr1 = 'test' + del o.attr2 + + assert o.attr1 == 'test' + assert o.attr2 is None + assert o.attr3 == 'test' + + o = Aliases(attr3='test') + + assert o.attr1 == 'test' + assert o.attr2 is None + assert o.attr3 == 'test' + + +class InstanceWithRequireOneOfAttr(Object): + attr1 = String(require_one_of=('attr1', 'attr2')) + attr2 = String(require_one_of=('attr1', 'attr2')) + + +def test_require_one_of(): + with pytest.raises(ValueError): + InstanceWithRequireOneOfAttr() + + o = InstanceWithRequireOneOfAttr(attr1='foo') + + assert o.attr1 == 'foo' + assert o.attr2 is None + + +class InstanceWithMutuallyExclusiveAttr(Object): + attr1 = String(mutually_exclusive_group = 'group1', + mutually_exclusive_priority=1) + attr2 = String(mutually_exclusive_group = 'group1') + + +def test_mutually_exclusive_with(): + o = InstanceWithMutuallyExclusiveAttr(attr1='test') + + assert o.attr1 == 'test' + assert o.attr2 is None + + o.attr2 = 'test' + + assert o.attr1 == 'test' + assert o.attr2 == 'test' + + serialized = o.serialize() + assert serialized.get('attr1') == 'test' + assert serialized.get('attr2') is None + + +class MapInstance(MapObject): + name = String() + + +def test_init(): + o = MapInstance(name='test') + assert o.name == 'test' + assert o._vars == {} + + +def test_init_with_vars(): + o = MapInstance(name='test', test1='test1') + assert o.name == 'test' + assert o['test1'] == 'test1' + + +def test_get_item(): + o = MapInstance() + o['test'] = 'test' + assert o['test'] == 'test' + + +def test_set_item(): + o = MapInstance(name='test') + assert o._vars == {} + o['test'] = 'test' + assert o._vars['test'] == 'test' + + +def test_del_item(): + o = MapInstance(name='test') + assert o._vars == {} + o['test'] = 'test' + assert o._vars['test'] == 'test' + del o['test'] + assert 'test' not in o._vars + + +def test_mapobject_serialize(): + o = MapInstance() + o['test'] = 'test' + assert o.serialize() == {'test': 'test'} + + +def test_mapobject_deserialize(): + o = MapInstance() + assert 'test' not in o._vars + o.deserialize({'test': 'test'}) + assert o._vars['test'] == 'test' + + +def test_iter(): + o = MapInstance() + o['test1'] = 'test1' + o['test2'] = 'test2' + for item in o: + assert item in ('test1', 'test2') + + + + diff --git a/test/unit/types/test_validators.py b/test/unit/types/test_validators.py new file mode 100644 index 000000000..6ea2542a7 --- /dev/null +++ b/test/unit/types/test_validators.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2019 Red Hat, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +import pytest + +from ansible_runner.types.validators import ChoiceValidator +from ansible_runner.types.validators import RangeValidator +from ansible_runner.types.validators import PortValidator + + +def test_choice_validator_pass(): + v = ChoiceValidator(choices=['one', 'two', 'three']) + v('one') + v('two') + v('three') + + +def test_choice_validator_fail(): + v = ChoiceValidator(choices=['one', 'two', 'three']) + with pytest.raises(AttributeError): + v('four') + + +def test_range_validator_pass(): + v = RangeValidator(1, 3) + v(1) + v(2) + v(3) + + +def test_range_validator_fail(): + v = RangeValidator(1, 3) + with pytest.raises(AttributeError): + v(0) + with pytest.raises(AttributeError): + v(4) + + +def test_port_validator_pass(): + v = PortValidator() + for i in range(1, 65535): + v(i) + + +def test_port_validator_fail(): + v = PortValidator() + with pytest.raises(AttributeError): + v(0) + with pytest.raises(AttributeError): + v(65536) diff --git a/tox.ini b/tox.ini index 00807da5a..6faf8c56c 100644 --- a/tox.ini +++ b/tox.ini @@ -7,7 +7,7 @@ deps = pipenv ansible commands= pipenv install --dev #--ignore-pipfile --dev - pipenv run py.test -v test + pipenv run py.test -v test --cov=ansible_runner [testenv:linters] basepython = python3