├── paternoster ├── test │ ├── __init__.py │ ├── mockrunner.py │ ├── test_paternoster.py │ ├── test_root.py │ ├── test_ansible_runner.py │ ├── test_types.py │ ├── test_parameters.py │ └── test_prompt.py ├── runners │ ├── __init__.py │ └── ansiblerunner.py ├── __init__.py ├── shebang.py ├── root.py ├── types │ └── __init__.py └── paternoster.py ├── requirements ├── py2.txt ├── lint.txt ├── dev.txt └── test.txt ├── requirements.txt ├── logo.png ├── .coveragerc ├── .travis.yml ├── .pypirc ├── .editorconfig ├── ansible.cfg ├── vagrant ├── templates │ └── sudoers.j2 ├── files │ └── scripts │ │ └── uberspace-add-domain ├── tests │ ├── test_variables.yml │ ├── test_ymlapi.yml │ ├── test_everything.yml │ ├── drop_script.yml │ ├── test_become_root_symlink.yml │ ├── test_become_root.yml │ └── test_exploit_ansiblecfg.yml ├── site.yml └── run_integration_tests.py ├── Vagrantfile ├── .pre-commit-config.yaml ├── tox.ini ├── .gitignore ├── LICENSE.txt ├── setup.py ├── logo.svg ├── doc └── script_development.md └── README.md /paternoster/test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /paternoster/runners/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements/py2.txt: -------------------------------------------------------------------------------- 1 | mock 2 | -------------------------------------------------------------------------------- /requirements/lint.txt: -------------------------------------------------------------------------------- 1 | pre-commit 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -r requirements/dev.txt 2 | -------------------------------------------------------------------------------- /requirements/dev.txt: -------------------------------------------------------------------------------- 1 | tox 2 | -r lint.txt 3 | -------------------------------------------------------------------------------- /requirements/test.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | pytest-cov 3 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Uberspace/paternoster/HEAD/logo.png -------------------------------------------------------------------------------- /paternoster/__init__.py: -------------------------------------------------------------------------------- 1 | from .paternoster import Paternoster # noqa F401 2 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | omit = 4 | paternoster/test/* 5 | paternoster/root.py 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "3.6" 4 | install: pip -q install tox 5 | script: tox 6 | -------------------------------------------------------------------------------- /.pypirc: -------------------------------------------------------------------------------- 1 | [distutils] 2 | index-servers=pypi 3 | 4 | [pypi] 5 | repository = https://upload.pypi.org/legacy/ 6 | username = uberspace 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | # Unix-style newlines with a newline ending every file 4 | [*] 5 | end_of_line = lf 6 | insert_final_newline = true 7 | 8 | [*.py] 9 | indent_style = space 10 | indent_size = 4 11 | -------------------------------------------------------------------------------- /ansible.cfg: -------------------------------------------------------------------------------- 1 | [defaults] 2 | hostfile=.vagrant/provisioners/ansible/inventory/vagrant_ansible_inventory 3 | host_key_checking = False 4 | gathering = smart 5 | retry_files_enabled = False 6 | nocows=1 7 | 8 | [ssh_connection] 9 | pipelining = True 10 | -------------------------------------------------------------------------------- /paternoster/test/mockrunner.py: -------------------------------------------------------------------------------- 1 | class MockRunner: 2 | def __init__(self, result=True): 3 | self._result = result 4 | 5 | def run(self, *args, **kwargs): 6 | self.args = args 7 | self.kwargs = kwargs 8 | return self._result 9 | -------------------------------------------------------------------------------- /vagrant/templates/sudoers.j2: -------------------------------------------------------------------------------- 1 | # do not ever do this in production. this is just to allow 2 | # the unittests to use _any_ user. On a production system 3 | # this can have serious security consequences! 4 | 5 | ALL ALL=(ALL) NOPASSWD: /usr/local/bin/{{ item|basename }} 6 | -------------------------------------------------------------------------------- /Vagrantfile: -------------------------------------------------------------------------------- 1 | # -*- mode: ruby -*- 2 | # vi: set ft=ruby : 3 | 4 | Vagrant.configure("2") do |config| 5 | config.vm.box = "geerlingguy/centos7" 6 | 7 | config.vm.provision "ansible" do |ansible| 8 | ansible.playbook = "vagrant/site.yml" 9 | end 10 | 11 | if Vagrant.has_plugin?("vagrant-vbguest") 12 | config.vbguest.no_install = true 13 | config.vbguest.auto_update = false 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /paternoster/test/test_paternoster.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import pytest 3 | 4 | from .mockrunner import MockRunner 5 | 6 | 7 | @pytest.mark.parametrize("status,rc", [ 8 | (True, 0), 9 | (False, 1), 10 | ]) 11 | def test_auto_returncode(status, rc): 12 | from ..paternoster import Paternoster 13 | 14 | p = Paternoster( 15 | runner_parameters={'result': status}, 16 | parameters=[], runner_class=MockRunner 17 | ) 18 | 19 | with pytest.raises(SystemExit) as excinfo: 20 | p.auto() 21 | 22 | assert excinfo.value.code == rc 23 | -------------------------------------------------------------------------------- /vagrant/files/scripts/uberspace-add-domain: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env paternoster 2 | 3 | - hosts: paternoster 4 | vars: 5 | parameters: 6 | - name: domain 7 | short: d 8 | help: this is the domain to add to your uberspace 9 | type: paternoster.types.domain 10 | required: yes 11 | - name: webserver 12 | short: w 13 | help: add domain to the webserver configuration 14 | action: store_const 15 | dest: type 16 | const: mail 17 | - name: mailserver 18 | short: m 19 | help: add domain to the mailserver configuration 20 | action: store_const 21 | dest: type 22 | const: mail 23 | 24 | - name: test play 25 | hosts: all 26 | tasks: 27 | - debug: msg="Trying to add '{{ param_domain }}' to your space..." 28 | -------------------------------------------------------------------------------- /paternoster/test/test_root.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | import pwd 4 | 5 | import pytest 6 | 7 | 8 | @pytest.mark.parametrize("sudo_user,valid", [ 9 | ('aa', True), 10 | ('b22', True), 11 | ('abbbbb4', True), 12 | ('ab_a', False), 13 | ('a' * 30, False), 14 | ('a' * 30 + '22', False), 15 | ('22', False), 16 | ('a22\n2', False), 17 | ('\x01aaa', False), 18 | ('aaa\x01', False), 19 | ]) 20 | def test_type_domain(sudo_user, valid): 21 | from ..root import become_user 22 | 23 | current_user = pwd.getpwuid(os.getuid()).pw_name 24 | os.environ['SUDO_USER'] = sudo_user 25 | 26 | if not valid: 27 | with pytest.raises(ValueError): 28 | become_user(current_user) 29 | else: 30 | rtn_sudo_user = become_user(current_user) 31 | assert rtn_sudo_user == sudo_user 32 | -------------------------------------------------------------------------------- /vagrant/tests/test_variables.yml: -------------------------------------------------------------------------------- 1 | - name: test script_name and sudo_user variables 2 | hosts: all 3 | gather_facts: no 4 | tasks: 5 | - include: drop_script.yml 6 | vars: 7 | playbook: | 8 | - hosts: all 9 | tasks: 10 | - debug: var=script_name 11 | - debug: var=sudo_user 12 | script: | 13 | #!/bin/env python2.7 14 | 15 | import paternoster 16 | 17 | s = paternoster.Paternoster( 18 | runner_parameters={'playbook': '/opt/uberspace/playbooks/uberspace-unittest.yml'}, 19 | parameters=[], 20 | become_user='root', 21 | ).auto() 22 | 23 | - assert: 24 | that: 25 | - "script.stdout_lines[0] == 'uberspace-unittest'" 26 | - "script.stdout_lines[1] == 'vagrant'" 27 | # "--parameters" are tested using normal unittests 28 | -------------------------------------------------------------------------------- /vagrant/tests/test_ymlapi.yml: -------------------------------------------------------------------------------- 1 | - name: test parameter passing with sudo in one go 2 | hosts: all 3 | gather_facts: no 4 | tasks: 5 | - include: drop_script.yml 6 | vars: 7 | script_params: -u aaaa 8 | script: | 9 | #!/usr/bin/env paternoster 10 | - hosts: paternoster 11 | vars: 12 | success_msg: 42 13 | parameters: 14 | - name: username 15 | short: u 16 | help: "name of the user to create" 17 | type: paternoster.types.restricted_str 18 | required: yes 19 | type_params: 20 | regex: '^[a-z]+$' 21 | 22 | - hosts: localhost 23 | tasks: 24 | - debug: msg="creating user {%raw%}{{ param_username }}{%endraw%}" 25 | 26 | - assert: 27 | that: 28 | - "script.stdout_lines[0] == 'creating user aaaa'" 29 | - "script.stdout_lines[1] == '42'" 30 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | default_language_version: 3 | python: python3 4 | repos: 5 | - repo: https://github.com/pre-commit/pre-commit-hooks 6 | rev: master 7 | hooks: 8 | # Generall Stuff 9 | - id: trailing-whitespace 10 | - id: mixed-line-ending 11 | args: [--fix=lf] 12 | - id: end-of-file-fixer 13 | exclude: "^(.bumpversion.cfg|CHANGELOG.rst)$" 14 | # VCS 15 | - id: check-merge-conflict 16 | # Config / Data Files 17 | - id: check-yaml 18 | # Python 19 | - id: debug-statements 20 | # Python: flakes8 (syntax check with pyflakes only) 21 | - repo: https://gitlab.com/pycqa/flake8 22 | rev: master 23 | hooks: 24 | - id: flake8 25 | # Python: reorder imports 26 | - repo: https://github.com/asottile/reorder_python_imports 27 | rev: master 28 | hooks: 29 | - id: reorder-python-imports 30 | args: [--application-directories=.] 31 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | lint, 4 | {py27,py36}-ansible{21,22,23,24,25,26,27}, 5 | py3{6,7,8}-ansible{28,29,210} 6 | 7 | [testenv] 8 | basepython = 9 | py27: python2.7 10 | py36: python3.6 11 | py37: python3.7 12 | py38: python3.8 13 | commands = py.test {posargs} 14 | deps = 15 | -rrequirements/test.txt 16 | py27: -rrequirements/py2.txt 17 | ansible21: ansible>=2.1,<2.2 18 | ansible22: ansible>=2.2,<2.3 19 | ansible23: ansible>=2.3,<2.4 20 | ansible24: ansible>=2.4,<2.5 21 | ansible25: ansible>=2.5,<2.6 22 | ansible26: ansible>=2.6,<2.7 23 | ansible27: ansible>=2.7,<2.8 24 | ansible28: ansible>=2.8,<2.9 25 | ansible29: ansible>=2.9,<2.10 26 | ansible210: ansible>=2.10,<2.11 27 | 28 | [testenv:lint] 29 | basepython = python3.6 30 | skip_install = true 31 | deps = -rrequirements/lint.txt 32 | commands = pre-commit run --all-files --show-diff-on-failure 33 | 34 | [flake8] 35 | max-line-length = 120 36 | ignore = E402,W503 37 | 38 | [pytest] 39 | testpaths = paternoster/test 40 | addopts = --cov=paternoster --cov-append --cov-report html 41 | -------------------------------------------------------------------------------- /vagrant/tests/test_everything.yml: -------------------------------------------------------------------------------- 1 | - name: test parameter passing with sudo in one go 2 | hosts: all 3 | gather_facts: no 4 | tasks: 5 | - include: drop_script.yml 6 | vars: 7 | script_params: -d aaaa 8 | playbook: | 9 | - hosts: all 10 | tasks: 11 | - debug: var=param_domain 12 | - debug: var=ansible_user_id 13 | - debug: var=sudo_user 14 | script: | 15 | #!/bin/env python2.7 16 | 17 | import paternoster 18 | import paternoster.types 19 | 20 | s = paternoster.Paternoster( 21 | runner_parameters={'playbook': '/opt/uberspace/playbooks/uberspace-unittest.yml'}, 22 | parameters=[ 23 | { 'name': 'domain', 'short': 'd', 'type': paternoster.types.restricted_str('a-z') }, 24 | ], 25 | become_user='root', 26 | ).auto() 27 | 28 | - assert: 29 | that: 30 | - "script.stdout_lines[0] == 'aaaa'" 31 | - "script.stdout_lines[1] == 'root'" 32 | - "script.stdout_lines[2] == 'vagrant'" 33 | -------------------------------------------------------------------------------- /vagrant/tests/drop_script.yml: -------------------------------------------------------------------------------- 1 | - name: install ansible 2 | become: yes 3 | command: pip install ansible{{ install_ansible_version }} 4 | 5 | - name: drop script 6 | become: yes 7 | copy: 8 | dest: /usr/local/bin/uberspace-unittest 9 | mode: 0775 10 | content: "{{ script }}" 11 | 12 | - name: drop playbook 13 | become: yes 14 | copy: 15 | dest: /opt/uberspace/playbooks/uberspace-unittest.yml 16 | content: "{{ playbook|default('') }}" 17 | 18 | - name: sudoers config 19 | become: yes 20 | template: src=../templates/sudoers.j2 dest=/etc/sudoers.d/uberspace-unittest 21 | vars: 22 | item: uberspace-unittest 23 | 24 | - shell: uberspace-unittest {{ script_params|default('') }} 25 | become_user: testy 26 | register: script 27 | ignore_errors: "{{ ignore_script_errors|default(false) }}" 28 | 29 | - name: delete script, playbook and sudoers config 30 | become: yes 31 | file: 32 | name: "{{ item }}" 33 | state: absent 34 | with_items: 35 | - /usr/local/bin/uberspace-unittest 36 | - /opt/uberspace/playbooks/uberspace-unittest.yml 37 | - /etc/sudoers.d/uberspace-unittest 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # pyenv 28 | .python-version 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *,cover 49 | .hypothesis/ 50 | 51 | # virtualenv 52 | venv/ 53 | ENV/ 54 | 55 | # ansible retry files 56 | *.retry 57 | 58 | # vagrant 59 | .vagrant/ 60 | 61 | # PyCharm 62 | .idea/ 63 | *.iml 64 | 65 | # VSCode 66 | .vscode 67 | 68 | # Sublime 69 | *.sublime-project 70 | *.sublime-workspace 71 | -------------------------------------------------------------------------------- /paternoster/shebang.py: -------------------------------------------------------------------------------- 1 | # this code is executed when paternoster is used as 2 | # part of a shebang line, at the beginning of a script. 3 | from __future__ import absolute_import 4 | 5 | import os.path 6 | import sys 7 | 8 | import yaml 9 | 10 | import paternoster.types 11 | 12 | 13 | def _load_playbook(path): 14 | with open(path) as f: 15 | playbook = yaml.safe_load(f) 16 | 17 | assert type(playbook) == list 18 | return playbook 19 | 20 | 21 | def _find_paternoster_config(playbook): 22 | assert len(playbook) > 0, "no plays found in playbook" 23 | play = playbook[0] 24 | assert type(play) == dict 25 | assert play.get('hosts', None) == 'paternoster', "paternoster play could not be found" 26 | assert 'vars' in play 27 | return play['vars'] 28 | 29 | 30 | def main(): 31 | playbookpath = os.path.abspath(sys.argv[1]) 32 | playbook = _load_playbook(playbookpath) 33 | config = _find_paternoster_config(playbook) 34 | 35 | sys.argv = [playbookpath] + sys.argv[2:] 36 | 37 | paternoster.Paternoster( 38 | runner_parameters={'playbook': playbookpath}, 39 | **config 40 | ).auto() 41 | -------------------------------------------------------------------------------- /paternoster/root.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | import pwd 3 | import re 4 | import sys 5 | 6 | 7 | def become_user(user): 8 | if os.geteuid() != pwd.getpwnam(user).pw_uid: 9 | # flush output buffers. Otherwise the output before the 10 | # become_root()-call might be never shown to the user 11 | sys.stdout.flush() 12 | sys.stderr.flush() 13 | 14 | # resolve symlinks, so the path given in sudo-config matches 15 | realme = os.path.realpath(sys.argv[0]) 16 | 17 | # -n disables password prompt, when sudo isn't configured properly 18 | os.execv('/usr/bin/sudo', ['/usr/bin/sudo', '-u', user, '-n', '--', realme] + sys.argv[1:]) 19 | else: 20 | sudouser = os.environ.get('SUDO_USER', None) 21 | # $SUDO_USER is set directly by sudo, so users should not be alble 22 | # to trick here. Better be safe, than sorry, though. 23 | if sudouser and re.match('^[a-z][a-z0-9]{0,20}$', sudouser): 24 | return sudouser 25 | else: 26 | raise ValueError('invalid username: "{}"'.format(sudouser)) 27 | 28 | 29 | def check_user(user): 30 | return os.geteuid() == pwd.getpwnam(user).pw_uid 31 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | All code in this repository is licensed under the MIT license (see below). 2 | The logo (both the png and svg versions) is licensed unter the CC-BY-NC-ND 4.0 license, which can be found online: 3 | https://creativecommons.org/licenses/by-nc-nd/4.0/ 4 | 5 | 6 | The MIT License (MIT) 7 | 8 | Copyright (c) 2016 uberspace.de 9 | 10 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 15 | -------------------------------------------------------------------------------- /vagrant/tests/test_become_root_symlink.yml: -------------------------------------------------------------------------------- 1 | - name: test become_user 2 | hosts: all 3 | gather_facts: no 4 | tasks: 5 | - name: drop script 6 | become: yes 7 | copy: 8 | dest: /usr/local/bin/uberspace-unittest 9 | mode: 0775 10 | content: | 11 | #!/bin/env python2.7 12 | import os 13 | from paternoster.root import become_user 14 | print('UID:{}'.format(os.geteuid())) 15 | become_user('root') 16 | 17 | - name: sudoers config 18 | become: yes 19 | template: src=../templates/sudoers.j2 dest=/etc/sudoers.d/uberspace-unittest 20 | vars: 21 | item: uberspace-unittest 22 | 23 | - name: create symlink 24 | become: yes 25 | file: 26 | src: /usr/local/bin/uberspace-unittest 27 | dest: /bin/ubrspc-ut 28 | state: link 29 | 30 | - shell: /bin/ubrspc-ut 31 | become: yes 32 | become_user: testy 33 | register: script 34 | 35 | - assert: 36 | that: 37 | - script.stdout_lines[0] == 'UID:1001' 38 | - script.stdout_lines[1] == 'UID:0' 39 | 40 | - name: delete script, symlink and sudoers config 41 | become: yes 42 | file: 43 | name: "{{ item }}" 44 | state: absent 45 | with_items: 46 | - /usr/local/bin/uberspace-unittest 47 | - /etc/sudoers.d/uberspace-unittest 48 | - /bin/ubrspc-ut 49 | -------------------------------------------------------------------------------- /vagrant/site.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: install python 3 | hosts: all 4 | become: yes 5 | tags: setup 6 | tasks: 7 | - name: install shelltools and python 8 | yum: pkg={{ item }} state=installed 9 | with_items: 10 | - nano 11 | - gcc 12 | - openssl-devel 13 | - python-devel 14 | - python-setuptools 15 | 16 | - name: install pip2 17 | easy_install: name=pip 18 | 19 | - name: setup python module 20 | hosts: all 21 | become: yes 22 | tags: setup 23 | tasks: 24 | - command: python setup.py develop 25 | args: 26 | chdir: /vagrant 27 | creates: /usr/lib/python2.7/site-packages/paternoster.egg-link 28 | - name: install development requirements 29 | pip: 30 | requirements: /vagrant/dev-requirements.txt 31 | executable: pip2.7 32 | 33 | - name: create test user 34 | hosts: all 35 | become: yes 36 | tags: setup 37 | tasks: 38 | - name: create test user 39 | user: name=testy state=present 40 | 41 | - name: deploy test scripts 42 | hosts: all 43 | become: yes 44 | tags: scripts 45 | tasks: 46 | - name: deploy script 47 | file: src=/vagrant/vagrant/files/scripts/{{ item|basename }} dest=/usr/local/bin/{{ item|basename }} mode=0775 state=link 48 | with_fileglob: files/scripts/* 49 | 50 | - name: sudoers configs 51 | template: src=sudoers.j2 dest=/etc/sudoers.d/{{ item|basename }} 52 | with_fileglob: files/scripts/* 53 | 54 | - name: create playbook dir 55 | file: state=directory name=/opt/uberspace/playbooks/ 56 | -------------------------------------------------------------------------------- /vagrant/tests/test_become_root.yml: -------------------------------------------------------------------------------- 1 | - name: test become_user 2 | hosts: all 3 | gather_facts: no 4 | tasks: 5 | - include: drop_script.yml 6 | vars: 7 | script: | 8 | #!/bin/env python2.7 9 | from paternoster.root import become_user 10 | import os 11 | 12 | print('UID:{}'.format(os.geteuid())) 13 | user = become_user('root') 14 | print(user) 15 | 16 | - assert: 17 | that: 18 | - "script.stdout_lines[0] != 'UID:0'" 19 | - "script.stdout_lines[1] == 'UID:0'" 20 | - "script.stdout_lines[2] == 'vagrant'" 21 | 22 | - name: test become_user parameter 23 | hosts: all 24 | gather_facts: no 25 | tasks: 26 | - include: drop_script.yml 27 | vars: 28 | script: | 29 | #!/bin/env python2.7 30 | import os 31 | from paternoster.root import become_user 32 | print('UID:{}'.format(os.geteuid())) 33 | become_user('testy') 34 | 35 | - stat: path=/root/foo 36 | become: yes 37 | register: testfile 38 | 39 | - assert: 40 | that: 41 | - "script.stdout_lines[0] == 'UID:1000'" 42 | - "script.stdout_lines[1] == 'UID:1001'" 43 | 44 | - name: become_user should not execute addtional commands 45 | hosts: all 46 | gather_facts: no 47 | tasks: 48 | - include: drop_script.yml 49 | vars: 50 | script_params: "'&& touch /root/foo'" 51 | script: | 52 | #!/bin/env python2.7 53 | from paternoster.root import become_user 54 | become_user('root') 55 | 56 | - stat: path=/root/foo 57 | become: yes 58 | register: testfile 59 | 60 | - assert: 61 | that: 62 | - "not testfile.stat.exists" 63 | -------------------------------------------------------------------------------- /vagrant/tests/test_exploit_ansiblecfg.yml: -------------------------------------------------------------------------------- 1 | - name: test parameter passing with sudo in one go 2 | hosts: all 3 | gather_facts: no 4 | tasks: 5 | - name: Create /modules directory 6 | become: yes 7 | file: path=/etc/ansible state=directory 8 | 9 | - name: Drop /etc/ansible/ansible.cfg 10 | become: yes 11 | copy: 12 | content: | 13 | [defaults] 14 | library = /modules 15 | dest: /etc/ansible/ansible.cfg 16 | 17 | - name: Create /modules directory 18 | become: yes 19 | file: path=/modules state=directory 20 | 21 | - name: Drop stub module 22 | become: yes 23 | copy: 24 | content: | 25 | #!/bin/sh 26 | echo '{}' 27 | dest: /modules/custommodule.py 28 | 29 | - name: Drop fake ansible.cfg 30 | copy: 31 | content: | 32 | [defaults] 33 | library = /fooo 34 | dest: /home/vagrant/ansible.cfg 35 | 36 | - include: drop_script.yml 37 | vars: 38 | ignore_script_errors: yes 39 | playbook: | 40 | - hosts: all 41 | tasks: 42 | - action: custommodule 43 | script: | 44 | #!/bin/env python2.7 45 | 46 | import paternoster 47 | import paternoster.types 48 | 49 | s = paternoster.Paternoster( 50 | runner_parameters={'playbook': '/opt/uberspace/playbooks/uberspace-unittest.yml'}, 51 | parameters=[], 52 | become_user='root', 53 | success_msg='executed successfully', 54 | ).auto() 55 | 56 | - name: Delete ansible.cfgs 57 | file: path={{ item }} state=absent 58 | become: yes 59 | with_items: 60 | - /home/vagrant/ansible.cfg 61 | - /etc/ansible/ansible.cfg 62 | 63 | - assert: 64 | that: 65 | - "script.stdout_lines[0] == 'executed successfully'" 66 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | import sys 4 | 5 | try: 6 | from setuptools import setup 7 | except ImportError: 8 | print("Paternoster needs setuptools.", file=sys.stderr) 9 | print("Please install it using your package-manager or pip.", file=sys.stderr) 10 | sys.exit(1) 11 | 12 | setup(name='paternoster', 13 | version='3.3.0', 14 | description='Paternoster provides allows to run ansible playbooks like ordinary python or bash scripts.', 15 | author='uberspace.de', 16 | author_email='hallo@uberspace.de', 17 | url='https://github.com/uberspace/paternoster', 18 | packages=[ 19 | 'paternoster', 20 | 'paternoster.runners', 21 | 'paternoster.types', 22 | ], 23 | entry_points={ 24 | 'console_scripts': ['paternoster=paternoster.shebang:main'], 25 | }, 26 | install_requires=[ 27 | 'tldextract>=2.0.1', 28 | 'six', 29 | ], 30 | extras_require={ 31 | 'ansible21': ['ansible==2.1.*'], 32 | 'ansible22': ['ansible==2.2.*'], 33 | 'ansible23': ['ansible==2.3.*'], 34 | 'ansible24': ['ansible==2.4.*'], 35 | 'ansible25': ['ansible==2.5.*'], 36 | 'ansible26': ['ansible==2.6.*'], 37 | 'ansible27': ['ansible==2.7.*'], 38 | 'ansible28': ['ansible==2.8.*'], 39 | 'ansible29': ['ansible==2.9.*'], 40 | 'ansible210': ['ansible==2.10.*'], 41 | }, 42 | classifiers=[ 43 | 'Development Status :: 5 - Production/Stable', 44 | 'Intended Audience :: Developers', 45 | 'Intended Audience :: Information Technology', 46 | 'Intended Audience :: System Administrators', 47 | 'Topic :: System :: Systems Administration', 48 | 'Topic :: Security', 49 | 'Topic :: Utilities', 50 | 'Natural Language :: English', 51 | 'Operating System :: POSIX :: Linux', 52 | 'Programming Language :: Python :: 2.7', 53 | 'Programming Language :: Python :: 3.6', 54 | ], 55 | zip_safe=True, 56 | ) 57 | -------------------------------------------------------------------------------- /vagrant/run_integration_tests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import argparse 3 | import os.path 4 | import subprocess 5 | try: 6 | from ConfigParser import ConfigParser as ConfigParser 7 | except ImportError: 8 | from configparser import ConfigParser as ConfigParser 9 | 10 | 11 | TOX_INI_PATH = '../tox.ini' 12 | TESTS_DIR = 'tests' 13 | IGNORED_TESTS = ['drop_script.yml'] 14 | 15 | 16 | def _abs_path(path): 17 | if os.path.isabs(path): 18 | return path 19 | here = os.path.dirname(__file__) 20 | toxini = os.path.join(here, path) 21 | return os.path.abspath(toxini) 22 | 23 | 24 | def _ansible_versions(): 25 | p = ConfigParser() 26 | p.read(_abs_path(TOX_INI_PATH)) 27 | deps = p.get('testenv', 'deps').split('\n') 28 | deps = [d.split(':') for d in deps if d.startswith('ansible') and ':' in d] 29 | deps = [(d[0], d[1].strip()[len('ansible'):]) for d in deps] 30 | return dict(deps) 31 | 32 | 33 | def _run_command(cmd): 34 | proc = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) 35 | out, err = proc.communicate() 36 | return (proc.returncode, out) 37 | 38 | 39 | def _run_file(path, ansible_versions): 40 | orig_path = path 41 | testpath = os.path.join(_abs_path(TESTS_DIR), path) 42 | if not os.path.exists(path) and os.path.exists(testpath): 43 | path = testpath 44 | 45 | path = _abs_path(path) 46 | 47 | os.chdir(_abs_path('..')) 48 | 49 | for v in ansible_versions: 50 | print('=== running {} with ansible{}'.format(orig_path, v)) 51 | cmd = "ansible-playbook {} -e install_ansible_version='{}'".format(path, v) 52 | rc, out = _run_command(cmd) 53 | if rc != 0: 54 | print(out) 55 | 56 | 57 | def _run_all(ansible_versions): 58 | tests = _abs_path(TESTS_DIR) 59 | for f in os.listdir(tests): 60 | if f not in IGNORED_TESTS: 61 | _run_file(f, ansible_versions) 62 | 63 | 64 | def main(): 65 | ansible_versions = _ansible_versions() 66 | 67 | parser = argparse.ArgumentParser(description='Run paternoster integration tests.') 68 | parser.add_argument('ansible', nargs='?', choices=list(ansible_versions.keys()) + ['all'], default='all') 69 | parser.add_argument('--file', help='test file to run, otherwise run all') 70 | args = parser.parse_args() 71 | 72 | if args.ansible == 'all': 73 | ansible_versions = _ansible_versions().values() 74 | else: 75 | ansible_versions = [_ansible_versions()[args.ansible]] 76 | 77 | if args.file: 78 | _run_file(args.file, ansible_versions) 79 | else: 80 | _run_all(ansible_versions) 81 | 82 | 83 | if __name__ == '__main__': 84 | main() 85 | -------------------------------------------------------------------------------- /paternoster/types/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf8 -*- 2 | import re 3 | 4 | import six.moves.urllib as urllib 5 | import tldextract 6 | 7 | 8 | class domain: 9 | __name__ = 'domain' 10 | 11 | DOMAIN_REGEX = r'\A(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])\Z' # noqa 12 | 13 | def __init__(self, wildcard=False, maxlen=255): 14 | self._wildcard = wildcard 15 | self.maxlen = maxlen 16 | 17 | def __call__(self, val): 18 | val = val.encode('idna').decode('ascii') 19 | domain = val 20 | 21 | if '@' in domain: 22 | raise ValueError( 23 | "this looks like an email-adress, " 24 | "try only supplying the part after the @" 25 | ) 26 | 27 | if val.endswith('.'): 28 | domain = val = val[:-1] 29 | 30 | if self._wildcard and val.startswith('*.'): 31 | val = val[2:] 32 | 33 | extracted = tldextract.TLDExtract(suffix_list_urls=[])(val) 34 | 35 | if ( 36 | any(map(lambda p: len(p) > 63, val.split('.'))) 37 | or len(val) > self.maxlen 38 | ): 39 | raise ValueError('domain too long') 40 | if val.count('.') < 1: 41 | raise ValueError('domain has too few components') 42 | if not re.match(self.DOMAIN_REGEX, val): 43 | raise ValueError('invalid domain') 44 | if not extracted.suffix: 45 | raise ValueError('invalid domain suffix') 46 | if not extracted.domain: 47 | raise ValueError('invalid domain') 48 | 49 | return domain.lower() 50 | 51 | 52 | class uri: 53 | __name__ = 'URI' 54 | 55 | SCHEME_REGEX = r'\A[a-z][a-z0-9+.-]*\Z' 56 | SCHEME_MAX_LEN = 255 57 | 58 | PATH_REGEX = u'\\A/([a-zA-ZüäöÜÄÖß0-9._=-]+/?)*\\Z' 59 | PATH_MAX_LEN = 512 60 | 61 | def __init__(self, optional_scheme=True, optional_domain=True, domain_options={}): 62 | self._required = filter(bool, [ 63 | 'scheme' if not optional_scheme else None, 64 | 'domain' if not optional_domain else None, 65 | ]) 66 | self._domaincheck = domain(domain_options) 67 | 68 | def __call__(self, val): 69 | parsed = urllib.parse.urlsplit(val) 70 | 71 | result = { 72 | 'scheme': parsed.scheme, 73 | 'domain': parsed.netloc, 74 | 'path': parsed.path, 75 | } 76 | 77 | # correctly parse scheme-less URIs like "google.com/foobar" 78 | if not result['domain']: 79 | maybedomain, _, maybepath = result['path'].partition('/') 80 | 81 | if '.' in maybedomain: 82 | result['domain'] = maybedomain 83 | result['path'] = maybepath 84 | 85 | # === check scheme 86 | if result['scheme']: 87 | if len(result['scheme']) > self.SCHEME_MAX_LEN: 88 | raise ValueError('scheme too long') 89 | elif not re.match(self.SCHEME_REGEX, result['scheme']): 90 | raise ValueError('invalid scheme') 91 | 92 | result['scheme'] = result['scheme'].lower() 93 | 94 | # === check domain 95 | if result['domain']: 96 | result['domain'] = self._domaincheck(result['domain']) 97 | 98 | # === check path 99 | result['path'] = '/' + result['path'].strip('/') 100 | if len(result['path']) > self.PATH_MAX_LEN: 101 | raise ValueError('path too long') 102 | elif not re.match(self.PATH_REGEX, result['path']): 103 | raise ValueError('invalid path') 104 | 105 | # normalize falsy values 106 | result = {k: v if v else '' for k, v in result.items()} 107 | 108 | missing = [k for k in self._required if not result[k]] 109 | if missing: 110 | raise ValueError('missing ' + ', '.join(missing)) 111 | 112 | if result['scheme']: 113 | result['full'] = u'{scheme}://{domain}{path}'.format(**result) 114 | else: 115 | result['full'] = u'{domain}{path}'.format(**result) 116 | 117 | return result 118 | 119 | 120 | class restricted_str: 121 | __name__ = 'string' 122 | 123 | def __init__(self, allowed_chars=None, regex=None, minlen=1, maxlen=255): 124 | if minlen is not None and maxlen is not None and minlen > maxlen: 125 | raise ValueError('minlen must be smaller than maxlen') 126 | if not allowed_chars and not regex: 127 | raise ValueError('either allowed_chars or regex must be supplied') 128 | if allowed_chars and regex: 129 | raise ValueError('allowed_chars or regex are mutally exclusive') 130 | 131 | if allowed_chars: 132 | # construct a regex matching a arbitrary number of characters within 133 | # the given set. 134 | self._regex = re.compile(r'\A[{}]+\Z'.format(allowed_chars)) 135 | elif regex: 136 | if not regex.startswith('^') or not regex.endswith('$'): 137 | raise ValueError('regex must be anchored') 138 | 139 | # replace $ at the end with \Z, so we can't match "a\n" for "^a$" 140 | regex = r'\A' + regex[1:-1] + r'\Z' 141 | self._regex = re.compile(regex) 142 | 143 | self._minlen = minlen 144 | self._maxlen = maxlen 145 | 146 | def __call__(self, val): 147 | if self._maxlen is not None and len(val) > self._maxlen: 148 | raise ValueError('string is too long (must be <= {})'.format(self._maxlen)) 149 | if self._minlen is not None and len(val) < self._minlen: 150 | raise ValueError('string is too short (must be >= {})'.format(self._minlen)) 151 | if not self._regex.match(val): 152 | raise ValueError('invalid value') 153 | return val 154 | 155 | 156 | class restricted_int: 157 | __name__ = 'integer' 158 | 159 | def __init__(self, minimum=None, maximum=None): 160 | if minimum is not None: 161 | try: 162 | minimum = int(minimum) 163 | except (ValueError, TypeError): 164 | raise ValueError('minimum is not a integer') 165 | 166 | if maximum is not None: 167 | try: 168 | maximum = int(maximum) 169 | except (ValueError, TypeError): 170 | raise ValueError('maximum is not a integer') 171 | 172 | if minimum is not None and maximum is not None and minimum > maximum: 173 | raise ValueError('minimum must be smaller than maximum') 174 | 175 | self._minimum = minimum 176 | self._maximum = maximum 177 | 178 | def __call__(self, val): 179 | try: 180 | val = int(val) 181 | except TypeError: 182 | raise ValueError('invalid integer') 183 | 184 | if self._minimum is not None and val < self._minimum: 185 | raise ValueError('value too small (must be >= {})'.format(self._minimum)) 186 | if self._maximum is not None and val > self._maximum: 187 | raise ValueError('value too big (must be <= {})'.format(self._maximum)) 188 | 189 | return val 190 | 191 | 192 | __all__ = [ 193 | 'domain', 194 | 'uri', 195 | 'restricted_int', 196 | 'restricted_str', 197 | ] 198 | -------------------------------------------------------------------------------- /paternoster/test/test_ansible_runner.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from distutils.version import LooseVersion 3 | 4 | import ansible.release 5 | import pytest 6 | 7 | ANSIBLE_VERSION = LooseVersion(ansible.release.__version__) 8 | SKIP_ANSIBLE_TESTS = (sys.version_info >= (3, 0) and ANSIBLE_VERSION < LooseVersion('2.4.0')) 9 | 10 | 11 | @pytest.mark.parametrize("args,kwargs,isfilertn,valid", [ 12 | ([None], {}, None, False), 13 | (['../playbook.yml'], {}, True, False), 14 | (['/playbook.yml'], {}, True, True), 15 | (['/i/do/not/exist.yml'], {}, None, False), 16 | ]) 17 | def test_playbook_validation(args, kwargs, isfilertn, valid, monkeypatch): 18 | import os 19 | from ..runners.ansiblerunner import AnsibleRunner 20 | from ansible.errors import AnsibleFileNotFound 21 | 22 | if isfilertn is not None: 23 | monkeypatch.setattr(os.path, 'isfile', lambda *args, **kwargs: isfilertn) 24 | 25 | monkeypatch.setattr(os, 'chdir', lambda *args, **kwargs: None) 26 | 27 | try: 28 | if not valid: 29 | with pytest.raises(ValueError): 30 | AnsibleRunner(*args, **kwargs).run({}, False) 31 | else: 32 | AnsibleRunner(*args, **kwargs).run({}, False) 33 | except AnsibleFileNotFound: 34 | pass 35 | 36 | 37 | @pytest.mark.skipif(SKIP_ANSIBLE_TESTS, reason="ansible <2.4 requires python2") 38 | @pytest.mark.parametrize("verbosity,keywords,notkeywords", [ 39 | (False, [], ["TASK [debug]", "PLAY RECAP"]), 40 | (True, ["TASK [debug]", "PLAY RECAP"], ["ESTABLISH LOCAL CONNECTION"]), 41 | (3, ["TASK [debug]", "PLAY RECAP", "task path"], []), 42 | ]) 43 | def test_verbose(verbosity, keywords, notkeywords, capsys, monkeypatch): 44 | import os 45 | from ..runners.ansiblerunner import AnsibleRunner 46 | 47 | playbook_path = '/tmp/paternoster-test-playbook.yml' 48 | playbook = """ 49 | - hosts: all 50 | gather_facts: no 51 | tasks: 52 | - debug: msg=a 53 | """ 54 | 55 | with open(playbook_path, 'w') as f: 56 | f.write(playbook) 57 | 58 | monkeypatch.setattr(os, 'chdir', lambda *args, **kwargs: None) 59 | AnsibleRunner(playbook_path).run([], verbosity) 60 | 61 | out, err = capsys.readouterr() 62 | 63 | for kw in keywords: 64 | assert (kw in out) or (kw in err) 65 | for kw in notkeywords: 66 | assert (kw not in out) and (kw not in err) 67 | 68 | 69 | @pytest.mark.skipif(SKIP_ANSIBLE_TESTS, reason="ansible <2.4 requires python2") 70 | @pytest.mark.parametrize("task,exp_out,exp_err,exp_status", [ 71 | ("debug: msg=hi", "hi\n", "", True), 72 | # the list order is not defined in some ansible versions, so we just assert 73 | # that all items are present in whatever order. 74 | # https://github.com/ansible/ansible/issues/21008 75 | ("debug: var=item\n with_items: ['a', 'b']", ["a", "b"], "", True), 76 | ("debug: msg='{{ item }}'\n with_items: ['a', 'b']", ["a", "b"], "", True), 77 | ("debug: var=param_foo", "22\n", "", True), 78 | ("command: echo hi", "", "", True), 79 | ("fail: msg=42", "", "42\n", False), 80 | ("fail: msg=42\n ignore_errors: yes", "", "", True), 81 | ("""fail: msg='{{ item }}' 82 | with_items: 83 | - /bin/true 84 | - /bin/maybe""", "", ["/bin/true", "/bin/maybe"], False), 85 | ("""command: '{{ item }}' 86 | with_items: 87 | - /bin/true 88 | - /bin/maybe""", "", ["No such file"], False) 89 | ]) 90 | def test_output(task, exp_out, exp_err, exp_status, capsys, monkeypatch): 91 | import os 92 | from ..runners.ansiblerunner import AnsibleRunner 93 | 94 | playbook_path = '/tmp/paternoster-test-playbook.yml' 95 | playbook = """ 96 | - hosts: all 97 | gather_facts: no 98 | tasks: 99 | - """ + task 100 | 101 | with open(playbook_path, 'w') as f: 102 | f.write(playbook) 103 | 104 | monkeypatch.setattr(os, 'chdir', lambda *args, **kwargs: None) 105 | status = AnsibleRunner(playbook_path).run([('param_foo', 22)], False) 106 | 107 | out, err = capsys.readouterr() 108 | 109 | if isinstance(exp_out, list): 110 | for s in exp_out: 111 | assert s in out 112 | else: 113 | assert out == exp_out 114 | 115 | if isinstance(exp_err, list): 116 | for s in exp_err: 117 | assert s in err 118 | else: 119 | assert err == exp_err 120 | 121 | assert status == exp_status 122 | 123 | 124 | @pytest.mark.skipif(SKIP_ANSIBLE_TESTS, reason="ansible <2.4 requires python2") 125 | def test_paramater_passing(capsys, monkeypatch): 126 | import os 127 | from ..runners.ansiblerunner import AnsibleRunner 128 | 129 | playbook_path = '/tmp/paternoster-test-playbook.yml' 130 | playbook = """ 131 | - hosts: all 132 | gather_facts: no 133 | tasks: 134 | - debug: var=param_foo 135 | - set_fact: 136 | param_foo: new value 137 | - debug: var=param_foo 138 | """ 139 | 140 | with open(playbook_path, 'w') as f: 141 | f.write(playbook) 142 | 143 | monkeypatch.setattr(os, 'chdir', lambda *args, **kwargs: None) 144 | AnsibleRunner(playbook_path).run([('param_foo', 'param_foo value')], False) 145 | 146 | out, err = capsys.readouterr() 147 | 148 | assert 'param_foo value' in out 149 | assert 'new value' in out 150 | 151 | 152 | @pytest.mark.skipif(SKIP_ANSIBLE_TESTS, reason="ansible <2.4 requires python2") 153 | def test_msg_handling_hide_warnings(capsys, monkeypatch): 154 | """Don't display warning on missing `msg` key in `results`.""" 155 | import os 156 | from ..runners.ansiblerunner import AnsibleRunner 157 | 158 | playbook_path = '/tmp/paternoster-test-playbook.yml' 159 | playbook = """ 160 | - hosts: all 161 | gather_facts: no 162 | tasks: 163 | - debug: 164 | msg: start 165 | - assert: 166 | that: 1 == 0 167 | ignore_errors: yes 168 | with_items: 169 | - 1 170 | - 2 171 | - debug: 172 | msg: stop 173 | """ 174 | 175 | with open(playbook_path, 'w') as f: 176 | f.write(playbook) 177 | 178 | monkeypatch.setattr(os, 'chdir', lambda *args, **kwargs: None) 179 | AnsibleRunner(playbook_path).run([], False) 180 | 181 | exp_stdout = "start\nstop\n" 182 | exp_stderr = '' 183 | 184 | out, err = capsys.readouterr() 185 | 186 | assert out == exp_stdout 187 | assert err == exp_stderr 188 | 189 | 190 | @pytest.mark.skipif(SKIP_ANSIBLE_TESTS, reason="ansible <2.4 requires python2") 191 | def test_msg_handling_hide_nonzero(capsys, monkeypatch): 192 | """Don't display `non-zero return code` on failing tasks if errors are ignored.""" 193 | import os 194 | from ..runners.ansiblerunner import AnsibleRunner 195 | 196 | playbook_path = '/tmp/paternoster-test-playbook.yml' 197 | playbook = """ 198 | - hosts: all 199 | gather_facts: no 200 | tasks: 201 | - debug: 202 | msg: start 203 | - shell: /bin/fail 204 | ignore_errors: yes 205 | with_items: 206 | - 1 207 | - 2 208 | - debug: 209 | msg: stop 210 | """ 211 | 212 | with open(playbook_path, 'w') as f: 213 | f.write(playbook) 214 | 215 | monkeypatch.setattr(os, 'chdir', lambda *args, **kwargs: None) 216 | AnsibleRunner(playbook_path).run([], False) 217 | 218 | exp_stdout = "start\nstop\n" 219 | exp_stderr = '' 220 | 221 | out, err = capsys.readouterr() 222 | 223 | assert out == exp_stdout 224 | assert err == exp_stderr 225 | -------------------------------------------------------------------------------- /logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | image/svg+xml 150 | -------------------------------------------------------------------------------- /paternoster/runners/ansiblerunner.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | import os.path 4 | import sys 5 | from distutils.version import LooseVersion 6 | 7 | # Ansible loads the ansible.cfg in the following order. Ansible will process the 8 | # list below and use the first file found, all others are ignored. 9 | # see: https://docs.ansible.com/ansible/latest/reference_appendices/config.html#the-configuration-file 10 | # 11 | # 1. Path given in $ANSIBLE_CONFIG 12 | # 2. ansible.cfg file in the current directory 13 | # 3. .ansible.cfg file in the home directory 14 | # 4. /etc/ansible/ansible.cfg 15 | # 16 | # Since the current directory is controlled by the user and we don't want them 17 | # to be able to load their own config and thus their own ansible modules, we 18 | # need to counter them by setting the env-variable. 19 | os.environ['ANSIBLE_CONFIG'] = '/etc/ansible/ansible.cfg' 20 | 21 | # by default ansible uses "$HOME/.ansible/tmp" as the directory to drop 22 | # its module files. For some reason $HOME is not resolved when using the 23 | # python API directly resuling in a new directory called '$HOME' within 24 | # the paternoster source. This forces the modules to be dropped in /tmp. 25 | os.environ['ANSIBLE_REMOTE_TEMP'] = '/tmp' 26 | os.environ['ANSIBLE_LOCAL_TEMP'] = '/tmp' 27 | 28 | # Verbosity within ansbible is controlled by the Display-class. Each and 29 | # every ansible-file creates their own instance of this class, like this: 30 | # 31 | # try: 32 | # from __main__ import display 33 | # except ImportError: 34 | # from ansible.utils.display import Display 35 | # display = Display() 36 | # 37 | # This means that the verbosity-parameter of display _always_ default to 38 | # zero. There is no sane way to overwrite this. Within a normal ansible 39 | # setup __main__ corresponds to the current executable (e.g. "ansible-playbook"), 40 | # which creates a Display instance based on the cli parameters (-v, -vv, ...). 41 | # 42 | # This has to happen before anything from ansible is imported! 43 | import __main__ 44 | from ansible.utils.display import Display 45 | 46 | __main__.display = Display() 47 | 48 | import ansible.constants 49 | from ansible.executor.playbook_executor import PlaybookExecutor 50 | from ansible.parsing.dataloader import DataLoader 51 | from ansible.plugins.callback import CallbackBase 52 | 53 | import ansible.release 54 | 55 | ANSIBLE_VERSION = LooseVersion(ansible.release.__version__) 56 | 57 | if ANSIBLE_VERSION < LooseVersion('2.4.0'): 58 | from ansible.inventory import Inventory 59 | from ansible.vars import VariableManager 60 | else: 61 | from ansible.inventory.manager import InventoryManager 62 | from ansible.vars.manager import VariableManager 63 | 64 | if ANSIBLE_VERSION < LooseVersion('2.8.0'): 65 | from collections import namedtuple 66 | Options = namedtuple( 67 | 'Options', 68 | [ 69 | 'connection', 'module_path', 'forks', 'become', 'become_method', 70 | 'become_user', 'check', 'listhosts', 'listtasks', 'listtags', 71 | 'syntax', 'diff' 72 | ] 73 | ) 74 | else: 75 | from ansible import context 76 | from ansible.module_utils.common.collections import ImmutableDict 77 | 78 | 79 | class MinimalAnsibleCallback(CallbackBase): 80 | """ filters out all ansible messages except for playbook fails and debug-module-calls. """ 81 | 82 | def v2_runner_on_failed(self, result, ignore_errors=False): 83 | msg = result._result.get('msg') 84 | task_ignores_errors = getattr(result, '_task_fields', {}).get('ignore_errors', False) 85 | if ( 86 | not ignore_errors and not task_ignores_errors 87 | and msg is not None and msg != 'All items completed' 88 | ): 89 | print(msg, file=sys.stderr) 90 | 91 | def v2_runner_item_on_ok(self, result): 92 | self.v2_runner_on_ok(result) 93 | 94 | def v2_runner_item_on_failed(self, result): 95 | self.v2_runner_on_failed(result) 96 | 97 | def _get_action_args(self, result): 98 | if ANSIBLE_VERSION < LooseVersion('2.3'): 99 | result = result._result 100 | if 'invocation' in result: 101 | action = result['invocation'].get('module_name', None) 102 | args = result['invocation'].get('module_args', None) 103 | else: 104 | action = None 105 | args = {} 106 | 107 | isloop = False 108 | else: 109 | action = result._task_fields.get('action', None) 110 | args = result._task_fields.get('args', {}) 111 | isloop = 'results' in result._result 112 | 113 | return (action, args, isloop) 114 | 115 | def v2_runner_on_ok(self, result): 116 | action, args, isloop = self._get_action_args(result) 117 | 118 | if isloop: 119 | # ansible 2.2+ calls runner_on_ok after all items have passed 120 | # older versions don't. 121 | return 122 | 123 | if action == 'debug': 124 | if 'var' in args: 125 | print(result._result[args['var']]) 126 | if 'msg' in args: 127 | print(result._result['msg']) 128 | 129 | 130 | class AnsibleRunner: 131 | def __init__(self, playbook): 132 | self._playbook = playbook 133 | 134 | def _get_playbook_executor(self, variables, verbosity): 135 | # -v given to us enables ansibles non-debug output. 136 | # So -vv should become ansibles -v. 137 | __main__.display.verbosity = max(0, verbosity - 1) 138 | 139 | # make sure ansible does not output warnings for our paternoster pseudo-play 140 | __main__._real_warning = __main__.display.warning 141 | 142 | def display_warning(msg, *args, **kwargs): 143 | if not msg.startswith('Could not match supplied host pattern'): 144 | __main__._real_warning(msg, *args, **kwargs) 145 | __main__.display.warning = display_warning 146 | 147 | loader = DataLoader() 148 | if ANSIBLE_VERSION < LooseVersion('2.4.0'): 149 | variable_manager = VariableManager() 150 | inventory = Inventory(loader=loader, variable_manager=variable_manager, host_list='localhost,') 151 | variable_manager.set_inventory(inventory) 152 | else: 153 | inventory = InventoryManager(loader=loader, sources='localhost,') 154 | variable_manager = VariableManager(loader=loader, inventory=inventory) 155 | 156 | if ANSIBLE_VERSION < LooseVersion('2.9.0'): 157 | localhost = inventory.localhost 158 | else: 159 | localhost = inventory.localhost.get_name() 160 | 161 | # force ansible to use the current python executable. Otherwise 162 | # it can end up choosing a python3 one (named python) or a different 163 | # python 2 version 164 | variable_manager.set_host_variable(localhost, 'ansible_python_interpreter', sys.executable) 165 | 166 | for name, value in variables: 167 | variable_manager.set_host_variable(localhost, name, value) 168 | 169 | if ANSIBLE_VERSION < LooseVersion('2.8.0'): 170 | cli_options = Options( 171 | become=None, 172 | become_method=None, 173 | become_user=None, 174 | check=False, 175 | connection='local', 176 | diff=False, 177 | forks=1, 178 | listhosts=False, 179 | listtags=False, 180 | listtasks=False, 181 | module_path=None, 182 | syntax=False, 183 | ) 184 | else: 185 | cli_options = ImmutableDict( 186 | become=None, 187 | become_method=None, 188 | become_user=None, 189 | check=False, 190 | connection='local', 191 | diff=False, 192 | forks=1, 193 | listhosts=False, 194 | listtags=False, 195 | listtasks=False, 196 | module_path=None, 197 | syntax=False, 198 | start_at_task=None, 199 | ) 200 | 201 | if ANSIBLE_VERSION < LooseVersion('2.8.0'): 202 | pexec = PlaybookExecutor( 203 | playbooks=[self._playbook], 204 | inventory=inventory, 205 | variable_manager=variable_manager, 206 | loader=loader, 207 | options=cli_options, 208 | passwords={}, 209 | ) 210 | else: 211 | context.CLIARGS = cli_options 212 | pexec = PlaybookExecutor( 213 | [self._playbook], inventory, variable_manager, loader, {} 214 | ) 215 | 216 | ansible.constants.RETRY_FILES_ENABLED = False 217 | 218 | if not verbosity: 219 | # ansible doesn't provide a proper API to overwrite this, 220 | # if you're using PlaybookExecutor instead of initializing 221 | # the TaskQueueManager (_tqm) yourself, like in the offical 222 | # example. 223 | pexec._tqm._stdout_callback = MinimalAnsibleCallback() 224 | 225 | return pexec 226 | 227 | def _check_playbook(self): 228 | if not self._playbook: 229 | raise ValueError('no playbook given') 230 | if not os.path.isabs(self._playbook): 231 | raise ValueError('path to playbook must be absolute') 232 | if not os.path.isfile(self._playbook): 233 | raise ValueError('playbook must exist') 234 | 235 | def run(self, variables, verbosity): 236 | self._check_playbook() 237 | os.chdir(os.path.dirname(self._playbook)) 238 | status = self._get_playbook_executor(variables, verbosity).run() 239 | return True if status == 0 else False 240 | -------------------------------------------------------------------------------- /paternoster/test/test_types.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import pytest 3 | 4 | 5 | @pytest.mark.parametrize("value,wildcard,valid", [ 6 | ("uberspace.de", False, True), 7 | ("foo.google", False, True), 8 | ("foo.de\n", False, False), 9 | ("foo.de\nbar.com", False, False), 10 | (u"foobär.com", False, True), 11 | ("uberspace.deee", False, False), 12 | ("-bla.com", False, False), 13 | ("a42'.com", False, False), 14 | ('some"thing.com', False, False), 15 | ("*.google.at", False, False), 16 | ("*.google.at", True, True), 17 | ("foo.*.google.at", True, False), 18 | ("foo.*", True, False), 19 | ('someth\x00ing.com', False, False), 20 | ('something', False, False), 21 | ('*.de', True, False), 22 | ('*', True, False), 23 | ('*.*.de', True, False), 24 | ('*.', True, False), 25 | ("a" * 65 + ".com", False, False), 26 | (("a" * 40 + '.') * 8 + "com", False, False), 27 | ('', False, False), 28 | ('example.com.', False, True), 29 | ('example.com..', False, False), 30 | ('*.example.com.', True, True), 31 | ('.', False, False), 32 | ]) 33 | def test_type_domain(value, wildcard, valid): 34 | from ..types import domain 35 | 36 | check = domain(wildcard) 37 | 38 | if not valid: 39 | with pytest.raises(ValueError): 40 | check(value) 41 | else: 42 | check(value) 43 | 44 | 45 | def test_type_domain_detect_email(): 46 | from ..types import domain 47 | 48 | with pytest.raises(ValueError) as exc: 49 | domain()('foo@bar.com') 50 | assert 'this looks like an email-adress' in str(exc.value) 51 | 52 | 53 | def test_type_domain_maxlen(): 54 | from ..types import domain 55 | 56 | d_name = ( 57 | 'abc.def.ghi.klmn.opq.rst.uvw.xyz.now.you.know.my.abc.next.time.just.' 58 | 'sing.the.xxxx.with.me.because.if.you.dont.i.will.literally.xxxx.xxx.' 59 | 'you.xxxxxxx.xxxxx.lets.try.again.shall.we.abc.def.ghi.klmn.opq.rst.' 60 | 'uvw.xyz.xxxx.were.missing.a.letter.there.someth.co' 61 | ) 62 | 63 | d = domain()(d_name) 64 | assert d == d_name 65 | 66 | with pytest.raises(ValueError) as exc: 67 | domain(maxlen=64)(d_name) 68 | assert 'domain too long' in str(exc.value) 69 | 70 | 71 | @pytest.mark.parametrize("value,wildcard,expected", [ 72 | ("uberspace.de", False, "uberspace.de"), 73 | ("ubERspaCE.dE", False, "uberspace.de"), 74 | ("uberspace.de.", False, "uberspace.de"), 75 | ("*.uberspace.de", True, "*.uberspace.de"), 76 | ("*.uberspace.de.", True, "*.uberspace.de"), 77 | ]) 78 | def test_type_domain_return(value, wildcard, expected): 79 | from ..types import domain 80 | 81 | check = domain(wildcard) 82 | actual = check(value) 83 | 84 | assert actual == expected 85 | 86 | 87 | @pytest.mark.parametrize("value,expected", [ 88 | ("", {'path': '/', 'full': '/'}), 89 | ("/foo", {'path': '/foo', 'full': '/foo'}), 90 | ("/foo/", {'path': '/foo', 'full': '/foo'}), 91 | (u"/föö", {'path': u'/föö', 'full': u'/föö'}), 92 | ("/0a0", {'path': '/0a0'}), 93 | ("/bla.foo", {'path': '/bla.foo'}), 94 | ("/bla_foo", {'path': '/bla_foo'}), 95 | ("/bla-foo", {'path': '/bla-foo'}), 96 | ("/bla/foo", {'path': '/bla/foo'}), 97 | ("/foo bar", False), 98 | ('a' * 511, {'path': '/' + 'a' * 511}), 99 | ('a' * 512, False), 100 | ("uberspace.de", {'domain': 'uberspace.de'}), 101 | ("uberspace.de/", {'domain': 'uberspace.de', 'full': 'uberspace.de/'}), 102 | ("uberspace.de/bla", {'domain': 'uberspace.de', 'path': '/bla'}), 103 | ( 104 | "https://uberspace.de/bla", 105 | {'scheme': 'https', 'domain': 'uberspace.de', 'path': '/bla', 'full': 'https://uberspace.de/bla'} 106 | ), 107 | ("https://*.uberspace.de/bla", False), 108 | ("uberspace.deee", False), 109 | ("https://", {'scheme': 'https', 'path': '/'}), 110 | ("https://uberspace.deee", False), 111 | ("https://uberspac" + "e" * 56 + ".de", False), 112 | ("äää://uberspace.de", False), 113 | ("://uberspace.de", False), 114 | ('a' * 255 + "://uberspace.de", {'scheme': 'a' * 255, 'domain': 'uberspace.de'}), 115 | ('a' * 256 + "://uberspace.de", False), 116 | ("https://foo://", False), 117 | ("https://foo://a", False), 118 | ]) 119 | def test_type_uri(value, expected): 120 | from ..types import uri 121 | 122 | check = uri() 123 | 124 | if expected: 125 | actual = check(value) 126 | 127 | assert 'scheme' in actual 128 | assert 'domain' in actual 129 | assert 'path' in actual 130 | 131 | for k, v in expected.items(): 132 | assert actual.pop(k, None) == v, k 133 | 134 | assert not actual.get('schema') 135 | assert not actual.get('domain') 136 | assert 'path' not in actual or actual['path'] == '/' 137 | else: 138 | try: 139 | with pytest.raises(ValueError): 140 | x = check(value) 141 | finally: 142 | try: 143 | print('invalid return: {}'.format(x)) 144 | except: # noqa 145 | pass 146 | 147 | 148 | @pytest.mark.parametrize("value,required,expected", [ 149 | ("", ["scheme", "domain"], ["scheme", "domain"]), 150 | ("http://", ["scheme", "domain"], ["domain"]), 151 | ("google.com", ["scheme", "domain"], ["scheme"]), 152 | ("", ["scheme"], ["scheme"]), 153 | ("google.com", ["scheme"], ["scheme"]), 154 | ("", ["domain"], ["domain"]), 155 | ("https://", ["domain"], ["domain"]), 156 | ]) 157 | def test_type_uri_optional(value, required, expected): 158 | from ..types import uri 159 | 160 | args = {'optional_' + k: False for k in required} 161 | check = uri(**args) 162 | 163 | with pytest.raises(ValueError) as excinfo: 164 | check(value) 165 | 166 | msg = str(excinfo) 167 | assert 'missing' in msg 168 | for e in expected: 169 | assert e in msg 170 | 171 | 172 | def test_type_uri_domain_options(): 173 | from ..types import uri 174 | 175 | check = uri(domain_options={'wildcard': True}) 176 | check('https://*.foo.com') 177 | 178 | 179 | @pytest.mark.parametrize("allowed_chars,regex,minlen,maxlen,value,valid", [ 180 | ("a-z", None, None, None, "aaaaaabb", True), 181 | ("a-z", None, None, None, "aaaaaabb2", False), 182 | ("a-z", None, None, None, "aaaa\n", False), 183 | ("a-z", None, None, None, "aaaa\nbb", False), 184 | ("b", None, None, None, "bbbb", True), 185 | ("b", None, None, None, "a", False), 186 | ("a-z0-9", None, None, None, "aaaaaabb2", True), 187 | ("a", None, 3, None, "a" * 2, False), 188 | ("a", None, None, 5, "a" * 5, True), 189 | ("a", None, None, 5, "a" * 6, False), 190 | ("a", None, 3, 5, "a" * 4, True), 191 | ("a-z", None, None, None, "aaaaaabb", True), 192 | (None, '^[a-z]$', None, None, "a", True), 193 | (None, '^[a-z]$', None, None, "aa", False), 194 | (None, '^a$', None, None, "a\n", False), 195 | ]) 196 | def test_type_restricted_str(allowed_chars, regex, minlen, maxlen, value, valid): 197 | from ..types import restricted_str 198 | 199 | check = restricted_str(allowed_chars=allowed_chars, regex=regex, minlen=minlen, maxlen=maxlen) 200 | 201 | if not valid: 202 | with pytest.raises(ValueError): 203 | check(value) 204 | else: 205 | check(value) 206 | 207 | 208 | @pytest.mark.parametrize('allowed_chars,regex,minlen,maxlen,valid', [ 209 | ("a", None, 100, 0, False), 210 | ("a", "^aa$", None, None, False), 211 | (None, "a", None, None, False), 212 | (None, "^a", None, None, False), 213 | (None, "a$", None, None, False), 214 | ]) 215 | def test_type_restricted_str_ctor(allowed_chars, regex, minlen, maxlen, valid): 216 | from ..types import restricted_str 217 | 218 | if not valid: 219 | with pytest.raises(ValueError): 220 | restricted_str(allowed_chars=allowed_chars, regex=regex, minlen=minlen, maxlen=maxlen) 221 | else: 222 | restricted_str(allowed_chars=allowed_chars, regex=regex, minlen=minlen, maxlen=maxlen) 223 | 224 | 225 | def test_type_restricted_str_minlen_default(): 226 | from ..types import restricted_str 227 | 228 | with pytest.raises(ValueError): 229 | restricted_str("a")("") 230 | 231 | 232 | def test_type_restricted_str_maxlen_default(): 233 | from ..types import restricted_str 234 | 235 | with pytest.raises(ValueError): 236 | restricted_str("a")("a" * 256) 237 | 238 | 239 | @pytest.mark.parametrize("minimum,maximum,value,valid", [ 240 | (0, 100, "a", False), 241 | (0, 100, "", False), 242 | (0, 100, None, False), 243 | (0, 100, "50", True), 244 | (0, 100, "50.5", False), 245 | (0, 100, 50, True), 246 | (0, 100, 50, True), 247 | (0, 100, 0, True), 248 | (0, 100, -1, False), 249 | (0, 100, 100, True), 250 | (0, 100, 101, False), 251 | (None, 100, 101, False), 252 | (0, None, -1, False), 253 | (None, 100, 99, True), 254 | (0, None, 1, True), 255 | (None, 100, -1000, True), 256 | (0, None, 1000, True), 257 | ]) 258 | def test_restricted_int(minimum, maximum, value, valid): 259 | from ..types import restricted_int 260 | 261 | check = restricted_int(minimum, maximum) 262 | 263 | if not valid: 264 | with pytest.raises(ValueError): 265 | check(value) 266 | else: 267 | check(value) 268 | 269 | 270 | def test_range_int_ctor(): 271 | from ..types import restricted_int 272 | 273 | with pytest.raises(ValueError): 274 | restricted_int(100, 0) 275 | 276 | 277 | def test_range_int_ctor_types_min(): 278 | from ..types import restricted_int 279 | 280 | with pytest.raises(ValueError): 281 | restricted_int(minimum="foo") 282 | 283 | 284 | def test_range_int_ctor_types_max(): 285 | from ..types import restricted_int 286 | 287 | with pytest.raises(ValueError): 288 | restricted_int(maximum="bar") 289 | -------------------------------------------------------------------------------- /doc/script_development.md: -------------------------------------------------------------------------------- 1 | # Script Development 2 | 3 | A typical boilerplate for Paternoster looks like this: 4 | 5 | ```yml 6 | #!/usr/bin/env paternoster 7 | 8 | - hosts: paternoster 9 | vars: 10 | success_msg: "all good!" 11 | parameters: 12 | - name: username 13 | short: u 14 | help: "name of the user to create" 15 | type: paternoster.types.restricted_str 16 | required: yes 17 | type_params: 18 | regex: '^[a-z]+$' 19 | 20 | - hosts: localhost 21 | tasks: 22 | - debug: msg="creating user {{ param_username }}" 23 | ``` 24 | 25 | Inside `vars` the following values can be set: 26 | 27 | * `parameters`: command line parameters to parse, check and pass on to ansible 28 | * `become_user`: use `sudo` to execute the playbook as the given user (e.g. `root`) 29 | * `check_user`: check that the user running the script is the one given here 30 | * `success_msg`: print this message once the script has exited successfully 31 | * `description`: a short description of the script's purpose (for `--help` output) 32 | 33 | ## Parameters 34 | 35 | Each parameter is represented by a dictionary within the `patermeters` list. 36 | The values supplied there are passed onto pythons [`add_argument()`-function](https://docs.python.org/2/library/argparse.html#the-add-argument-method), 37 | except for a few special ones: 38 | 39 | | Name | Description | 40 | | ---- | ----------- | 41 | | `name` | `--long` name on the command line | 42 | | `short` | `-s`hort name on the command line | 43 | | `type` | a class, which is parse and validate the value given by the user | 44 | | `type_params` | optional parameters for the type class | 45 | | `depends_on` | makes this argument depend on the presence of another one | 46 | | `positional` | indicates whether the argument is a `--keyword` one (default) or positional. Must not be supplied together with `required`. | 47 | | `prompt` | prompt the user for input, if the argument is not supplied. If the argument is `required`, it has to be set on the command line though. You can set this to _True_ to use the default prompt, or to a (non empty) _string_ to supply your own. The default prompt uses the `name` of the parameter. | 48 | | `prompt_options` | dictionary containing optional settings for the prompt (see below for more information). | 49 | 50 | All arguments to the script are passed to ansible as variables with the 51 | `param_`-prefix. This means that `--domain foo.com` becomes the variable 52 | `param_domain` with value `foo.com`. 53 | 54 | There are a few special variables to provide the playbook further 55 | details about the environment it's in: 56 | 57 | | Name | Description | 58 | | ---- | ----------- | 59 | | `sudo_user` | the user who executed the script originally. If the script is not configured to run as root, this variable does not exist. | 60 | | `script_name` | the filename of the script, which is currently executed (e.g. `uberspace-add-domain`) | 61 | 62 | ### Types 63 | 64 | In general the type-argument is mostly identical to the one supplied to 65 | the python function [`add_argument()`](https://docs.python.org/2/library/argparse.html#type). 66 | This means that all standard python types (e.g. `int`) can be used. Since 67 | the input validation is quite weak on these types, paternoster supplies 68 | a number of additional types. They can be referenced like `paternoster.types.`. 69 | 70 | #### `restricted_string` 71 | 72 | To enforce a certain level of security, all strings must be of the type 73 | `restricted_str`. This standard python `str` or `unicode` types may not 74 | be used. This forces the developer the make a choice about the characters, 75 | length and format of the given input. 76 | 77 | ```yml 78 | type: paternoster.types.restricted_str 79 | type_params: 80 | # Either `regex` or `allowed_chars` must be supplied, but not both. 81 | # anchored regular expression, which user input must match 82 | regex: "^[a-z][a-z0-9]+$" 83 | # regex character class, which contains all valid characters 84 | allowed_chars: a-zab 85 | # minimum length of a given input (defaults to 1), optional 86 | minlen: 5 87 | # maximum length of a given input (defaults to 255), optional 88 | maxlen: 30 89 | ``` 90 | 91 | #### `restricted_int` 92 | 93 | Integer which can optionally be restricted by a minimum as well as a maximum 94 | value. Both of these values are inclusive. 95 | 96 | ```yml 97 | type: paternoster.types.restricted_int 98 | type_params: 99 | minimum: 0 100 | maximum: 30 101 | ``` 102 | 103 | #### `domain` 104 | 105 | A fully qualified domain name with valid length, format and TLD. 106 | 107 | ```yml 108 | type: paternoster.types.domain 109 | type_params: 110 | # whether to allow domains like "*.domain.com", defaults to false 111 | wildcard: true 112 | maxlen: 255 113 | ``` 114 | 115 | Note that domains given with a trailing dot (e.g. `example.com.`) are normalized 116 | to their dot-less form (e.g. `example.com`). The `maxlen` parameter restricts 117 | simple string length before normalization. 118 | 119 | #### `uri` 120 | 121 | A Uniform Resource Identifier (URI), with its scheme (protocol), domain (host) 122 | and path. By default all of these parts are optional, defaulting to `''` or 123 | `/` (for path). This results in an empty string being a valid URI, representing 124 | a scheme-less, domain-less URI with path `/`. 125 | 126 | ```yml 127 | type: paternoster.types.uri 128 | type_params: 129 | # whether to allow domains without scheme/protocol, defaults to true 130 | optional_scheme: false 131 | # whether to allow domains without domain/host, defaults to true 132 | optional_domain: false 133 | # options to pass onto the domain type, defaults to {}, see respective docs 134 | domain_options: 135 | wildcard: true 136 | ``` 137 | 138 | The parsed components can be accessed as dictionary keys in the resulting value: 139 | 140 | ```json 141 | { 142 | "scheme": "https", 143 | "domain": "uberspace.de", 144 | "path": "/bla", 145 | "full": "https://uberspace.de/bla" 146 | } 147 | ``` 148 | 149 | As noted above, all components not present in the original URI will have a value 150 | of `''`, except for `path` which will default to `/`. 151 | 152 | ### Dependencies 153 | 154 | In some cases a parameter may need another one to function correctly. A 155 | real-life example of this might be the `--namespace` parameter, which 156 | depends on the `--mailserver` parameter in `uberspace-add-domain`. Such 157 | a dependency can be expressed using the `depends`-option of a pararmeter: 158 | 159 | ```yml 160 | parameters: 161 | - name: mailserver 162 | short: m 163 | help: add domain to the mailserver configuration 164 | action: store_true 165 | - name: namespace 166 | short: e 167 | help: use this namespace when adding a mail domain 168 | type: paternoster.types.restricted_str 169 | type_params: 170 | allowed_chars: a-z0-9 171 | depends_on: mailserver 172 | ``` 173 | 174 | ### Mutually Exclusive Parameters 175 | 176 | Mutually exclusive parameters may never be given together by the caller (e.g. 177 | `--debug` and `--quiet`). It is possible to specify any number of parameter 178 | groups using the `mutually_exclusive` key: 179 | 180 | ```yml 181 | - hosts: paternoster 182 | vars: 183 | description: Do something 184 | parameters: 185 | - name: debug 186 | short: d 187 | action: store_true 188 | - name: quiet 189 | short: q 190 | action: store_true 191 | mutually_exclusive: 192 | - ["debug", "quiet"] 193 | ``` 194 | 195 | With this configuration, the script may be invoked with `--debug` or `--quiet` 196 | alone, but never with both: `--debug --quiet`. 197 | 198 | ### (at least) one of a group 199 | 200 | Defines parameter groups of which at least one parameter must be given (e.g. 201 | `--webserver` or `--mailserver` when adding a domain). This can be combined with 202 | `mutually_exclusive` to specify a group, of which exactly one must be given. It 203 | is possible to specify any number of parameter groups using the 204 | `required_one_of` key: 205 | 206 | ```yml 207 | - hosts: paternoster 208 | vars: 209 | description: Do something 210 | parameters: 211 | - name: webserver 212 | short: w 213 | action: store_true 214 | - name: mailserver 215 | short: m 216 | action: store_true 217 | required_one_of: 218 | - ["webserver", "mailserver"] 219 | ``` 220 | 221 | With this configuration, the script may be invoked with `--webserver`, 222 | `--mailserver`, or `--webserver --mailserver`, but never without any arguments. 223 | 224 | ### Prompt Options 225 | 226 | | Name | Description | 227 | | ---- | ----------- | 228 | | `accept_empty` | if _True_, allow empty input. The default is to keep prompting until a non empty input is recieved. | 229 | | `confirm` | ask for input confirmation (user has to repeat the entry). You can set this to _True_ to use the default prompt, or to a (non empty) _string_ to supply your own. 230 | | `confirm_error` | use this (non empty) _string_ as error message if confirmation fails, instead of the default. | 231 | | `no_echo` | if _True_: don't echo the user input on the screen. | 232 | | `strip` | if _True_, remove whitespace at the start and end of the user input. | 233 | 234 | ## Status Reporting 235 | 236 | There are multiple ways to let the user know, what's going on: 237 | 238 | ### Failure 239 | 240 | You can use the [`fail`-module](http://docs.ansible.com/ansible/fail_module.html) 241 | to display a error message to the user. The `msg` option will be written 242 | to stderr as-is, followed by an immediate exit of the script with exit- 243 | code `1`. 244 | 245 | ### Success 246 | 247 | To display a customized message when the playbook executes successfully 248 | just set the `success_msg`-variable, as demonstrated in the boilerplate above. 249 | The message will be written to stdout as-is. 250 | 251 | ### Progress Messages 252 | 253 | If you want to inform the user about the current task your playbook is 254 | executing, you can use the [`debug`-module](http://docs.ansible.com/ansible/debug_module.html). 255 | All messages sent by this module are written to stdout as-is. Note that 256 | only messages with the default `verbosity` value will be shown. All 257 | other verbosity-levels can be used for actual debugging. 258 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Paternoster [![Build Status](https://travis-ci.org/Uberspace/paternoster.svg?branch=master)](https://travis-ci.org/Uberspace/paternoster) 2 | 3 | 4 | 5 | Paternoster enables ansible playbooks to be run like normal bash or python 6 | scripts. It parses the given parameters using python's [argparse][] and the 7 | passes them on to the actual playbook via the ansible API. In addition it 8 | provides an automated way to run commands as another user, which can be used to 9 | give normal shell users special privileges, while still having a sleek and easy 10 | to understand user interface. 11 | 12 | Ansible 2.1.x to 2.10.x as well as python 2.7 to 3.8 is supported and tested 13 | automatically. We recommend using ansible 2.8+ and python 3.6+. 14 | 15 | Once everything is set up, a paternoster script can be used like this: 16 | 17 | ``` 18 | $ create-user --help 19 | usage: create-user [-h] -u USERNAME [-v] 20 | 21 | Create a user. 22 | 23 | required arguments: 24 | -u USERNAME, --username USERNAME 25 | name of the user to create 26 | 27 | optional arguments: 28 | -h, --help show this help message and exit 29 | -v, --verbose run with a lot of debugging output 30 | $ create-user -u luto 31 | creating user luto 32 | ``` 33 | 34 | The script looks like a normal ansible playbook, except for a few additions. 35 | Firstly, it uses a different shebang-line, which kicks off paternoster instead 36 | of ansible. Secondly, there is a special play at the beginning of the playbook, 37 | which contains the configuration for parameter parsing and other features. 38 | 39 | ```yaml 40 | #!/usr/bin/env paternoster 41 | 42 | - hosts: paternoster 43 | vars: 44 | description: Create a user. 45 | parameters: 46 | - name: username 47 | short: u 48 | help: "name of the user to create" 49 | type: paternoster.types.restricted_str 50 | required: yes 51 | type_params: 52 | regex: "^[a-z]+$" 53 | 54 | - hosts: localhost 55 | tasks: 56 | - debug: msg="creating user {{ param_username }}" 57 | ``` 58 | 59 | For more information on how to develop scripts using paternoster, please refer 60 | the the corresponding sub-document: 61 | [`doc/script_development.md`][docs-script_dev]. 62 | 63 | ## Privilege Escalation 64 | 65 | Paternoster also provides an automated way to run commands as another user. To 66 | use this feature, set the `become_user` to the desired username. This causes 67 | paternoster to execute itself as the given user using _sudo_. For this to work a 68 | sudoers-config has to be created by the developer. 69 | 70 | Please refer to the Deployment section of this document for further details. 71 | 72 | # Deployment 73 | 74 | ## Python-Module 75 | 76 | The python module can be installed using pip: `pip install paternoster`. 77 | 78 | Note that this is the only distribution packaged by us. We do not and cannot 79 | check the content of all other, following methods. 80 | 81 | ## Fedora 82 | 83 | Paternoster is also available [as a Fedora package](https://src.fedoraproject.org/rpms/paternoster). 84 | 85 | ``` 86 | dnf install paternoster 87 | ``` 88 | 89 | ## RHEL/CentOS 90 | 91 | Paternoster is also available in [EPEL](https://fedoraproject.org/wiki/EPEL) for RHEL7, RHEL8, CentOS7 and CentOS8. 92 | 93 | ``` 94 | yum install epel-release 95 | yum install paternoster 96 | ``` 97 | 98 | ## AUR 99 | 100 | Paternoster is also available [as an AUR package](https://aur.archlinux.org/packages/paternoster/) 101 | for arch linux. 102 | 103 | ## sudo 104 | 105 | If you are planning to let users execute certain commands as root, 106 | a few changes to your `sudo`-configuration are needed. boils down to: 107 | 108 | ``` 109 | ALL ALL = NOPASSWD: /usr/local/bin/your-script-name 110 | ``` 111 | 112 | This line allows _any_ user to execute the given command as root. 113 | 114 | Please refer to the [`sudoers(5)`-manpage][man-sudoers] for details. 115 | 116 | ## Notes 117 | 118 | - This library makes use of the [tldextract-module][]. Internally this 119 | relies on a list of top level domains, which changes every so often. 120 | Execute the `tldextract --update`-command as root in a _cronjob_ or 121 | similar to keep the list up to date. 122 | 123 | # Library-Development 124 | 125 | Most tasks can be achieved by writing scripts only. Therefore, the library does 126 | not need to be changed in most cases. Sometimes it might be desirable to provide 127 | a new type or feature to all other scripts. To fulfill these needs, the 128 | following section outlines the setup and development process for library. 129 | 130 | ## Setup 131 | 132 | To get a basic environment up and running, use the following commands: 133 | 134 | ```shell 135 | virtualenv venv --python python2.7 136 | source venv/bin/activate 137 | python setup.py develop 138 | pip install -r requirements.txt 139 | pre-commit install --overwrite --install-hooks 140 | ``` 141 | 142 | This project uses _Python_ `2.7`, because _Python_ `3.x` is not yet supported by 143 | ansible. All non-ansible code is tested with python 3 as well. 144 | 145 | ### Vagrant 146 | 147 | Most features, where unit tests suffice can be tested using a _virtualenv_ only. 148 | If your development relies on the sudo-mechanism, you can spin up a [vagrant 149 | VM][] which provides a dummy `uberspace-add-domain`-script as well as the 150 | library-code in the `/vagrant`-directory. 151 | 152 | ```shell 153 | vagrant up 154 | vagrant ssh 155 | ``` 156 | 157 | And inside the host: 158 | 159 | ``` 160 | Last login: Wed Aug 3 17:23:02 2016 from 10.0.2.2 161 | [vagrant@localhost ~]$ uberspace-add-domain -d a.com -v 162 | 163 | PLAY [test play] ************** (...) 164 | ``` 165 | 166 | If you want to add your own scripts, just add the corresponding files in 167 | `vagrant/files/scripts`. You can deploy it using the following command: 168 | `ansible-playbook vagrant/site.yml --tags scripts`. Once your script has been 169 | deployed, you can just edit the source file to make further changes, as the file 170 | is symlinked, not copied. 171 | 172 | ## Tests 173 | 174 | ### Linter 175 | 176 | To lint the source code, you can run: 177 | 178 | ```shell 179 | tox -e lint 180 | ``` 181 | 182 | ### Unit Tests 183 | 184 | The core functionality of this library can be tested using the `tox`- command. 185 | If only _Python_ `2.x` or `3.x` should be tested, the `-e` parameter can be 186 | used, like so: `tox -e py36-ansible23`, `tox -e py27-ansible22`. New tests 187 | should be added to the `paternoster/test`-directory. Please refer to the 188 | [pytest-documentation][] for further details. 189 | 190 | NOTE: you might need to install the the proper _"devel"_ package, for the Python 191 | versions you want to test. 192 | 193 | ### Integration Tests 194 | 195 | Some features (like the `become_user` function) require a correctly setup Linux 196 | environment. They can be tested using the provided ansible playbooks in 197 | `vagrant/tests`. 198 | 199 | The playbooks can be invoked using the `run_integration_tests.py`-utility: 200 | 201 | ``` 202 | $ ./vagrant/run_integration_tests.py --file test_variables.yml 203 | === running test_variables.yml with ansible>=2.1,<2.2 204 | === running test_variables.yml with ansible>=2.2,<2.3 205 | === running test_variables.yml with ansible>=2.3,<2.4 206 | $ ./vagrant/run_integration_tests.py ansible22 --file test_variables.yml 207 | === running test_variables.yml with ansible>=2.2,<2.3 208 | $ ./vagrant/run_integration_tests.py --help 209 | usage: run_integration_tests.py [-h] [--file FILE] 210 | [{ansible21,ansible22,ansible23,all}] 211 | 212 | Run paternoster integration tests. 213 | 214 | (...) 215 | ``` 216 | 217 | #### Boilerplate 218 | 219 | A typical [ansible playbook][] for a system-test might look like this: 220 | 221 | ```yaml 222 | - name: give this test a proper name 223 | hosts: all 224 | tasks: 225 | - include: drop_script.yml 226 | vars: 227 | ignore_script_errors: yes 228 | script_params: --some-parameter 229 | playbook: | 230 | - hosts: all 231 | tasks: 232 | - debug: msg="hello world" 233 | script: | 234 | #!/bin/env python2.7 235 | # some python code to test 236 | 237 | - assert: 238 | that: 239 | - "script.stdout_lines[0] == 'something'" 240 | ``` 241 | 242 | Most of the heavy lifting is done by the included `drop_script.yml`-file. It 243 | creates the required python-script & playbook, executes it and stores the result 244 | in the `script`-variable. After the execution, all created files are removed. 245 | After the script has been executed, the [`assert`-module][ansible-assert-module] 246 | can be used to check the results. 247 | 248 | There are several parameters to control the behavior of `drop_script.yml`: 249 | 250 | | Name | Optional | Description | 251 | | ---------------------- | ---------------------- | --------------------------------------------------------------------------------------------- | 252 | | `script` | no | the **content** of a python script to save as `/usr/local/bin/uberspace-unittest` and execute | 253 | | `playbook` | yes (default: empty) | the **content** of a playbook to save as `/opt/uberspace/playbooks/uberspace-unittest.yml` | 254 | | `ignore_script_errors` | yes (default: `false`) | whether to continue even if python script has a non-zero exitcode | 255 | | `script_params` | yes (default: empty) | command line parameters for the script (e.g. `"--domain foo.com"`) | 256 | 257 | ## Releasing a new version 258 | 259 | Assuming you have been handed the required credentials, a new version can be 260 | released as follows. 261 | 262 | 1. adapt the version in `setup.py`, according to [semver](http://semver.org/) 263 | 2. commit this change as `Version 1.2.3` 264 | 3. tag the resulting commit as `v1.2.3` 265 | 4. push the new tag as well as the `master` branch 266 | 5. update the package on PyPI: 267 | 268 | ```shell 269 | rm dist/* 270 | python setup.py sdist bdist_wheel 271 | twine upload dist/* 272 | ``` 273 | 274 | # License 275 | 276 | All code in this repository (including this document) is licensed under the MIT 277 | license. The logo (both the _png_ and _svg_ versions) is licensed unter the 278 | [CC-BY-NC-ND 4.0][] license. 279 | 280 | [ansible playbook]: http://docs.ansible.com/ansible/playbooks.html 281 | [ansible-assert-module]: http://docs.ansible.com/ansible/assert_module.html 282 | [argparse]: https://docs.python.org/2/library/argparse.html 283 | [cc-by-nc-nd 4.0]: https://creativecommons.org/licenses/by-nc-nd/4.0/ 284 | [docs-script_dev]: doc/script_development.md 285 | [man-sudoers]: https://www.sudo.ws/man/1.8.17/sudoers.man.html 286 | [pytest-documentation]: http://doc.pytest.org/ 287 | [semver]: http://semver.org/ 288 | [tldextract-module]: https://github.com/john-kurkowski/tldextract 289 | [vagrant-vm]: https://vagrantup.com/ 290 | -------------------------------------------------------------------------------- /paternoster/test/test_parameters.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import pytest 3 | import six 4 | 5 | from .. import Paternoster 6 | from .. import types 7 | from .mockrunner import MockRunner 8 | 9 | 10 | @pytest.mark.parametrize("args,valid", [ 11 | ([], True), 12 | (['-m'], True), 13 | (['-e', 'aa'], False), 14 | (['-m', '-e', 'aa'], True), 15 | (['-m', '--namespace', 'aa'], True), 16 | (['--mailserver', '-e', 'aa'], True), 17 | (['--namespace', 'a', '--mailserver'], True), 18 | ]) 19 | def test_parameter_depends(args, valid): 20 | s = Paternoster( 21 | runner_parameters={'playbook': ''}, 22 | parameters=[ 23 | { 24 | 'name': 'mailserver', 'short': 'm', 25 | 'help': '', 'action': 'store_true', 26 | 'dest': 'bla', 27 | }, 28 | { 29 | 'name': 'namespace', 'short': 'e', 30 | 'help': '', 'type': types.restricted_str('a'), 'depends_on': 'mailserver', 31 | }, 32 | ], 33 | ) 34 | 35 | if not valid: 36 | with pytest.raises(SystemExit): 37 | s.parse_args(args) 38 | else: 39 | s.parse_args(args) 40 | 41 | 42 | @pytest.mark.parametrize("args,valid", [ 43 | ([], True), 44 | (['--dummy'], True), 45 | (['--mailserver'], True), 46 | (['--webserver'], True), 47 | (['--mailserver', '--dummy'], True), 48 | (['--mailserver', '--webserver'], False), 49 | (['--webserver', '--dummy'], False), 50 | (['-w'], True), 51 | (['-m', '-w'], False), 52 | (['-m', '--webserver'], False), 53 | ]) 54 | def test_parameter_mutually_exclusive(args, valid): 55 | s = Paternoster( 56 | runner_parameters={'playbook': ''}, 57 | parameters=[ 58 | { 59 | 'name': 'mailserver', 'short': 'm', 60 | 'help': '', 'action': 'store_true', 61 | }, 62 | { 63 | 'name': 'webserver', 'short': 'w', 64 | 'help': '', 'action': 'store_true', 65 | }, 66 | { 67 | 'name': 'dummy', 'short': 'd', 68 | 'help': '', 'action': 'store_true', 69 | 'dest': 'foo', 70 | }, 71 | ], 72 | mutually_exclusive=[ 73 | ['mailserver', 'webserver'], 74 | ['dummy', 'webserver'], 75 | ] 76 | ) 77 | 78 | if not valid: 79 | with pytest.raises(SystemExit): 80 | s.parse_args(args) 81 | else: 82 | s.parse_args(args) 83 | 84 | 85 | @pytest.mark.parametrize("args,valid", [ 86 | ([], True), 87 | (['--mailserver'], True), 88 | (['--webserver'], True), 89 | (['--webserver', '--mailserver'], False), 90 | ]) 91 | def test_parameter_mutually_exclusive_dest(args, valid): 92 | s = Paternoster( 93 | runner_parameters={'playbook': ''}, 94 | parameters=[ 95 | {'name': 'mailserver', 'action': 'store_true', 'dest': 'server'}, 96 | {'name': 'webserver', 'action': 'store_true', 'dest': 'server'}, 97 | ], 98 | mutually_exclusive=[ 99 | ['mailserver', 'webserver'], 100 | ] 101 | ) 102 | 103 | if not valid: 104 | with pytest.raises(SystemExit): 105 | s.parse_args(args) 106 | else: 107 | s.parse_args(args) 108 | 109 | 110 | def test_parameter_dest(): 111 | s = Paternoster( 112 | runner_parameters={'playbook': ''}, 113 | parameters=[ 114 | {'name': 'mailserver', 'action': 'store_const', 'const': 'mail', 'dest': 'server'}, 115 | {'name': 'webserver', 'action': 'store_const', 'const': 'web', 'dest': 'server'}, 116 | ], 117 | ) 118 | 119 | s.parse_args([]) 120 | assert hasattr(s._parsed_args, 'server') 121 | assert s._parsed_args.server is None 122 | assert not hasattr(s._parsed_args, 'mailserver') 123 | 124 | s.parse_args(['--webserver']) 125 | assert hasattr(s._parsed_args, 'server') 126 | assert s._parsed_args.server == 'web' 127 | assert not hasattr(s._parsed_args, 'mailserver') 128 | 129 | s.parse_args(['--mailserver']) 130 | assert hasattr(s._parsed_args, 'server') 131 | assert s._parsed_args.server == 'mail' 132 | assert not hasattr(s._parsed_args, 'mailserver') 133 | 134 | 135 | def test_parameter_dashed(): 136 | s = Paternoster( 137 | runner_parameters={'playbook': ''}, 138 | parameters=[ 139 | {'name': 'mail-server', 'action': 'store_true'}, 140 | ], 141 | ) 142 | s.parse_args(['--mail-server']) 143 | 144 | 145 | @pytest.mark.parametrize("args,valid", [ 146 | ([], False), 147 | (['--dummy'], False), 148 | (['--mailserver'], True), 149 | (['--webserver'], True), 150 | (['--mailserver', '--webserver'], True), 151 | (['--mailserver', '--dummy'], True), 152 | ]) 153 | def test_parameter_required_one_of(args, valid): 154 | s = Paternoster( 155 | runner_parameters={'playbook': ''}, 156 | parameters=[ 157 | { 158 | 'name': 'mailserver', 'short': 'm', 159 | 'help': '', 'action': 'store_true', 160 | }, 161 | { 162 | 'name': 'webserver', 'short': 'w', 163 | 'help': '', 'action': 'store_true', 164 | 'dest': 'foo', 165 | }, 166 | { 167 | 'name': 'dummy', 'short': 'd', 168 | 'help': '', 'action': 'store_true', 169 | }, 170 | ], 171 | required_one_of=[ 172 | ['mailserver', 'webserver'], 173 | ] 174 | ) 175 | 176 | if not valid: 177 | with pytest.raises(SystemExit): 178 | s.parse_args(args) 179 | else: 180 | s.parse_args(args) 181 | 182 | 183 | def test_find_param(): 184 | s = Paternoster( 185 | runner_parameters={}, 186 | parameters=[ 187 | {'name': 'mailserver', 'short': 'm', 'type': types.restricted_str('a')}, 188 | {'name': 'namespace', 'short': 'e', 'action': 'store_true'}, 189 | ], 190 | runner_class=MockRunner 191 | ) 192 | 193 | assert s._find_param('e')['name'] == 'namespace' 194 | assert s._find_param('m')['name'] == 'mailserver' 195 | assert s._find_param('namespace')['short'] == 'e' 196 | assert s._find_param('mailserver')['short'] == 'm' 197 | 198 | with pytest.raises(KeyError): 199 | s._find_param('somethingelse') 200 | 201 | 202 | class FakeStr(str): 203 | pass 204 | 205 | 206 | cases_mandatory_parameters = [ 207 | # parameters, parses okay with empty args 208 | ({}, False), 209 | ({'type': str}, False), 210 | ({'type': FakeStr}, False), 211 | ({'type': types.restricted_str('a')}, True), 212 | ({'type': lambda x: x}, True), 213 | ({'choices': ['a', 'b']}, True), 214 | ({'action': 'store_true'}, True), 215 | ({'action': 'store_false'}, True), 216 | ({'action': 'store_const', 'const': 5}, True), 217 | ({'action': 'append'}, False), 218 | ({'action': 'append', 'type': str}, False), 219 | ({'action': 'append', 'type': types.restricted_str('a')}, True), 220 | ({'action': 'append_const', 'const': 5}, True), 221 | ] 222 | 223 | if six.PY2: 224 | cases_mandatory_parameters += [ 225 | ({'type': unicode}, False), # noqa F821 226 | ] 227 | 228 | 229 | @pytest.mark.parametrize("param,valid", cases_mandatory_parameters) 230 | def test_type_mandatory(param, valid): 231 | p = {'name': 'namespace', 'short': 'e'} 232 | p.update(param) 233 | s = Paternoster( 234 | runner_parameters={'playbook': ''}, 235 | parameters=[p], 236 | ) 237 | 238 | if not valid: 239 | with pytest.raises(ValueError): 240 | s.parse_args([]) 241 | else: 242 | s.parse_args([]) 243 | 244 | 245 | @pytest.mark.parametrize("param,valid", filter(lambda x: x is not None, [ 246 | ({'positional': True, 'type': types.restricted_str('a')}, True), 247 | ({'positional': True, 'type': types.restricted_str('a'), 'required': True}, False), 248 | ({'positional': True, 'type': types.restricted_str('a'), 'required': False}, False), 249 | ])) 250 | def test_positional(param, valid): 251 | p = {'name': 'namespace', 'short': 'e'} 252 | p.update(param) 253 | s = Paternoster( 254 | runner_parameters={'playbook': ''}, 255 | parameters=[p], 256 | ) 257 | 258 | if not valid: 259 | with pytest.raises(TypeError): 260 | s.parse_args(['aaa']) 261 | else: 262 | s.parse_args(['aaa']) 263 | 264 | 265 | @pytest.mark.parametrize("required,argv,valid", [ 266 | (True, ['-e', 'aa'], True), 267 | (True, [], False), 268 | (False, ['-e', 'aa'], True), 269 | (False, [], True), 270 | ]) 271 | def test_parameter_required(required, argv, valid): 272 | s = Paternoster( 273 | runner_parameters={}, 274 | parameters=[ 275 | {'name': 'namespace', 'short': 'e', 'type': types.restricted_str('a'), 'required': required}, 276 | ], 277 | runner_class=MockRunner 278 | ) 279 | 280 | if not valid: 281 | with pytest.raises(SystemExit): 282 | s.parse_args(argv) 283 | else: 284 | s.parse_args(argv) 285 | 286 | 287 | def test_arg_parameter_no_short(): 288 | s = Paternoster( 289 | runner_parameters={}, 290 | parameters=[ 291 | {'name': 'namespace', 'type': types.restricted_str('a')}, 292 | ], 293 | runner_class=MockRunner, 294 | ) 295 | s.parse_args(['--namespace', 'aa']) 296 | assert s._parsed_args.namespace == 'aa' 297 | 298 | 299 | def test_parameter_passing(): 300 | s = Paternoster( 301 | runner_parameters={}, 302 | parameters=[ 303 | {'name': 'namespace', 'short': 'e', 'type': types.restricted_str('a')}, 304 | ], 305 | runner_class=MockRunner 306 | ) 307 | s.parse_args(['-e', 'aaaa']) 308 | s.execute() 309 | 310 | assert dict(s._runner.args[0])['param_namespace'] == 'aaaa' 311 | 312 | 313 | def test_parameter_passing_unicode(): 314 | s = Paternoster( 315 | runner_parameters={}, 316 | parameters=[ 317 | {'name': 'namespace', 'short': 'e', 'type': types.restricted_str('ä')}, 318 | ], 319 | runner_class=MockRunner 320 | ) 321 | s.parse_args(['-e', 'ää']) 322 | s.execute() 323 | 324 | assert dict(s._runner.args[0])['param_namespace'] == u'ää' 325 | 326 | 327 | @pytest.mark.parametrize("value,valid", [ 328 | (1, True), 329 | (5, True), 330 | (60, True), 331 | (600, False), 332 | (6, False), 333 | (2, False), 334 | ("a", False), 335 | ]) 336 | def test_parameter_argparse(value, valid): 337 | s = Paternoster( 338 | runner_parameters={}, 339 | parameters=[ 340 | {'name': 'number', 'short': 'n', 'type': int, 'choices': [1, 5, 60]}, 341 | ], 342 | runner_class=MockRunner 343 | ) 344 | 345 | argv = ['-n', str(value)] 346 | 347 | if not valid: 348 | with pytest.raises(SystemExit): 349 | s.parse_args(argv) 350 | else: 351 | s.parse_args(argv) 352 | 353 | 354 | @pytest.mark.parametrize("success_msg,expected", [ 355 | ('4242', '4242\n'), 356 | ('', ''), 357 | (None, ''), 358 | ]) 359 | def test_success_msg(success_msg, expected, capsys): 360 | s = Paternoster( 361 | runner_parameters={}, 362 | parameters=[], 363 | runner_class=MockRunner, 364 | success_msg=success_msg, 365 | ) 366 | s.parse_args([]) 367 | s.execute() 368 | 369 | out, err = capsys.readouterr() 370 | assert out == expected 371 | 372 | 373 | @pytest.mark.parametrize("description,expected", [ 374 | ('Do things with stuff', 'Do things with stuff\n\n'), 375 | ('', ''), 376 | (None, ''), 377 | ]) 378 | def test_description(description, expected, capsys): 379 | s = Paternoster( 380 | runner_parameters={}, 381 | parameters=[], 382 | runner_class=MockRunner, 383 | description=description, 384 | ) 385 | with pytest.raises(SystemExit): 386 | s.parse_args(['--help']) 387 | 388 | out, err = capsys.readouterr() 389 | exp_help_text = 'usage: py.test [-h] [-v]\n\n{expected}optional arguments:' 390 | assert out.startswith(exp_help_text.format(expected=expected)) 391 | 392 | 393 | def test_arg_parameters_none(): 394 | s = Paternoster( 395 | runner_parameters={}, 396 | parameters=None, 397 | runner_class=MockRunner, 398 | ) 399 | assert s._parameters == [] 400 | 401 | 402 | def test_arg_parameters_missing(): 403 | s = Paternoster( 404 | runner_parameters={}, 405 | runner_class=MockRunner, 406 | ) 407 | assert s._parameters == [] 408 | -------------------------------------------------------------------------------- /paternoster/paternoster.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | from __future__ import print_function 3 | 4 | import argparse 5 | import getpass 6 | import inspect 7 | import os.path 8 | import sys 9 | 10 | import six 11 | 12 | from .root import become_user 13 | from .root import check_user 14 | from .runners.ansiblerunner import AnsibleRunner 15 | 16 | 17 | class Paternoster: 18 | def __init__(self, 19 | runner_parameters, 20 | parameters=None, 21 | mutually_exclusive=None, 22 | required_one_of=None, 23 | become_user=None, check_user=None, 24 | success_msg=None, 25 | description=None, 26 | runner_class=AnsibleRunner, 27 | ): 28 | if parameters is None: 29 | self._parameters = [] 30 | else: 31 | self._parameters = parameters 32 | self._mutually_exclusive = mutually_exclusive or [] 33 | self._required_one_of = required_one_of or [] 34 | self._become_user = become_user 35 | self._check_user = check_user 36 | self._success_msg = success_msg 37 | self._description = description 38 | self._sudo_user = None 39 | self._runner = runner_class(**runner_parameters) 40 | 41 | def _find_param(self, fname): 42 | """ look for a parameter by either its short- or long-name """ 43 | for param in self._parameters: 44 | if param['name'] == fname or param.get('short', None) == fname: 45 | return param 46 | 47 | raise KeyError('Parameter {0} could not be found'.format(fname)) 48 | 49 | def _get_param_val(self, args, fname): 50 | """ get the value of a parameter, named by either its short- or long-name """ 51 | param = self._find_param(fname) 52 | name = param['name'].replace('-', '_') 53 | return getattr(args, name) 54 | 55 | def _check_type(self, argParams): 56 | """ assert that given argument uses restricted_str in place of str/unicode, else raise ValueError """ 57 | action_whitelist = ('store_true', 'store_false', 'store_const', 'append_const', 'count') 58 | action = argParams.get('action', 'store') 59 | 60 | has_choices = ('choices' in argParams) 61 | is_whitelist_action = (action in action_whitelist) 62 | 63 | if is_whitelist_action: # the passed value is hardcoded by the dev 64 | return 65 | if has_choices: # there is a whitelist of valid values 66 | return 67 | 68 | argtype = argParams.get('type', str) 69 | has_type = ('type' in argParams) 70 | is_raw_string = (inspect.isclass(argtype) and issubclass(argtype, six.string_types)) 71 | 72 | if not has_type: 73 | raise ValueError('a type must be specified for each user-supplied argument') 74 | if is_raw_string: 75 | raise ValueError('restricted_str instead of str or unicode must be used for all string arguments') 76 | 77 | def _convert_type(sefl, argParams): 78 | param_type = argParams.pop('type', None) 79 | param_type_params = argParams.pop('type_params', {}) 80 | 81 | if isinstance(param_type, str): 82 | if param_type == 'int': 83 | argParams['type'] = int 84 | elif param_type == 'str': 85 | argParams['type'] = str 86 | elif param_type.startswith('paternoster.types.'): 87 | type_clazz = getattr(sys.modules['paternoster.types'], param_type.rpartition('.')[2]) 88 | argParams['type'] = type_clazz(**param_type_params) 89 | else: 90 | raise Exception('unknown type ' + param_type) 91 | elif param_type: 92 | argParams['type'] = param_type 93 | 94 | def _build_argparser(self): 95 | parser = argparse.ArgumentParser( 96 | add_help=False, 97 | description=self._description, 98 | ) 99 | requiredArgs = parser.add_argument_group('required arguments') 100 | optionalArgs = parser.add_argument_group('optional arguments') 101 | 102 | optionalArgs.add_argument( 103 | '-h', '--help', action='help', default=argparse.SUPPRESS, 104 | help='show this help message and exit' 105 | ) 106 | 107 | for param in self._parameters: 108 | argParams = param.copy() 109 | argParams.pop('depends_on', None) 110 | argParams.pop('positional', None) 111 | argParams.pop('short', None) 112 | argParams.pop('name', None) 113 | argParams.pop('prompt', None) 114 | argParams.pop('prompt_options', None) 115 | 116 | # remove dest here so the actual argument names are preserved 117 | argParams.pop('dest', None) 118 | 119 | self._convert_type(argParams) 120 | self._check_type(argParams) 121 | 122 | if 'name' not in param: 123 | raise Exception('Parameter without name given: {}'.format(param)) 124 | 125 | if param.get('positional', False): 126 | paramName = [param['name']] 127 | else: 128 | if 'short' in param: 129 | paramName = ['-' + param['short'], '--' + param['name']] 130 | else: 131 | paramName = ['--' + param['name']] 132 | 133 | if param.get('required', False) or param.get('positional', False): 134 | if param.get('prompt'): 135 | parser.error(( 136 | "'--{}' is required and can't be combined with prompt" 137 | ).format( 138 | param['name'], 139 | )) 140 | requiredArgs.add_argument(*paramName, **argParams) 141 | else: 142 | optionalArgs.add_argument(*paramName, **argParams) 143 | 144 | optionalArgs.add_argument( 145 | '-v', '--verbose', action='count', default=0, 146 | help='run with a lot of debugging output' 147 | ) 148 | 149 | return parser 150 | 151 | def _prompt_for_missing(self, argv, parser, args): 152 | """ 153 | Return *args* after prompting the user for missing arguments. 154 | 155 | Prompts the user for arguments (`self._parameters`), that are missing 156 | from *args* (don't exist or are set to `None`). But only if they have 157 | the `prompt` key set to `True` or a non empty string. 158 | 159 | """ 160 | # get parameter dictionaries for missing arguments 161 | missing_params = ( 162 | param for param in self._parameters 163 | if param.get('prompt') 164 | and isinstance(param.get('prompt'), (bool, six.string_types)) 165 | and self._get_param_val(args, param['name']) is None 166 | ) 167 | 168 | # prompt for missing args 169 | prompt_data = { 170 | param['name']: self.get_input(param) for param in missing_params 171 | } 172 | 173 | # add prompt_data to new argv and return newly parsed arguments 174 | if prompt_data: 175 | argv = list(argv) if argv else sys.argv[1:] 176 | for name, value in prompt_data.items(): 177 | argv.append('--{}'.format(name)) 178 | argv.append(value) 179 | return parser.parse_args(argv) 180 | 181 | # return already parsed arguments 182 | else: 183 | return args 184 | 185 | def _argument_given(self, args, name): 186 | param = self._find_param(name) 187 | return self._get_param_val(args, param['name']) 188 | 189 | def _check_arg_dependencies(self, parser, args): 190 | for param in self._parameters: 191 | param_given = self._argument_given(args, param['name']) 192 | dependency_given = ('depends_on' not in param) or self._argument_given(args, param['depends_on']) 193 | 194 | if param_given and not dependency_given: 195 | parser.error( 196 | 'argument --{} requires --{} to be present.'.format(param['name'], param['depends_on']) 197 | ) 198 | 199 | def _check_arg_mutually_exclusive(self, parser, args): 200 | for group in self._mutually_exclusive: 201 | given_args = ['--' + a for a in group if self._argument_given(args, a)] 202 | 203 | if len(given_args) > 1: 204 | parser.error( 205 | 'arguments {} are mutually exclusive.'.format(', '.join(given_args)) 206 | ) 207 | 208 | def _check_arg_required_one_of(self, parser, args): 209 | for group in self._required_one_of: 210 | given_args = [True for a in group if self._argument_given(args, a)] 211 | 212 | if len(given_args) == 0: 213 | parser.error( 214 | 'at least one of {} is needed.'.format(', '.join('--' + x for x in group)) 215 | ) 216 | 217 | def _apply_dest(self, args): 218 | """ 219 | The dest attribute is removed earlier so the actual argument names are preserved for dependency checking. 220 | This renames all the arguments to their "dest" name, or leaves them as-is, if non is given. 221 | """ 222 | 223 | for param in self._parameters: 224 | if not param.get('dest'): 225 | continue 226 | 227 | name = param['name'] 228 | dest = param['dest'] 229 | value = self._get_param_val(args, name) 230 | 231 | if value is not None or not hasattr(args, dest): 232 | setattr(args, dest, value) 233 | delattr(args, name) 234 | 235 | def check_user(self): 236 | if not self._check_user: 237 | return 238 | if not check_user(self._check_user): 239 | print('This script can only be used by the user ' + self._check_user, file=sys.stderr) 240 | sys.exit(1) 241 | 242 | def become_user(self): 243 | if not self._become_user: 244 | return 245 | 246 | try: 247 | self._sudo_user = become_user(self._become_user) 248 | except ValueError as e: 249 | print(e, file=sys.stderr) 250 | sys.exit(1) 251 | 252 | def auto(self): 253 | self.check_user() 254 | self.become_user() 255 | self.parse_args() 256 | status = self.execute() 257 | sys.exit(0 if status else 1) 258 | 259 | def parse_args(self, argv=None): 260 | parser = self._build_argparser() 261 | try: 262 | args = parser.parse_args(argv) 263 | args = self._prompt_for_missing(argv, parser, args) 264 | self._check_arg_dependencies(parser, args) 265 | self._check_arg_mutually_exclusive(parser, args) 266 | self._check_arg_required_one_of(parser, args) 267 | self._apply_dest(args) 268 | self._parsed_args = args 269 | except ValueError as exc: 270 | print(exc, file=sys.stderr) 271 | sys.exit(3) 272 | 273 | def _get_runner_variables(self): 274 | if self._sudo_user: 275 | yield ('sudo_user', self._sudo_user) 276 | 277 | yield ('script_name', os.path.basename(sys.argv[0])) 278 | 279 | for name in vars(self._parsed_args): 280 | value = getattr(self._parsed_args, name) 281 | if six.PY2 and isinstance(value, str): 282 | value = value.decode('utf-8') 283 | yield ('param_' + name, value) 284 | 285 | def execute(self): 286 | status = self._runner.run(self._get_runner_variables(), self._parsed_args.verbose) 287 | if status and self._success_msg: 288 | print(self._success_msg) 289 | return status 290 | 291 | @staticmethod 292 | def prompt(text, no_echo=False): 293 | """ 294 | Return user input from a prompt with *text*. 295 | 296 | If *no_echo* is set, :func:`getpass.getpass` is used to prevent echoing 297 | of the user input. Exits gracefully on keyboard interrupts (with return 298 | code 3). 299 | 300 | """ 301 | try: 302 | if no_echo: 303 | user_input = getpass.getpass(text) 304 | else: 305 | try: 306 | user_input = raw_input(text) # Python 2 307 | except NameError: 308 | user_input = input(text) # Python 3 309 | return user_input 310 | except KeyboardInterrupt: 311 | sys.exit(3) 312 | 313 | @staticmethod 314 | def get_input(param): 315 | """ 316 | Return user input for *param*. 317 | 318 | The `param['name']` item needs to be set. The text for the prompt is 319 | taken from `param['prompt']`, if available and a non empty string. 320 | Otherwise `param['name']` is used. Also you can set additional 321 | arguments in `param['prompt_options']`: 322 | 323 | :accept_empty: if `True`: allows empty input 324 | :confirm: if `True` or string: prompt user for confirmation 325 | :confirm_error: if string: used as confirmation error message 326 | :no_echo: if `True`: don't echo the user input on the screen 327 | :strip: if `True`: strips user input 328 | 329 | Raises: 330 | KeyError: if no `name` item is set for *param*. 331 | ValueError: if input and confirmation do not match. 332 | 333 | """ 334 | name = param['name'] 335 | prompt = param.get('prompt') 336 | options = param.get('prompt_options', {}) 337 | confirmation_prompt = options.get('confirm') 338 | accept_empty = options.get('accept_empty') 339 | no_echo = options.get('no_echo') 340 | strip = options.get('strip') 341 | 342 | # set prompt 343 | if not isinstance(prompt, six.string_types): 344 | prompt = '{}: '.format(name.title()) 345 | 346 | # set confirmation prompt 347 | ask_confirmation = ( 348 | confirmation_prompt 349 | and isinstance(confirmation_prompt, (bool, six.string_types)) 350 | ) 351 | if not isinstance(confirmation_prompt, six.string_types): 352 | confirmation_prompt = 'Please confirm: ' 353 | 354 | # get input 355 | while True: 356 | value = Paternoster.prompt(prompt, no_echo) 357 | if strip: 358 | value = value.strip() 359 | if value or accept_empty: 360 | break 361 | 362 | # confirm 363 | if ask_confirmation: 364 | confirmed_value = Paternoster.prompt(confirmation_prompt, no_echo) 365 | if value != confirmed_value: 366 | confirm_error = ( 367 | options.get('confirm_error') 368 | or 'ERROR: input does not match its confirmation' 369 | ) 370 | raise ValueError(confirm_error) 371 | 372 | return value 373 | -------------------------------------------------------------------------------- /paternoster/test/test_prompt.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | from __future__ import absolute_import 3 | from __future__ import print_function 4 | from __future__ import unicode_literals 5 | 6 | import io 7 | import sys 8 | 9 | try: 10 | from unittest.mock import patch 11 | except ImportError: 12 | try: 13 | from mock import patch 14 | except ImportError: 15 | patch = None 16 | 17 | import pytest 18 | 19 | from paternoster import Paternoster, types 20 | 21 | from .mockrunner import MockRunner 22 | 23 | 24 | class InputBuffer(object): 25 | 26 | def setup_method(self): 27 | self.stdin_org = sys.stdin 28 | 29 | def teardown_method(self): 30 | sys.stdin = self.stdin_org 31 | 32 | def buffer(self, text): 33 | sys.stdin = io.StringIO(text) 34 | 35 | 36 | class PaternosterHelper(object): 37 | 38 | DEFAULT_PARAM_NAME = 'username' 39 | 40 | def get_param(self, prompt=None, options={}, name=None, **kwargs): 41 | param = { 42 | 'name': name if (name is not None) else self.DEFAULT_PARAM_NAME, 43 | } 44 | if prompt: 45 | param['prompt'] = prompt 46 | if options: 47 | param['prompt_options'] = options 48 | if 'short' not in kwargs: 49 | param['short'] = param['name'][0] 50 | if 'type' not in kwargs: 51 | param['type'] = types.restricted_str(allowed_chars='a-z') 52 | param.update(kwargs) 53 | return param 54 | 55 | def get_paternoster(self, *parameters, **kwargs): 56 | defaults = { 57 | 'runner_parameters': {}, 58 | 'runner_class': MockRunner, 59 | 'parameters': parameters, 60 | } 61 | defaults.update(kwargs) 62 | return Paternoster(**defaults) 63 | 64 | 65 | class TestPrompt(PaternosterHelper, InputBuffer): 66 | 67 | PROMPT_TEXT = 'Enter: ' 68 | 69 | def test_prompt(self, capsys): 70 | p = self.get_paternoster() 71 | self.buffer('\n') 72 | p.prompt(self.PROMPT_TEXT) 73 | out, err = capsys.readouterr() 74 | assert out == self.PROMPT_TEXT 75 | 76 | def test_prompt_unicode(self, capsys): 77 | p = self.get_paternoster() 78 | self.buffer('\n') 79 | prompt = u'Entör!: ' 80 | p.prompt(prompt) 81 | out, err = capsys.readouterr() 82 | assert out == prompt 83 | 84 | @pytest.mark.skipif(patch is None, reason='test needs `mock`') 85 | def test_echo(self): 86 | """Check that `getpass` is called if *no_echo* is set.""" 87 | with patch('paternoster.paternoster.getpass.getpass') as mockfunc: 88 | p = self.get_paternoster() 89 | self.buffer('\n') 90 | p.prompt(self.PROMPT_TEXT) 91 | mockfunc.assert_not_called() 92 | with patch('paternoster.paternoster.getpass.getpass') as mockfunc: 93 | p = self.get_paternoster() 94 | self.buffer('\n') 95 | p.prompt(self.PROMPT_TEXT, no_echo=True) 96 | mockfunc.assert_called() 97 | 98 | def test_input(self): 99 | p = self.get_paternoster() 100 | self.buffer('hello world test') 101 | res = p.prompt(self.PROMPT_TEXT) 102 | assert res == 'hello world test' 103 | 104 | def test_input_unicode(self): 105 | p = self.get_paternoster() 106 | self.buffer('hello wörld test! ') 107 | res = p.prompt(self.PROMPT_TEXT) 108 | assert res == 'hello wörld test! ' 109 | 110 | def test_input_newlines(self): 111 | p = self.get_paternoster() 112 | self.buffer('hello\nworld\ntest') 113 | res = p.prompt(self.PROMPT_TEXT) 114 | assert res == 'hello' 115 | 116 | def test_input_empty(self): 117 | p = self.get_paternoster() 118 | self.buffer('\n') 119 | res = p.prompt(self.PROMPT_TEXT) 120 | assert res == '' 121 | 122 | 123 | class TestInput(PaternosterHelper, InputBuffer): 124 | 125 | DEFAULT_PARAM_NAME = 'username' 126 | 127 | def test_prompt_not_set(self, capsys): 128 | p = self.get_paternoster() 129 | self.buffer('test\n') 130 | param = self.get_param() 131 | p.get_input(param) 132 | out, err = capsys.readouterr() 133 | assert out == 'Username: ' 134 | 135 | def test_prompt_True(self, capsys): 136 | p = self.get_paternoster() 137 | self.buffer('test\n') 138 | param = self.get_param(prompt=True) 139 | p.get_input(param) 140 | out, err = capsys.readouterr() 141 | assert out == 'Username: ' 142 | 143 | def test_prompt_string(self, capsys): 144 | p = self.get_paternoster() 145 | self.buffer('test\n') 146 | param = self.get_param(prompt='foo') 147 | p.get_input(param) 148 | out, err = capsys.readouterr() 149 | assert out == 'foo' 150 | 151 | @pytest.mark.parametrize('value', [ 152 | None, 153 | False, 154 | 0, 155 | 1, 156 | [1, 2, 3], 157 | ]) 158 | def test_prompt_object(self, value, capsys): 159 | p = self.get_paternoster() 160 | self.buffer('test\n') 161 | param = self.get_param(prompt=value) 162 | p.get_input(param) 163 | out, err = capsys.readouterr() 164 | assert out == 'Username: ' 165 | 166 | def test_prompt_empty(self, capsys): 167 | p = self.get_paternoster() 168 | self.buffer('test\n') 169 | param = self.get_param(prompt='') 170 | p.get_input(param) 171 | out, err = capsys.readouterr() 172 | assert out == 'Username: ' 173 | 174 | @pytest.mark.parametrize('value,exp', [ 175 | ('xxx\n', 'xxx'), 176 | (' xxx\n', ' xxx'), 177 | (' xxx \n', ' xxx '), 178 | ('tes xxx\n', 'tes xxx'), 179 | ('tes xxx\n', 'tes xxx'), 180 | (' \n', ' '), 181 | (' \n', ' '), 182 | ('\n\nxtestx\n', 'xtestx'), 183 | ('\n\n\nxtestx\nol ol\n', 'xtestx'), 184 | ]) 185 | def test_input(self, value, exp): 186 | p = self.get_paternoster() 187 | self.buffer(value) 188 | param = self.get_param() 189 | res = p.get_input(param) 190 | assert res == exp 191 | 192 | def test_input_error_empty(self): 193 | p = self.get_paternoster() 194 | self.buffer('\n') 195 | with pytest.raises(EOFError): 196 | param = self.get_param() 197 | p.get_input(param) 198 | 199 | def test_input_error_empty_opt_strip(self): 200 | p = self.get_paternoster() 201 | self.buffer(' \n') 202 | with pytest.raises(EOFError): 203 | param = self.get_param(options=dict(strip=True)) 204 | p.get_input(param) 205 | 206 | @pytest.mark.parametrize('value,exp', [ 207 | ('xxx\n', 'xxx'), 208 | (' xxx\n', ' xxx'), 209 | (' xxx \n', ' xxx '), 210 | ('tes xxx\n', 'tes xxx'), 211 | ('tes xxx\n', 'tes xxx'), 212 | (' \n', ' '), 213 | (' \n', ' '), 214 | ('\n\nxtestx\n', ''), 215 | ('\n\n\nxtestx\nolol\n', ''), 216 | ('\n', ''), 217 | ]) 218 | def test_input_opt_accept_empty(self, value, exp): 219 | p = self.get_paternoster() 220 | self.buffer(value) 221 | param = self.get_param(options=dict(accept_empty=True)) 222 | res = p.get_input(param) 223 | assert res == exp 224 | 225 | @pytest.mark.parametrize('value,exp', [ 226 | ('xxx\n', 'xxx'), 227 | (' xxx\n', 'xxx'), 228 | (' xxx \n', 'xxx'), 229 | ('tes xxx\n', 'tes xxx'), 230 | ('tes xxx\n', 'tes xxx'), 231 | ]) 232 | def test_input_opt_strip(self, value, exp): 233 | p = self.get_paternoster() 234 | self.buffer(value) 235 | param = self.get_param(options=dict(strip=True)) 236 | res = p.get_input(param) 237 | assert res == exp 238 | 239 | @pytest.mark.parametrize('value,exp', [ 240 | ('xxx\n', 'xxx'), 241 | (' xxx\n', 'xxx'), 242 | (' xxx \n', 'xxx'), 243 | ('tes xxx\n', 'tes xxx'), 244 | ('tes xxx\n', 'tes xxx'), 245 | (' \n', ''), 246 | (' \n', ''), 247 | ('\n\nxtestx\n', ''), 248 | ('\n\n\nxtestx\nolol\n', ''), 249 | ('\n', ''), 250 | ]) 251 | def test_input_opt_accept_empty_strip(self, value, exp): 252 | p = self.get_paternoster() 253 | self.buffer(value) 254 | param = self.get_param(options=dict(accept_empty=True, strip=True)) 255 | res = p.get_input(param) 256 | assert res == exp 257 | 258 | def test_confirm_not_string(self, capsys): 259 | p = self.get_paternoster() 260 | self.buffer('test\ntest\n') 261 | param = self.get_param(options=dict(confirm=True)) 262 | p.get_input(param) 263 | out, err = capsys.readouterr() 264 | assert out == 'Username: Please confirm: ' 265 | 266 | def test_confirm_string(self, capsys): 267 | p = self.get_paternoster() 268 | self.buffer('test\ntest\n') 269 | param = self.get_param(options=dict(confirm='lol:')) 270 | p.get_input(param) 271 | out, err = capsys.readouterr() 272 | assert out == 'Username: lol:' 273 | 274 | def test_confirm(self): 275 | p = self.get_paternoster() 276 | self.buffer('test\ntest\n') 277 | param = self.get_param(options=dict(confirm=True)) 278 | res = p.get_input(param) 279 | assert res == 'test' 280 | 281 | def test_confirm_fail(self): 282 | p = self.get_paternoster() 283 | self.buffer('test\ntest2\n') 284 | with pytest.raises(ValueError): 285 | param = self.get_param(options=dict(confirm=True)) 286 | p.get_input(param) 287 | 288 | def test_confirm_fail_msg(self): 289 | p = self.get_paternoster() 290 | self.buffer('test\ntest2\n') 291 | with pytest.raises(ValueError) as excinfo: 292 | param = self.get_param(options=dict( 293 | confirm=True, confirm_error='lol' 294 | )) 295 | p.get_input(param) 296 | assert str(excinfo.value) == u'lol' 297 | 298 | def test_echo(self): 299 | with patch('paternoster.paternoster.getpass.getpass') as mockfunc: 300 | p = self.get_paternoster() 301 | self.buffer('elo\n') 302 | param = self.get_param() 303 | p.get_input(param) 304 | mockfunc.assert_not_called() 305 | with patch('paternoster.paternoster.getpass.getpass') as mockfunc: 306 | p = self.get_paternoster() 307 | self.buffer('elo part two\n') 308 | param = self.get_param(options=dict(no_echo=True)) 309 | p.get_input(param) 310 | mockfunc.assert_called() 311 | 312 | 313 | class TestInputSignalHandling(PaternosterHelper): 314 | 315 | def test_exit_on_keyboard_interrupt(self): 316 | p = self.get_paternoster() 317 | param = self.get_param() 318 | if sys.version_info[0] == 2: 319 | mock_target = '__builtin__.raw_input' 320 | else: 321 | mock_target = 'builtins.input' 322 | with patch(mock_target) as mockfunc: 323 | mockfunc.side_effect = KeyboardInterrupt 324 | with pytest.raises(SystemExit) as exp: 325 | p.get_input(param) 326 | assert str(exp.value) == '3' 327 | 328 | 329 | class TestIntegrationParams(PaternosterHelper, InputBuffer): 330 | 331 | def test_no_prompt(self): 332 | para01 = self.get_param() 333 | para02 = self.get_param(name='password') 334 | p = self.get_paternoster(para01, para02) 335 | p.parse_args(argv=['--username', 'testor']) 336 | args = dict(p._get_runner_variables()) 337 | assert args['param_username'] == 'testor' 338 | assert args['param_password'] is None 339 | 340 | def test_prompt(self): 341 | para01 = self.get_param() 342 | para02 = self.get_param(name='password', prompt=True) 343 | p = self.get_paternoster(para01, para02) 344 | self.buffer('secret\n') 345 | p.parse_args(argv=['--username', 'testor']) 346 | args = dict(p._get_runner_variables()) 347 | assert args['param_username'] == 'testor' 348 | assert args['param_password'] == 'secret' 349 | 350 | def test_prompt_confirm(self): 351 | para01 = self.get_param() 352 | para02 = self.get_param( 353 | name='password', prompt=True, options={'confirm': True}, 354 | ) 355 | p = self.get_paternoster(para01, para02) 356 | self.buffer('secret\nsecret\n') 357 | p.parse_args(argv=['--username', 'testor']) 358 | args = dict(p._get_runner_variables()) 359 | assert args['param_username'] == 'testor' 360 | assert args['param_password'] == 'secret' 361 | 362 | def test_prompt_confirm_failed(self): 363 | para01 = self.get_param() 364 | para02 = self.get_param( 365 | name='password', prompt=True, options={'confirm': True}, 366 | ) 367 | p = self.get_paternoster(para01, para02) 368 | self.buffer('secret\nsecretasd\n') 369 | with pytest.raises(SystemExit) as excinfo: 370 | p.parse_args(argv=['--username', 'testor']) 371 | assert str(excinfo.value) == '3' 372 | 373 | def test_prompt_required(self, capsys): 374 | para01 = self.get_param() 375 | para02 = self.get_param( 376 | name='password', prompt=True, required=True, 377 | ) 378 | p = self.get_paternoster(para01, para02) 379 | self.buffer('secret\n') 380 | with pytest.raises(SystemExit) as excinfo: 381 | p.parse_args(argv=['--username', 'testor']) 382 | assert str(excinfo.value) == '2' 383 | out, err = capsys.readouterr() 384 | assert err.endswith("'--password' is required and can't be combined with prompt\n") 385 | 386 | @pytest.mark.parametrize('value', [ 387 | None, 388 | False, 389 | 0, 390 | 1, 391 | [1, 2, 3], 392 | ]) 393 | def test_prompt_error_wrong_type(self, value): 394 | para01 = self.get_param() 395 | para02 = self.get_param(name='password', prompt=value) 396 | p = self.get_paternoster(para01, para02) 397 | self.buffer('test\n') 398 | p.parse_args(argv=['--username', 'testor']) 399 | args = dict(p._get_runner_variables()) 400 | assert args['param_username'] == 'testor' 401 | assert args['param_password'] is None 402 | 403 | def test_type_check(self, capsys): 404 | para01 = self.get_param() 405 | para02 = self.get_param(name='password', prompt=True) 406 | p = self.get_paternoster(para01, para02) 407 | self.buffer('secret2\n') 408 | with pytest.raises(SystemExit) as excinfo: 409 | p.parse_args(argv=['--username', 'testor']) 410 | assert str(excinfo.value) == '2' 411 | out, err = capsys.readouterr() 412 | assert ( 413 | err.endswith("invalid string value: u'secret2'\n") # Python 2 414 | or err.endswith("invalid string value: 'secret2'\n") # Python 3 415 | ) 416 | --------------------------------------------------------------------------------