├── 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 |
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 [](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 |
--------------------------------------------------------------------------------