├── .github └── workflows │ └── pre-commit.yml ├── .gitignore ├── .pre-commit-config.yaml ├── AUTHORS ├── CONTRIBUTING.md ├── CONTRIBUTORS ├── LICENSE ├── MAINTAINERS ├── README.md ├── __init__.py ├── examples └── config.yml ├── lib.py ├── scpbastion.sh ├── scpwrapper.py ├── sftpbastion.sh ├── sftpwrapper.py ├── sshwrapper.py └── tests.py /.github/workflows/pre-commit.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: pre-commit 3 | on: [push, pull_request] 4 | jobs: 5 | pre-commit: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v3 9 | - uses: actions/setup-python@v3 10 | - uses: pre-commit/action@v3.0.0 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | repos: 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v4.1.0 5 | hooks: 6 | - id: check-executables-have-shebangs 7 | - id: check-merge-conflict 8 | - id: end-of-file-fixer 9 | - id: fix-encoding-pragma 10 | args: ['--remove'] 11 | - id: requirements-txt-fixer 12 | - id: trailing-whitespace 13 | - repo: https://github.com/psf/black 14 | rev: 22.3.0 15 | hooks: 16 | - id: black 17 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | # This is the official list of authors for copyright purposes. 2 | # This file is distinct from the CONTRIBUTORS files 3 | # and it lists the copyright holders only. 4 | 5 | # Names should be added to this file as one of 6 | # Organization's name 7 | # Individual's name 8 | # Individual's name 9 | # See CONTRIBUTORS for the meaning of multiple email addresses. 10 | 11 | # Please keep the list sorted. 12 | 13 | OVH SAS 14 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to The Bastion Ansible Wrapper 2 | 3 | This project accepts contributions. In order to contribute, you should 4 | pay attention to a few things: 5 | 6 | 1. your code must follow the coding style rules 7 | 2. your code must be unit-tested 8 | 3. your code must be documented 9 | 4. your work must be signed (see below) 10 | 5. you may contribute through GitHub Pull Requests 11 | 12 | # Coding and documentation Style 13 | 14 | Given the relatively small size of the project, please refer to the 15 | actual files and respect the coding style you observe there. 16 | 17 | # Submitting Modifications 18 | 19 | The contributions should be submitted through Github Pull Requests 20 | and follow the DCO which is defined below. 21 | 22 | # Licensing for new files 23 | 24 | The Bastion Ansible Wrapperr is licensed under an Apache 2 license. Anything 25 | contributed to The Bastion Ansible Wrapper must be released under this license. 26 | 27 | When introducing a new file into the project, please make sure it has a 28 | copyright header making clear under which license it's being released. 29 | 30 | # Developer Certificate of Origin (DCO) 31 | 32 | To improve tracking of contributions to this project we will use a 33 | process modeled on the modified DCO 1.1 and use a "sign-off" procedure 34 | on patches that are being emailed around or contributed in any other 35 | way. 36 | 37 | The sign-off is a simple line at the end of the explanation for the 38 | patch, which certifies that you wrote it or otherwise have the right 39 | to pass it on as an open-source patch. The rules are pretty simple: 40 | if you can certify the below: 41 | 42 | By making a contribution to this project, I certify that: 43 | 44 | (a) The contribution was created in whole or in part by me and I have 45 | the right to submit it under the open source license indicated in 46 | the file; or 47 | 48 | (b) The contribution is based upon previous work that, to the best of 49 | my knowledge, is covered under an appropriate open source License 50 | and I have the right under that license to submit that work with 51 | modifications, whether created in whole or in part by me, under 52 | the same open source license (unless I am permitted to submit 53 | under a different license), as indicated in the file; or 54 | 55 | (c) The contribution was provided directly to me by some other person 56 | who certified (a), (b) or (c) and I have not modified it. 57 | 58 | (d) The contribution is made free of any other party's intellectual 59 | property claims or rights. 60 | 61 | (e) I understand and agree that this project and the contribution are 62 | public and that a record of the contribution (including all 63 | personal information I submit with it, including my sign-off) is 64 | maintained indefinitely and may be redistributed consistent with 65 | this project or the open source license(s) involved. 66 | 67 | 68 | then you just add a line saying 69 | 70 | Signed-off-by: Random J Developer 71 | 72 | using your real name (sorry, no pseudonyms or anonymous contributions.) 73 | -------------------------------------------------------------------------------- /CONTRIBUTORS: -------------------------------------------------------------------------------- 1 | # This is the official list of people who can contribute 2 | # (and typically have contributed) code to the repository. 3 | # 4 | # Names should be added to this file only after verifying that 5 | # the individual or the individual's organization has agreed to 6 | # the appropriate CONTRIBUTING.md file. 7 | # 8 | # Names should be added to this file like so: 9 | # Individual's name 10 | # Individual's name 11 | # 12 | # Please keep the list sorted. 13 | # 14 | Julien Riou 15 | Nicolas Payart 16 | Stéphane Lesimple 17 | Wifried Roset 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | -------------------------------------------------------------------------------- /MAINTAINERS: -------------------------------------------------------------------------------- 1 | # This is the official list of the project maintainers. 2 | # This is mostly useful for contributors that want to push 3 | # significant pull requests or for project management issues. 4 | # 5 | # 6 | # Names should be added to this file like so: 7 | # Individual's name 8 | # Individual's name 9 | # 10 | # Please keep the list sorted. 11 | # 12 | Stéphane Lesimple 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Using Ansible SSH Connection through The Bastion 2 | 3 | The three scripts in this directory are a wrapper around Ansible native SSH 4 | connection, so that [The Bastion](https://github.com/ovh/the-bastion/) can be transparently used along with Ansible. 5 | You have to set some os SSH Ansible variables as defined in 6 | https://docs.ansible.com/ansible/latest/plugins/connection/ssh.html in addition 7 | with `BASTION_USER`, `BASTION_PORT` and `BASTION_HOST`. It can also rely on 8 | `ansible-inventory` to identify `bastion_user`, `bastion_host`, `bastion_port`. 9 | `ansible-inventory` takes precedences over environment variables as this will 10 | allow to use different bastion for different hosts. 11 | 12 | ## Simple usage with environment variables 13 | 14 | Ensure the scripts are executable (`chmod +x`) 15 | 16 | ```bash 17 | export BASTION_USER="bastion_user" 18 | export BASTION_HOST="bastion.example.org" 19 | export BASTION_PORT=22 20 | export ANSIBLE_PIPELINING=1 21 | export ANSIBLE_SCP_IF_SSH="True" 22 | export ANSIBLE_PRIVATE_KEY_FILE="${HOME}/.ssh/id_rsa" 23 | export ANSIBLE_SSH_EXECUTABLE="CHANGE_THIS_PATH_TO_THE_PROPER_ONE/sshwrapper.py" 24 | export ANSIBLE_SCP_EXECUTABLE="CHANGE_THIS_PATH_TO_THE_PROPER_ONE/scpbastion.sh" 25 | 26 | ansible all -i hosts -m raw -a uptime 27 | 28 | ansible all -i hosts -m ping 29 | ``` 30 | 31 | ## Leveraging Ansible inventory 32 | 33 | `ansible-inventory` provides access to host's variables. This plugin takes 34 | advantage of this to look for `bastion_*`. 35 | 36 | In the following example all hosts will use the same `your-bastion-user`. The hosts 37 | in `zone_secure` will reach the bastion `your-supersecure-bastion` on port 222 38 | the others hosts will use `your-bastion` on port 22. 39 | 40 | ```yaml 41 | $ grep -ri bastion group_vars/ 42 | group_vars/all.yml:bastion_user: 43 | group_vars/all.yml:bastion_host: 44 | group_vars/all.yml:bastion_port: 22 45 | group_vars/zone_secure.yml:bastion_port: 222 46 | group_vars/zone_secure.yml:bastion_host: 47 | ``` 48 | 49 | For more information have a look at [the official documentation](https://docs.ansible.com/ansible/latest/network/getting_started/first_inventory.html). 50 | 51 | ## Ansible inventory cache 52 | 53 | Because `ansible-inventory` command can be slow, the Ansible inventory results can be saved to a file to speed up 54 | multiple calls with the following environment variables: 55 | * `BASTION_ANSIBLE_INV_CACHE_FILE`: path to the cache file on the filesystem 56 | * `BASTION_ANSIBLE_INV_CACHE_TIMEOUT`: number of seconds before refreshing the cache 57 | 58 | Note: the cache file will not be removed by the wrapper at the end of the run, which means that multiple consecutive runs might use it, as long as it's fresh enough (the expiration of `BASTION_ANSIBLE_INV_CACHE_TIMEOUT` will force a refresh). 59 | 60 | If not set, the cache will not be used, even if `cache` is set at the Ansible level. 61 | 62 | ## Using env vars from a playbook 63 | 64 | In some cases, like the usage of multiple bastions for a single ansible controller and multiple inventory sources, it may be useful to set the vars in the environment configuration from the playbook. 65 | 66 | It can also be combined with the group_vars. 67 | 68 | Example: 69 | ```yaml 70 | --- 71 | - hosts: all 72 | gather_facts: false 73 | environment: 74 | BASTION_USER: "{{ bastion_user }}" 75 | BASTION_HOST: "{{ bastion_host }}" 76 | BASTION_PORT: "{{ bastion_port }}" 77 | tasks: 78 | ... 79 | ``` 80 | 81 | here, each host may have its bastion_X vars defined in group_vars and host_vars. 82 | 83 | If environement vars are not defined, or if the module does not send them, then the sshwrapper is doing a lookup on the ansible-inventory to fetch the bastion_X vars. 84 | 85 | ## Using vars from a config file 86 | 87 | For some use cases (AWX in a non containerised environment for instance), the environment is overridden by the job, and there is no fixed inventory source path. 88 | 89 | So we may not get the vars from the environment nor the inventory. 90 | 91 | In this case, we may use a configuration file to provide the BASTION vars. 92 | 93 | Example: 94 | 95 | ``` 96 | cat /etc/ovh/bastion/config.yml 97 | 98 | --- 99 | bastion_host: "my_great_bastion" 100 | bastion_port: 22 101 | bastion_user: "my_bastion_user" 102 | ``` 103 | 104 | The configuration file is read after checking the environment variables sent in the ssh command line, and will only set them if not defined. 105 | 106 | The location of the configuration file can be set with `BASTION_CONFIG_FILE` 107 | environment variable (defaults to `/etc/ovh/bastion/config.yml`). 108 | 109 | ## Configuration priority 110 | 111 | Source of variables are read in the following order: 112 | * Ansible playbook `environment` 113 | * configuration file 114 | * Ansible inventory 115 | * operating system environment variables 116 | 117 | ## Using multiple inventories sources 118 | 119 | The wrapper is going to lookup the ansible inventory to look for the host and its vars. 120 | 121 | You may define multiple inventories sources in an ENV var. Example: 122 | 123 | ``` 124 | export BASTION_ANSIBLE_INV_OPTIONS='-i my_first_inventory_source -i my_second_inventory_source' 125 | ``` 126 | 127 | ## Using the bastion wrapper with AWX 128 | 129 | When using AWX, the inventory is available as a file in the AWX Execution Environment. 130 | It is then easy and much faster to get the appropriate host from the IP sent by Ansible to the bastion wrapper. 131 | 132 | When AWX usage is detected, the bastion wrapper is going to: 133 | - lookup in the inventory file for the appropriate host 134 | - lookup for the bastion vars in the host_vars 135 | - if not found, run an inventory lookup on the host to get the group_vars too (and execute eventual vars plugins) 136 | 137 | The AWX usage is detected by looking for the inventory file, the default path being "/runner/inventory/hosts" 138 | The path may be changed y setting an "AWX_RUN_DIR" environment variable on the AWX worker. 139 | Ex on a AWX k8s instance group: 140 | ``` 141 | env: 142 | - name: "AWX_RUN_DIR" 143 | value: "/my_folder/my_sub_folder" 144 | ``` 145 | The inventory file will be looked up at "/my_folder/my_sub_folder/inventory/hosts" 146 | 147 | ## Connection via SSH 148 | 149 | The wrapper can be configured using `ansible.cfg` file as follow: 150 | 151 | ```ini 152 | [ssh_connection] 153 | pipelining = True 154 | ssh_executable = ./extra/bastion/sshwrapper.py 155 | ``` 156 | 157 | Or by using the `ANSIBLE_SSH_PIPELINING` and `ANSIBLE_SSH_EXECUTABLE` 158 | environment variables. 159 | 160 | ## File transfer using SFTP 161 | 162 | By default, Ansible uses SFTP to copy files. The executable should be defined 163 | as follow in the ansible.cfg file: 164 | 165 | ```ini 166 | [ssh_connection] 167 | transfer_method = sftp 168 | sftp_executable = ./extra/bastion/sftpbastion.sh 169 | ``` 170 | 171 | Or by using the `ANSIBLE_SFTP_EXECUTABLE` environment variable. 172 | 173 | ## File transfer using SCP (deprecated) 174 | 175 | The SCP protocol is still allowed but will soon deprecated by OpenSSH. You 176 | should consider using SFTP instead. If you still want to use the SCP protocol, 177 | you can define the method and executable as follow: 178 | 179 | File ansible.cfg: 180 | 181 | ```ini 182 | [ssh_connection] 183 | transfer_method = scp 184 | scp_if_ssh = True # Ansible < 2.17 185 | scp_extra_args = -O # OpenSSH >= 9.0 186 | scp_executable = ./extra/bastion/scpbastion.sh 187 | ``` 188 | 189 | Or by using the following environment variables: 190 | * `ANSIBLE_SCP_IF_SSH` 191 | * `ANSIBLE_SSH_TRANSFER_METHOD` 192 | * `ANSIBLE_SCP_EXTRA_ARGS` 193 | * `ANSIBLE_SCP_EXECUTABLE` 194 | 195 | ## Configuration example 196 | 197 | File ansible.cfg: 198 | 199 | ```ini 200 | [ssh_connection] 201 | pipelining = True 202 | ssh_executable = ./extra/bastion/sshwrapper.py 203 | sftp_executable = ./extra/bastion/sftpbastion.sh 204 | ``` 205 | 206 | ## Integration via submodule 207 | 208 | You can include this repository as a submodule in your playbook repository 209 | 210 | ```bash 211 | git submodule add https://github.com/ovh/the-bastion-ansible-wrapper.git extra/bastion 212 | ``` 213 | 214 | ## Requirements 215 | 216 | This has been tested with 217 | 218 | * Ansible 2.9.6 219 | * Python 3.7.3 220 | * SSH OpenSSH_7.9p1 Debian-10+deb10u2, OpenSSL 1.1.1d 221 | 222 | ## Debug 223 | 224 | If this doesn't seem to work, run your ansible with `-vvvv`, you'll see whether it actually attempts to use the wrappers or not. 225 | 226 | ## Lint 227 | 228 | Just use [pre-commit](https://pre-commit.com/). 229 | 230 | TLDR: 231 | * pip install --user pre-commit 232 | * pre-commit install 233 | * git commit 234 | 235 | # Related 236 | 237 | - [The Bastion](https://github.com/ovh/the-bastion) - Authentication, authorization, traceability and auditability for SSH accesses. 238 | 239 | # License 240 | 241 | Copyright OVH SAS 242 | 243 | Licensed under the Apache License, Version 2.0 (the "License"); 244 | you may not use this file except in compliance with the License. 245 | You may obtain a copy of the License at 246 | 247 | http://www.apache.org/licenses/LICENSE-2.0 248 | 249 | Unless required by applicable law or agreed to in writing, software 250 | distributed under the License is distributed on an "AS IS" BASIS, 251 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 252 | See the License for the specific language governing permissions and 253 | limitations under the License. 254 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ovh/the-bastion-ansible-wrapper/897f9dd9b2930f3ca4da6df56705c6edf3d409ac/__init__.py -------------------------------------------------------------------------------- /examples/config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | bastion_host: "my_great_bastion" 3 | bastion_port: 22 4 | bastion_user: "my_bastion_user" 5 | -------------------------------------------------------------------------------- /lib.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import os 4 | import subprocess 5 | import time 6 | 7 | from yaml import YAMLError, safe_load 8 | 9 | 10 | def find_executable(executable, path=None): 11 | """Find the absolute path of an executable 12 | 13 | :return: path 14 | :rtype: str 15 | """ 16 | _, ext = os.path.splitext(executable) 17 | 18 | if os.path.isfile(executable): 19 | return executable 20 | 21 | if path is None: 22 | path = os.environ.get("PATH", os.defpath) 23 | 24 | for p in path.split(os.pathsep): 25 | f = os.path.join(p, executable) 26 | if os.path.isfile(f): 27 | return f 28 | 29 | 30 | def get_inventory(): 31 | """Fetch ansible-inventory --list 32 | 33 | :return: inventory 34 | :rtype: dict 35 | """ 36 | inventory_cmd = find_executable("ansible-inventory") 37 | if not inventory_cmd: 38 | raise Exception("Failed to identify path of ansible-inventory") 39 | 40 | inventory = None 41 | 42 | # read and invalidate the inventory cache file 43 | cache_file = os.environ.get("BASTION_ANSIBLE_INV_CACHE_FILE") 44 | if cache_file: 45 | cache = get_inventory_from_cache( 46 | cache_file=cache_file, 47 | cache_timeout=int(os.environ.get("BASTION_ANSIBLE_INV_CACHE_TIMEOUT", 60)), 48 | ) 49 | if cache: 50 | inventory = cache.get("inventory") 51 | 52 | if inventory: 53 | return inventory 54 | 55 | # ex : export BASTION_ANSIBLE_INV_OPTIONS="-i my_inventory -i my_second_inventory" 56 | inventory_options = os.environ.get("BASTION_ANSIBLE_INV_OPTIONS", "") 57 | 58 | command = "{} {} --list".format(inventory_cmd, inventory_options) 59 | inventory = get_inv_from_command(command) 60 | if cache_file: 61 | write_inventory_to_cache(cache_file=cache_file, inventory=inventory) 62 | 63 | return inventory 64 | 65 | 66 | def get_inventory_from_cache(cache_file, cache_timeout): 67 | """Read ansible-inventory from cache file 68 | 69 | :return: Inventory cache with `updated_at` (to expire the cache) and 70 | `inventory` (results of `ansible-inventory` command) keys. 71 | :rtype: dict 72 | """ 73 | try: 74 | # Load JSON from cache file 75 | with open(cache_file, "r") as fd: 76 | cache = json.load(fd) 77 | except IOError: 78 | # File does not exist or path is incorrect 79 | return None 80 | except: 81 | # Invalid JSON or any other error 82 | pass 83 | else: 84 | # Check cache expiry 85 | if cache.get("updated_at", 0) >= int(time.time()) - cache_timeout: 86 | return cache 87 | 88 | # Cache expired or any other error 89 | try: 90 | os.remove(cache_file) 91 | except: 92 | pass 93 | 94 | return None 95 | 96 | 97 | def write_inventory_to_cache(cache_file, inventory): 98 | """Write inventory with last update time to a cache file""" 99 | cache = {"inventory": inventory, "updated_at": int(time.time())} 100 | with open(cache_file, "w") as fd: 101 | json.dump(cache, fd) 102 | 103 | 104 | def get_hostvars(host) -> dict: 105 | """Fetch hostvars for the given host 106 | 107 | Ansible either uses the "ansible_host" inventory variable or the hostname. 108 | Fetch inventory and browse all hostvars to return only the ones for the host. 109 | 110 | :return: hostvars 111 | :rtype: dict 112 | """ 113 | inventory = get_inventory() 114 | all_hostvars = inventory.get("_meta", {}).get("hostvars", {}) 115 | for inventory_host, hostvars in all_hostvars.items(): 116 | if inventory_host == host or hostvars.get("ansible_host") == host: 117 | return hostvars 118 | # Host not found 119 | return {} 120 | 121 | 122 | def manage_conf_file(conf_file, bastion_host, bastion_port, bastion_user): 123 | """Fetch the bastion vars from a config file. 124 | 125 | There will be set if not already defined, and before looking in the ansible inventory 126 | 127 | """ 128 | 129 | if os.path.exists(conf_file): 130 | try: 131 | with open(conf_file, "r") as f: 132 | yaml_conf = safe_load(f) 133 | 134 | if not bastion_host: 135 | bastion_host = yaml_conf.get("bastion_host") 136 | if not bastion_port: 137 | bastion_port = yaml_conf.get("bastion_port") 138 | if not bastion_user: 139 | bastion_user = yaml_conf.get("bastion_user") 140 | 141 | except (YAMLError, IOError) as e: 142 | print("Error loading yaml file: {}".format(e)) 143 | 144 | return bastion_host, bastion_port, bastion_user 145 | 146 | 147 | def get_var_within(my_value, hostvar, check_list=None): 148 | """If a value is a jinja2 var, try to resolve it in the hostvars 149 | 150 | Ex: 151 | "my_value" == {{ my_jinja2_var }} 152 | "my_jinja2_var" == "foo" 153 | 154 | Will return "foo" for "my_value" 155 | 156 | """ 157 | # keep track of parsed values 158 | # we want to avoid: 159 | # bastion_host == {{ foo }} 160 | # foo == {{ bastion_host }} 161 | if check_list is None: 162 | check_list = [] 163 | 164 | if ( 165 | isinstance(my_value, str) 166 | and my_value.startswith("{{") 167 | and my_value.endswith("}}") 168 | ): 169 | # ex: {{ my_jinja2_var }} -> lookup for 'my_jinja2_var' in hostvars 170 | key_name = my_value.replace("{{", "").replace("}}", "").strip() 171 | 172 | if key_name not in check_list: 173 | check_list.append(key_name) 174 | # resolve intricated vars 175 | return get_var_within( 176 | hostvar.get(key_name, ""), hostvar, check_list=check_list 177 | ) 178 | else: 179 | return "" 180 | 181 | return my_value 182 | 183 | 184 | def get_inv_from_command(command): 185 | p = subprocess.Popen( 186 | command, 187 | shell=True, 188 | stdin=subprocess.PIPE, 189 | stdout=subprocess.PIPE, 190 | stderr=subprocess.PIPE, 191 | ) 192 | output, error = p.communicate() 193 | if isinstance(output, bytes): 194 | output = output.decode() 195 | if not p.returncode: 196 | inventory = json.loads(output) 197 | return inventory 198 | else: 199 | logging.error(error) 200 | raise Exception("failed to get inventory") 201 | 202 | 203 | def awx_get_inventory_file(): 204 | # awx execution environment run dir, where the project and inventory are copied 205 | default_run_dir = "/runner" 206 | run_dir = os.environ.get("AWX_RUN_DIR", default_run_dir) 207 | return "{}/inventory/hosts".format(run_dir) 208 | 209 | 210 | def awx_get_vars(host_ip, inventory_file): 211 | # the inventory file is a script that print the inventory in json format 212 | inv = get_inv_from_command(inventory_file) 213 | 214 | # the ssh command sent only the IP to the ansible bastion wrapper. 215 | # We are looking for the host which "ansible_host" has the same ip, then try to fetch the required vars from 216 | # its host_vars 217 | host = None 218 | for k, v in inv.get("_meta", {}).get("hostvars", {}).items(): 219 | if v.get("ansible_host") == host_ip: 220 | host = k 221 | host_vars = v 222 | break 223 | 224 | # this should not happen 225 | if not host: 226 | return {} 227 | 228 | bastion_vars = get_bastion_vars(host_vars) 229 | 230 | if None not in [ 231 | bastion_vars.get("bastion_host"), 232 | bastion_vars.get("bastion_port"), 233 | bastion_vars.get("bastion_user"), 234 | ]: 235 | return bastion_vars 236 | 237 | # if some bastion vars are missing, maybe they are defined as group_vars. 238 | # We do an inventory lookup to get them. 239 | # With AWX no need to list the whole inventory, we already know the host 240 | command = "ansible-inventory -i {} --host {}".format(inventory_file, host) 241 | return get_inv_from_command(command) 242 | 243 | 244 | def get_bastion_vars(host_vars): 245 | bastion_host = host_vars.get("bastion_host") 246 | bastion_user = host_vars.get("bastion_user") 247 | bastion_port = host_vars.get("bastion_port") 248 | return { 249 | "bastion_host": bastion_host, 250 | "bastion_port": bastion_port, 251 | "bastion_user": bastion_user, 252 | } 253 | -------------------------------------------------------------------------------- /scpbastion.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | exec scp -S $(dirname $0)/scpwrapper.py "$@" 3 | -------------------------------------------------------------------------------- /scpwrapper.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import getpass 4 | import os 5 | import sys 6 | 7 | from lib import find_executable, get_hostvars, manage_conf_file 8 | 9 | 10 | def main(): 11 | argv = list(sys.argv[1:]) # Copy 12 | 13 | bastion_user = None 14 | bastion_host = None 15 | bastion_port = None 16 | remote_user = None 17 | remote_port = 22 18 | default_configuration_file = "/etc/ovh/bastion/config.yml" 19 | 20 | iteration = enumerate(argv) 21 | sshcmdline = [] 22 | for i, e in iteration: 23 | if e == "-l": 24 | remote_user = argv[i + 1] 25 | next(iteration) 26 | elif e == "-p": 27 | remote_port = argv[i + 1] 28 | next(iteration) 29 | elif e == "-o" and argv[i + 1].startswith("User="): 30 | remote_user = argv[i + 1].split("=")[-1] 31 | next(iteration) 32 | elif e == "-o" and argv[i + 1].startswith("Port="): 33 | remote_port = argv[i + 1].split("=")[-1] 34 | next(iteration) 35 | elif e == "--": 36 | sshcmdline.extend(argv[i + 1 :]) 37 | break 38 | else: 39 | sshcmdline.append(e) 40 | 41 | scpcmd = sshcmdline.pop() 42 | host = sshcmdline.pop() 43 | scpcmd = scpcmd.replace("#", "##").replace(" ", "#") 44 | 45 | # check if bastion_vars are passed as env vars in the playbook 46 | # may be usefull if the ansible controller manage many bastions 47 | for i in list(scpcmd.split(" ")): 48 | if "bastion_user" in i.lower(): 49 | bastion_user = i.split("=")[1] 50 | elif "bastion_host" in i.lower(): 51 | bastion_host = i.split("=")[1] 52 | elif "bastion_port" in i.lower(): 53 | bastion_port = i.split("=")[1] 54 | 55 | # read from configuration file 56 | if not bastion_host or not bastion_port or not bastion_user: 57 | bastion_host, bastion_port, bastion_user = manage_conf_file( 58 | os.getenv("BASTION_CONF_FILE", default_configuration_file), 59 | bastion_host, 60 | bastion_port, 61 | bastion_user, 62 | ) 63 | 64 | # lookup on the inventory may take some time, depending on the source, so use it only if not defined elsewhere 65 | # it seems like some module like template does not send env vars too... 66 | if not bastion_host or not bastion_port or not bastion_user: 67 | hostvar = get_hostvars(host) # dict 68 | 69 | bastion_port = hostvar.get("bastion_port", os.environ.get("BASTION_PORT", 22)) 70 | bastion_user = hostvar.get( 71 | "bastion_user", os.environ.get("BASTION_USER", getpass.getuser()) 72 | ) 73 | bastion_host = hostvar.get("bastion_host", os.environ.get("BASTION_HOST")) 74 | 75 | # syscall exec 76 | args = ( 77 | [ 78 | "ssh", 79 | "{}@{}".format(bastion_user, bastion_host), 80 | "-p", 81 | bastion_port, 82 | "-o", 83 | "StrictHostKeyChecking=no", 84 | "-T", 85 | ] 86 | + sshcmdline 87 | + [ 88 | "--", 89 | "--user", 90 | remote_user, 91 | "--port", 92 | remote_port, 93 | "--host", 94 | host, 95 | "--osh", 96 | "scp", 97 | "--scp-cmd", 98 | scpcmd, 99 | ] 100 | ) 101 | 102 | os.execv( 103 | find_executable("ssh"), # absolute path mandatory 104 | [str(e).strip() for e in args], # execv() arg 2 must contain only strings 105 | ) 106 | 107 | 108 | if __name__ == "__main__": 109 | main() 110 | -------------------------------------------------------------------------------- /sftpbastion.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | exec sftp -S $(dirname $0)/sftpwrapper.py "$@" 3 | -------------------------------------------------------------------------------- /sftpwrapper.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import getpass 4 | import os 5 | import sys 6 | 7 | from lib import find_executable, get_hostvars, manage_conf_file 8 | 9 | 10 | def main(): 11 | argv = list(sys.argv[1:]) 12 | 13 | bastion_user = None 14 | bastion_host = None 15 | bastion_port = None 16 | remote_user = None 17 | remote_port = 22 18 | default_configuration_file = "/etc/ovh/bastion/config.yml" 19 | 20 | iteration = enumerate(argv) 21 | for i, e in iteration: 22 | if e == "-o" and argv[i + 1].startswith("User="): 23 | remote_user = argv[i + 1].split("=")[-1] 24 | next(iteration) 25 | elif e == "-o" and argv[i + 1].startswith("Port="): 26 | remote_port = argv[i + 1].split("=")[-1] 27 | next(iteration) 28 | 29 | sftpcmd = argv.pop() 30 | host = argv.pop() 31 | 32 | # Playbook environment variables are not pushed to the sftp wrapper 33 | # Skipping this source of configuration 34 | 35 | # Read from configuration file 36 | bastion_host, bastion_port, bastion_user = manage_conf_file( 37 | os.getenv("BASTION_CONF_FILE", default_configuration_file), 38 | bastion_host, 39 | bastion_port, 40 | bastion_user, 41 | ) 42 | 43 | # Read from inventory and environment variables 44 | if not bastion_host or not bastion_port or not bastion_user: 45 | inventory = get_hostvars(host) 46 | bastion_port = inventory.get("bastion_port", os.getenv("BASTION_PORT", 22)) 47 | bastion_user = inventory.get( 48 | "bastion_user", os.getenv("BASTION_USER", getpass.getuser()) 49 | ) 50 | bastion_host = inventory.get("bastion_host", os.getenv("BASTION_HOST")) 51 | 52 | args = [ 53 | "ssh", 54 | "{}@{}".format(bastion_user, bastion_host), 55 | "-p", 56 | bastion_port, 57 | "-o", 58 | "StrictHostKeyChecking=no", 59 | "-T", 60 | "--", 61 | "--user", 62 | remote_user, 63 | "--port", 64 | remote_port, 65 | "--host", 66 | host, 67 | "--osh", 68 | "sftp", 69 | ] 70 | 71 | os.execv( 72 | find_executable("ssh"), 73 | [str(e).strip() for e in args], 74 | ) 75 | 76 | 77 | if __name__ == "__main__": 78 | main() 79 | -------------------------------------------------------------------------------- /sshwrapper.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import getpass 4 | import os 5 | import sys 6 | 7 | from lib import ( 8 | awx_get_inventory_file, 9 | awx_get_vars, 10 | find_executable, 11 | get_hostvars, 12 | get_var_within, 13 | manage_conf_file, 14 | ) 15 | 16 | 17 | def main(): 18 | argv = list(sys.argv[1:]) # Copy 19 | 20 | bastion_user = None 21 | bastion_host = None 22 | bastion_port = None 23 | remote_user = None 24 | remote_port = 22 25 | default_configuration_file = "/etc/ovh/bastion/config.yml" 26 | 27 | cmd = argv.pop() 28 | host = argv.pop() 29 | 30 | # check if bastion_vars are passed as env vars in the playbook 31 | # may be usefull if the ansible controller manage many bastions 32 | # example : 33 | # - hosts: all 34 | # gather_facts: false 35 | # environment: 36 | # BASTION_USER: "{{ bastion_user }}" 37 | # BASTION_HOST: "{{ bastion_host }}" 38 | # BASTION_PORT: "{{ bastion_port }}" 39 | # 40 | # will result as : ... '/bin/sh -c '"'"'BASTION_USER=my_bastion_user BASTION_HOST=my_bastion_host BASTION_PORT=22 /usr/bin/python3 && sleep 0'"'"'' 41 | for i in list(cmd.split(" ")): 42 | if "bastion_user" in i.lower(): 43 | bastion_user = i.split("=")[1] 44 | elif "bastion_host" in i.lower(): 45 | bastion_host = i.split("=")[1] 46 | elif "bastion_port" in i.lower(): 47 | bastion_port = i.split("=")[1] 48 | 49 | # in some cases (AWX in a non containerised environment for instance), the environment is overridden by the job 50 | # so we are not able to get the BASTION vars 51 | # if some vars are still undefined, try to load them from a configuration file 52 | if not bastion_host or not bastion_port or not bastion_user: 53 | bastion_host, bastion_port, bastion_user = manage_conf_file( 54 | os.environ.get("BASTION_CONF_FILE", default_configuration_file), 55 | bastion_host, 56 | bastion_port, 57 | bastion_user, 58 | ) 59 | 60 | # lookup on the inventory may take some time, depending on the source, so use it only if not defined elsewhere 61 | # it seems like some module like template does not send env vars too... 62 | if not bastion_host or not bastion_port or not bastion_user: 63 | 64 | # check if running on AWX, we'll get the vars in a different way 65 | awx_inventory_file = awx_get_inventory_file() 66 | if os.path.exists(awx_inventory_file): 67 | hostvar = awx_get_vars(host, awx_inventory_file) 68 | else: 69 | hostvar = get_hostvars(host) # dict 70 | 71 | # manage the case where a bastion var is defined from another var 72 | # Ex: bastion_host = {{ my_bastion_host }} 73 | bastion_port = get_var_within( 74 | hostvar.get("bastion_port", os.environ.get("BASTION_PORT", 22)), hostvar 75 | ) 76 | bastion_user = get_var_within( 77 | hostvar.get( 78 | "bastion_user", os.environ.get("BASTION_USER", getpass.getuser()) 79 | ), 80 | hostvar, 81 | ) 82 | bastion_host = get_var_within( 83 | hostvar.get("bastion_host", os.environ.get("BASTION_HOST")), hostvar 84 | ) 85 | 86 | for i, e in enumerate(argv): 87 | 88 | if e.startswith("User="): 89 | remote_user = e.split("=")[-1] 90 | argv[i] = "User={}".format(bastion_user) 91 | elif e.startswith("Port="): 92 | remote_port = e.split("=")[-1] 93 | argv[i] = "Port={}".format(bastion_port) 94 | 95 | # syscall exec 96 | args = ( 97 | [ 98 | "ssh", 99 | "-p", 100 | bastion_port, 101 | "-q", 102 | "-o", 103 | "StrictHostKeyChecking=no", 104 | "-l", 105 | bastion_user, 106 | bastion_host, 107 | "-T", 108 | ] 109 | + argv 110 | + [ 111 | "--", 112 | "-q", 113 | "-T", 114 | "--never-escape", 115 | "--user", 116 | remote_user, 117 | "--port", 118 | remote_port, 119 | host, 120 | "--", 121 | cmd, 122 | ] 123 | ) 124 | os.execv( 125 | find_executable("ssh"), # full path mandatory 126 | [str(e).strip() for e in args], # execv() arg 2 must contain only strings 127 | ) 128 | 129 | 130 | if __name__ == "__main__": 131 | main() 132 | -------------------------------------------------------------------------------- /tests.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from yaml import dump 4 | 5 | from lib import ( 6 | awx_get_inventory_file, 7 | get_bastion_vars, 8 | get_var_within, 9 | manage_conf_file, 10 | ) 11 | 12 | BASTION_HOST = "my_bastion" 13 | BASTION_PORT = 22 14 | BASTION_USER = "my_bastion_user" 15 | BASTION_CONF_FILE = "/tmp/test_bastion_conf_file.yml" 16 | 17 | 18 | def test_manage_conf_file_bastion_host_undefined(): 19 | bastion_host, bastion_port, bastion_user = manage_conf_file( 20 | BASTION_CONF_FILE, None, BASTION_PORT, BASTION_USER 21 | ) 22 | assert bastion_host == BASTION_HOST 23 | 24 | 25 | def test_manage_conf_file_bastion_port_undefined(): 26 | bastion_host, bastion_port, bastion_user = manage_conf_file( 27 | BASTION_CONF_FILE, BASTION_HOST, None, BASTION_USER 28 | ) 29 | assert bastion_port == BASTION_PORT 30 | 31 | 32 | def test_manage_conf_file_bastion_user_undefined(): 33 | bastion_host, bastion_port, bastion_user = manage_conf_file( 34 | BASTION_CONF_FILE, BASTION_HOST, BASTION_PORT, None 35 | ) 36 | assert bastion_user == BASTION_USER 37 | 38 | 39 | def test_manage_conf_file_bastion_all_undefined(): 40 | write_conf_file(BASTION_CONF_FILE) 41 | bastion_host, bastion_port, bastion_user = manage_conf_file( 42 | BASTION_CONF_FILE, None, None, None 43 | ) 44 | assert bastion_user == BASTION_USER 45 | assert bastion_port == BASTION_PORT 46 | assert bastion_host == BASTION_HOST 47 | 48 | 49 | def write_conf_file(conf_file): 50 | with open(conf_file, "w") as f: 51 | 52 | data = { 53 | "bastion_host": BASTION_HOST, 54 | "bastion_port": BASTION_PORT, 55 | "bastion_user": BASTION_USER, 56 | } 57 | 58 | dump(data, f) 59 | 60 | 61 | write_conf_file(BASTION_CONF_FILE) 62 | 63 | 64 | def test_get_var_within_one_level(): 65 | hostvars = {"bastion_host": "{{ bastion_fqdn }}", "bastion_fqdn": "my_real_bastion"} 66 | bastion_host = get_var_within(hostvars["bastion_host"], hostvars) 67 | assert bastion_host == hostvars["bastion_fqdn"] 68 | 69 | 70 | def test_get_var_within_two_levels(): 71 | hostvars = { 72 | "bastion_host": "{{ bastion_fqdn }}", 73 | "bastion_fqdn": "{{ my_other_var }}", 74 | "my_other_var": "my_real_bastion", 75 | } 76 | bastion_host = get_var_within(hostvars["bastion_host"], hostvars) 77 | assert bastion_host == hostvars["my_other_var"] 78 | 79 | 80 | def test_get_var_within_not_found(): 81 | hostvars = {"bastion_host": "{{ bastion_fqdn }}"} 82 | bastion_host = get_var_within(hostvars["bastion_host"], hostvars) 83 | assert not bastion_host 84 | 85 | 86 | def test_get_var_within_infinite(): 87 | hostvars = { 88 | "bastion_host": "{{ bastion_fqdn }}", 89 | "bastion_fqdn": "{{ bastion_host }}", 90 | } 91 | bastion_host = get_var_within(hostvars["bastion_host"], hostvars) 92 | assert not bastion_host 93 | 94 | 95 | def test_get_var_not_a_jinja2_var(): 96 | hostvars = {"bastion_host": "{{ bastion_fqdn"} 97 | bastion_host = get_var_within(hostvars["bastion_host"], hostvars) 98 | assert bastion_host == hostvars["bastion_host"] 99 | 100 | 101 | def test_get_var_not_a_string(): 102 | hostvars = {"bastion_host": 68} 103 | bastion_host = get_var_within(hostvars["bastion_host"], hostvars) 104 | assert bastion_host == hostvars["bastion_host"] 105 | 106 | 107 | def test_awx_get_inventory_file_default(): 108 | assert awx_get_inventory_file() == "/runner/inventory/hosts" 109 | 110 | 111 | def test_awx_get_inventory_file_env_defined(): 112 | env_path = "/my_awx" 113 | os.environ["AWX_RUN_DIR"] = env_path 114 | assert awx_get_inventory_file() == f"{env_path}/inventory/hosts" 115 | os.environ.pop("AWX_RUN_DIR") 116 | 117 | 118 | def test_get_bastion_vars(): 119 | host_vars = { 120 | "bastion_port": BASTION_PORT, 121 | "bastion_host": BASTION_HOST, 122 | "bastion_user": BASTION_USER, 123 | } 124 | bastion_vars = get_bastion_vars(host_vars) 125 | assert ( 126 | bastion_vars["bastion_port"] == BASTION_PORT 127 | and bastion_vars["bastion_host"] == BASTION_HOST 128 | and bastion_vars["bastion_user"] == BASTION_USER 129 | ) 130 | 131 | 132 | def test_get_bastion_vars_not_full(): 133 | host_vars = {"bastion_port": BASTION_PORT, "bastion_user": BASTION_USER} 134 | bastion_vars = get_bastion_vars(host_vars) 135 | assert not bastion_vars["bastion_host"] 136 | --------------------------------------------------------------------------------