├── .coveragerc ├── .github └── workflows │ ├── doc.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .pylintrc ├── LICENSE ├── MANIFEST.in ├── NEWS.rst ├── README.rst ├── bin └── pychroot ├── doc ├── Makefile ├── conf.py ├── index.rst └── man │ └── pychroot.rst ├── pyproject.toml ├── requirements ├── ci.txt ├── dev.txt ├── dist.txt ├── docs.txt ├── install.txt ├── pyproject.toml ├── test.txt └── tox.txt ├── setup.cfg ├── setup.py ├── src └── pychroot │ ├── __init__.py │ ├── base.py │ ├── exceptions.py │ ├── scripts │ ├── __init__.py │ └── pychroot.py │ └── utils.py ├── tests ├── test_chroot.py ├── test_chroot_utils.py ├── test_cli.py └── test_script.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = pychroot 3 | branch = True 4 | omit = src/*, tests/* 5 | 6 | [paths] 7 | source = **/site-packages/pychroot 8 | 9 | [report] 10 | show_missing = True 11 | skip_covered = True 12 | exclude_lines = 13 | # re-enable the standard pragma 14 | pragma: no cover 15 | 16 | # ignore defensive assertions 17 | raise AssertionError 18 | raise NotImplementedError 19 | 20 | # ignore unexecutable code 21 | if __name__ == .__main__.: 22 | -------------------------------------------------------------------------------- /.github/workflows/doc.yml: -------------------------------------------------------------------------------- 1 | name: doc 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | 7 | jobs: 8 | build-and-deploy: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout code 12 | uses: actions/checkout@v2 13 | 14 | - name: Set up Python 3.8 15 | uses: actions/setup-python@v2 16 | with: 17 | python-version: 3.8 18 | 19 | - name: Configure pip cache 20 | uses: actions/cache@v2 21 | with: 22 | path: ~/.cache/pip 23 | key: ${{ runner.os }}-pip-${{ hashFiles('requirements/*') }} 24 | restore-keys: ${{ runner.os }}-pip- 25 | 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | pip install -r requirements/dev.txt 30 | pip install -r requirements/docs.txt 31 | 32 | - name: Build sphinx documentation 33 | run: | 34 | python setup.py build_docs 35 | # notify github this isn't a jekyll site 36 | touch build/sphinx/html/.nojekyll 37 | 38 | - name: Deploy docs to gh-pages 39 | uses: JamesIves/github-pages-deploy-action@3.7.1 40 | with: 41 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 42 | BRANCH: gh-pages 43 | FOLDER: build/sphinx/html 44 | SINGLE_COMMIT: true 45 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | branches: [deploy] 6 | tags: [v*] 7 | 8 | jobs: 9 | build-and-deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@v2 14 | 15 | - name: Set up Python 3.8 16 | uses: actions/setup-python@v2 17 | with: 18 | python-version: 3.8 19 | 20 | - name: Install dependencies 21 | run: | 22 | # install deps required for building sdist/wheel 23 | python -m pip install --upgrade pip 24 | pip install -r requirements/dist.txt 25 | pip install -r requirements/test.txt 26 | 27 | - name: Test with pytest 28 | run: python setup.py test 29 | 30 | - name: Build release files 31 | run: | 32 | # use release version of pyproject.toml (without URL dev deps) 33 | cp requirements/pyproject.toml ./pyproject.toml 34 | python setup.py sdist 35 | python setup.py bdist_wheel 36 | 37 | # output file info 38 | tar -ztf dist/*.tar.gz | sort 39 | sha512sum dist/* 40 | 41 | - name: Upload files for tagged releases 42 | env: 43 | TWINE_USERNAME: __token__ 44 | TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} 45 | # only upload files for tagged releases 46 | if: startsWith(github.ref, 'refs/tags/') 47 | run: | 48 | pip install twine 49 | twine upload dist/* 50 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches-ignore: [deploy] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | continue-on-error: ${{ matrix.experimental }} 13 | strategy: 14 | matrix: 15 | python-version: ['3.8', '3.9'] 16 | experimental: [false] 17 | include: 18 | - python-version: '3.10.0-alpha - 3.10.0' 19 | experimental: true 20 | 21 | steps: 22 | - name: Checkout code 23 | uses: actions/checkout@v2 24 | 25 | - name: Set up Python ${{ matrix.python-version }} 26 | uses: actions/setup-python@v2 27 | with: 28 | python-version: ${{ matrix.python-version }} 29 | 30 | - name: Configure pip cache 31 | uses: actions/cache@v2 32 | with: 33 | path: ~/.cache/pip 34 | key: ${{ runner.os }}-pip-${{ hashFiles('requirements/*') }} 35 | restore-keys: ${{ runner.os }}-pip- 36 | 37 | - name: Install dependencies 38 | run: | 39 | python -m pip install --upgrade pip 40 | pip install -r requirements/test.txt 41 | pip install -r requirements/ci.txt 42 | pip install . 43 | 44 | - name: Test with pytest 45 | # forcibly enable pytest colors 46 | env: 47 | PY_COLORS: 1 48 | run: | 49 | pytest --cov --cov-report=term --cov-report=xml -v 50 | 51 | - name: Submit code coverage to codecov 52 | uses: codecov/codecov-action@v1 53 | with: 54 | file: ./coverage.xml 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[co] 2 | .*.swp 3 | .*.un~ 4 | 5 | # Packages 6 | *.egg 7 | *.egg-info 8 | MANIFEST 9 | dist 10 | build 11 | eggs 12 | 13 | # docs 14 | /doc/_build 15 | /doc/api 16 | /doc/generated 17 | 18 | # Unit test / coverage reports 19 | htmlcov 20 | .coverage 21 | .tox 22 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | # Specify a configuration file. 4 | #rcfile= 5 | 6 | # Python code to execute, usually for sys.path manipulation such as 7 | # pygtk.require(). 8 | #init-hook= 9 | 10 | # Profiled execution. 11 | profile=no 12 | 13 | # Add to the black list. It should be a base name, not a 14 | # path. You may set this option multiple times. 15 | ignore=CVS,.svn,.git 16 | 17 | # Pickle collected data for later comparisons. 18 | persistent=yes 19 | 20 | # List of plugins (as comma separated values of python modules names) to load, 21 | # usually to register additional checkers. 22 | load-plugins= 23 | 24 | 25 | [MESSAGES CONTROL] 26 | 27 | # Enable the message, report, category or checker with the given id(s). You can 28 | # either give multiple identifier separated by comma (,) or put this option 29 | # multiple time. 30 | #enable= 31 | 32 | # Disable the message, report, category or checker with the given id(s). You 33 | # can either give multiple identifier separated by comma (,) or put this option 34 | # multiple time (only on the command line, not in the configuration file where 35 | # it should appear only once). 36 | # Disable '* or ** magic' warning (W0142) 37 | # Disable 'Catch Exception' warning (W0703) 38 | # Disable 'Anomalous backslash in string' warning (W1401) 39 | disable=W0142,W0703,W1401,E1003,E1103,R0921,abstract-class-little-used,too-many-branches 40 | 41 | 42 | [REPORTS] 43 | 44 | # Set the output format. Available formats are text, parseable, colorized, msvs 45 | # (visual studio) and html 46 | output-format=text 47 | 48 | # Put messages in a separate file for each module / package specified on the 49 | # command line instead of printing them on stdout. Reports (if any) will be 50 | # written in a file name "pylint_global.[txt|html]". 51 | files-output=no 52 | 53 | # Tells whether to display a full report or only the messages 54 | reports=yes 55 | 56 | # Python expression which should return a note less than 10 (10 is the highest 57 | # note). You have access to the variables errors warning, statement which 58 | # respectively contain the number of errors / warnings messages and the total 59 | # number of statements analyzed. This is used by the global evaluation report 60 | # (RP0004). 61 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 62 | 63 | # Add a comment according to your evaluation note. This is used by the global 64 | # evaluation report (RP0004). 65 | comment=yes 66 | 67 | 68 | [TYPECHECK] 69 | 70 | # Tells whether missing members accessed in mixin class should be ignored. A 71 | # mixin class is detected if its name ends with "mixin" (case insensitive). 72 | ignore-mixin-members=yes 73 | 74 | # List of classes names for which member attributes should not be checked 75 | # (useful for classes with attributes dynamically set). 76 | ignored-classes=SQLObject 77 | 78 | # When zope mode is activated, add a predefined set of Zope acquired attributes 79 | # to generated-members. 80 | zope=no 81 | 82 | # List of members which are set dynamically and missed by pylint inference 83 | # system, and so shouldn't trigger E0201 when accessed. 84 | generated-members=REQUEST,acl_users,aq_parent 85 | 86 | 87 | [SIMILARITIES] 88 | 89 | # Minimum lines number of a similarity. 90 | min-similarity-lines=4 91 | 92 | # Ignore comments when computing similarities. 93 | ignore-comments=yes 94 | 95 | # Ignore docstrings when computing similarities. 96 | ignore-docstrings=yes 97 | 98 | 99 | [FORMAT] 100 | 101 | # Maximum number of characters on a single line. 102 | max-line-length=120 103 | 104 | # Maximum number of lines in a module 105 | max-module-lines=1000 106 | 107 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 108 | # tab). 109 | indent-string=' ' 110 | 111 | 112 | [BASIC] 113 | 114 | # Required attributes for module, separated by a comma 115 | required-attributes= 116 | 117 | # List of builtins function names that should not be used, separated by a comma 118 | bad-functions=apply,input 119 | 120 | # Regular expression which should only match correct module names 121 | module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 122 | 123 | # Regular expression which should only match correct module level names 124 | const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ 125 | 126 | # Regular expression which should only match correct class names 127 | class-rgx=[A-Z_][a-zA-Z0-9]+$ 128 | 129 | # Regular expression which should only match correct function names 130 | function-rgx=[a-z_][a-z0-9_]{2,30}$ 131 | 132 | # Regular expression which should only match correct method names 133 | method-rgx=[a-z_][a-z0-9_]{2,30}$ 134 | 135 | # Regular expression which should only match correct instance attribute names 136 | attr-rgx=[a-z_][a-z0-9_]{2,30}$ 137 | 138 | # Regular expression which should only match correct argument names 139 | argument-rgx=[a-z_][a-z0-9_]{2,30}$ 140 | 141 | # Regular expression which should only match correct variable names 142 | variable-rgx=[a-z_][a-z0-9_]{2,30}$ 143 | 144 | # Regular expression which should only match correct list comprehension / 145 | # generator expression variable names 146 | inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ 147 | 148 | # Good variable names which should always be accepted, separated by a comma 149 | good-names=e,f,i,j,k,ex,Run,_,fd,fp 150 | 151 | # Bad variable names which should always be refused, separated by a comma 152 | bad-names=foo,bar,baz 153 | 154 | # Regular expression which should only match functions or classes name which do 155 | # not require a docstring 156 | no-docstring-rgx=__.*__ 157 | 158 | 159 | [VARIABLES] 160 | 161 | # Tells whether we should check for unused import in __init__ files. 162 | init-import=no 163 | 164 | # A regular expression matching the beginning of the name of dummy variables 165 | # (i.e. not used). 166 | dummy-variables-rgx=_|dummy 167 | 168 | # List of additional names supposed to be defined in builtins. Remember that 169 | # you should avoid to define new builtins when possible. 170 | additional-builtins= 171 | 172 | 173 | [MISCELLANEOUS] 174 | 175 | # List of note tags to take in consideration, separated by a comma. 176 | notes=FIXME,XXX,TODO 177 | 178 | 179 | [DESIGN] 180 | 181 | # Maximum number of arguments for function / method 182 | max-args=10 183 | 184 | # Argument names that match this expression will be ignored. Default to name 185 | # with leading underscore 186 | ignored-argument-names=_.* 187 | 188 | # Maximum number of locals for function / method body 189 | max-locals=30 190 | 191 | # Maximum number of return / yield for function / method body 192 | max-returns=10 193 | 194 | # Maximum number of branch for function / method body 195 | max-branchs=20 196 | 197 | # Maximum number of statements in function / method body 198 | max-statements=200 199 | 200 | # Maximum number of parents for a class (see R0901). 201 | max-parents=7 202 | 203 | # Maximum number of attributes for a class (see R0902). 204 | max-attributes=20 205 | 206 | # Minimum number of public methods for a class (see R0903). 207 | min-public-methods=2 208 | 209 | # Maximum number of public methods for a class (see R0904). 210 | max-public-methods=20 211 | 212 | 213 | [CLASSES] 214 | 215 | # List of interface methods to ignore, separated by a comma. This is used for 216 | # instance to not check methods defines in Zope's Interface base class. 217 | ignore-iface-methods=isImplementedBy,deferred,extends,names,namesAndDescriptions,queryDescriptionFor,getBases,getDescriptionFor,getDoc,getName,getTaggedValue,getTaggedValueTags,isEqualOrExtendedBy,setTaggedValue,isImplementedByInstancesOf,adaptWith,is_implemented_by 218 | 219 | # List of method names used to declare (i.e. assign) instance attributes. 220 | defining-attr-methods=__init__,__new__,setUp 221 | 222 | 223 | [IMPORTS] 224 | 225 | # Deprecated modules which should not be used, separated by a comma 226 | deprecated-modules=regsub,string,TERMIOS,Bastion,rexec 227 | 228 | # Create a graph of every (i.e. internal and external) dependencies in the 229 | # given file (report RP0402 must not be disabled) 230 | import-graph= 231 | 232 | # Create a graph of external dependencies in the given file (report RP0402 must 233 | # not be disabled) 234 | ext-import-graph= 235 | 236 | # Create a graph of internal dependencies in the given file (report RP0402 must 237 | # not be disabled) 238 | int-import-graph= 239 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014-2018, pychroot developers 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 2. Redistributions in binary form must reproduce the above copyright 10 | notice, this list of conditions and the following disclaimer in the 11 | documentation and/or other materials provided with the distribution. 12 | 3. Neither the name of pychroot nor the names of its 13 | contributors may be used to endorse or promote products derived from 14 | this software without specific prior written permission. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 19 | ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE 20 | LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 21 | CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 22 | SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 23 | INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 24 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 25 | ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 26 | POSSIBILITY OF SUCH DAMAGE. 27 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE *.py *.rst 2 | include tox.ini pyproject.toml 3 | recursive-include bin * 4 | recursive-include doc * 5 | recursive-include requirements * 6 | recursive-include src * 7 | recursive-include tests * 8 | global-exclude *.pyc *.pyo __pycache__ 9 | -------------------------------------------------------------------------------- /NEWS.rst: -------------------------------------------------------------------------------- 1 | Release Notes 2 | ============= 3 | 4 | pychroot 0.10.4 (2021-01-25) 5 | ---------------------------- 6 | 7 | - Fix pychroot script exit status handling (#33). 8 | 9 | - Allow default mount disabling via passing mountpoints=None. 10 | 11 | - Force chroot path parameter to be positional only. 12 | 13 | - Drop support for python 3.6 and 3.7. 14 | 15 | pychroot 0.10.3 (2020-12-22) 16 | ---------------------------- 17 | 18 | - Fix build with newer versions of snakeoil. 19 | 20 | pychroot 0.10.2 (2020-10-25) 21 | ---------------------------- 22 | 23 | - Add py3.9 support. 24 | 25 | - Fix pychroot script hang on >=py3.7 (#30). 26 | 27 | pychroot 0.10.1 (2019-11-30) 28 | ---------------------------- 29 | 30 | - Add py3.8 support. 31 | 32 | pychroot 0.10.0 (2019-08-23) 33 | ---------------------------- 34 | 35 | - Minimum supported python version is now python3.6 (python2 support dropped). 36 | 37 | - Fix handling of environment variable mounts to nonexistent paths. 38 | 39 | pychroot 0.9.18 (2017-10-04) 40 | ---------------------------- 41 | 42 | - Return the exit status of the child process instead of 0 for the pychroot 43 | script. This makes pychroot more compatible with chroot's behavior. 44 | 45 | - Run clean up before exit after receiving SIGINT/SIGTERM, previously stub 46 | files/directories that were created for bind mounts weren't properly cleaned 47 | up in the chroot after the parent process received these signals and exited. 48 | 49 | pychroot 0.9.17 (2017-09-21) 50 | ---------------------------- 51 | 52 | - Handle single file bind mount creation failures. 53 | 54 | pychroot 0.9.16 (2016-10-31) 55 | ---------------------------- 56 | 57 | - Don't try to generate new version files if they already exist (fixes another 58 | pip install issue). 59 | 60 | - Drop py3.3 support. 61 | 62 | pychroot 0.9.15 (2016-05-29) 63 | ---------------------------- 64 | 65 | - Fix new installs using pip. 66 | 67 | pychroot 0.9.14 (2016-05-28) 68 | ---------------------------- 69 | 70 | - Move to generic scripts and docs framework used by pkgcore. 71 | 72 | pychroot 0.9.13 (2015-12-13) 73 | ---------------------------- 74 | 75 | - Add --no-mounts option to disable the default mounts for the command line 76 | tool. This makes pychroot act similar to chroot. 77 | 78 | - Make pychroot pip-installable without requiring wnakeoil to be manually 79 | installed first. 80 | 81 | - Add lots of additional content to the pychroot utility man page. 82 | 83 | pychroot 0.9.12 (2015-08-10) 84 | ---------------------------- 85 | 86 | - The main module was renamed from chroot to pychroot mostly for consistency to 87 | match the project name and cli tool installed alongside it. 88 | 89 | - Add a man page for the pychroot cli tool. 90 | 91 | - Add user namespace support so you can chroot as a regular user. Note that 92 | this also requires using a network namespace for which we only setup a 93 | loopback interface (if iproute2 is installed in the chroot) so external 94 | network access won't work by default in this situation. 95 | 96 | - Add an option to skip changing to the newroot directory after chrooting. This 97 | is similar to the option for chroot(1) but also allows skipping the directory 98 | change when the new root isn't '/'. In other words, you can use a chroot 99 | environment against the host's rootfs. 100 | 101 | - Use $SHELL from the environment for the pychroot script to mirror chroot's 102 | behavior. 103 | 104 | - Move WithParentSkip, the main parent/child execution splitting context 105 | manager allowing this all to work, to snakeoil.contextlib and rename it 106 | SplitExec. It was moved in order to develop other context managers around it 107 | in snakeoil and elsewhere more easily. 108 | 109 | - Fix additional argument parsing for the pychroot script. Now commands like:: 110 | 111 | pychroot ~/chroot /bin/bash -c "ls -R /" 112 | 113 | will work as expected (i.e. how they normally work with chroot). 114 | 115 | - Allow mount propagation from the host mount namespace to the chroot's but not 116 | vice versa. Previously systems that set the rootfs mount as shared, e.g. 117 | running something like:: 118 | 119 | mount --make-rshared / 120 | 121 | would leak mounts from the chroot mount namespace back into the host's 122 | namespace. Now the chroot mount namespace is recursively slaved from the 123 | host's so mount events will propagate down from host to chroot, but not back 124 | up from chroot to host. 125 | 126 | - Add support for setting the chroot's host and domain names for all versions 127 | of python. Previously we only supported setting the hostname for py33 and up. 128 | To set the domain name, pass an FQDN instead of a singular hostname. This 129 | also adds a "--hostname" option to the pychroot script that enables the same 130 | support for it. 131 | 132 | pychroot 0.9.11 (2015-07-05) 133 | ---------------------------- 134 | 135 | - Fix pychroot script when no custom mountpoints as specified. 136 | 137 | pychroot 0.9.10 (2015-04-09) 138 | ---------------------------- 139 | 140 | - Add support for custom bind mounts to the pychroot script. Now users are able 141 | to do things like:: 142 | 143 | pychroot -R /home/user ~/chroot 144 | 145 | which will recursively bind mount their home directory into the chroot in 146 | addition to the standard set of bind mounts. 147 | 148 | - Use "source[:dest]" as keys for mountpoints. This enables support for 149 | mounting the same source onto multiple destinations. For example, with 150 | pychroot it's now possible to run:: 151 | 152 | pychroot -B tmpfs:/dev/shm -B tmpfs:/tmp 153 | 154 | pychroot 0.9.9 (2015-04-03) 155 | --------------------------- 156 | 157 | - Install chroot(1) workalike as pychroot. This allows users to be lazier when 158 | doing basic chrooting since pychroot handles mounting and unmounting standard 159 | bind mounts automatically. 160 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | |pypi| |test| |coverage| 2 | 3 | ======== 4 | pychroot 5 | ======== 6 | 7 | pychroot is a python library and cli tool that simplify chroot handling. 8 | Specifically, the library provides a **Chroot** context manager that enables 9 | more pythonic methods for running code in chroots while the **pychroot** 10 | utility works much like an extended chroot command in the terminal. 11 | 12 | Usage 13 | ===== 14 | 15 | In its simplest form, the library can be used similar to the following:: 16 | 17 | from pychroot import Chroot 18 | 19 | with Chroot('/path/to/chroot'): 20 | code that will be run 21 | inside the chroot 22 | 23 | By default, this will bind mount the host's /dev, /proc, and /sys filesystems 24 | into the chroot as well as the /etc/resolv.conf file (so DNS resolution works 25 | as expected in the chroot). 26 | 27 | A simple chroot equivalent is also installed as **pychroot**. It can be used in 28 | a similar fashion to chroot; however, it also performs the bind mounts 29 | previously mentioned so the environment is usable. In addition, pychroot 30 | supports specifying custom bind mounts, for example:: 31 | 32 | pychroot -R /home/user ~/chroot 33 | 34 | will recursively bind mount the user's home directory at the same location 35 | inside the chroot directory in addition to the standard bind mounts. See 36 | pychroot's help output for more options. 37 | 38 | When running on a system with a recent kernel (Linux 3.8 and on) and user 39 | namespaces enabled pychroot can be run by a regular user. Currently 40 | pychroot just maps the current user to root in the chroot environment. This 41 | means that recursively chown-ing the chroot directory to the user running 42 | pychroot should essentially allow that user to act as root in the pychroot 43 | environment. 44 | 45 | Implementation details 46 | ====================== 47 | 48 | Namespaces are used by the context manager to isolate the chroot instance from 49 | the host system and to simplify the teardown phase for the environments. By 50 | default, new mount, UTS, IPC, and pid namespaces are used. In addition, if 51 | running as non-root, both user and network namespaces will be enabled as well 52 | so that the chrooting and mounting process will work without elevated 53 | permissions. 54 | 55 | One quirk of note is that currently local variables are not propagated back 56 | from the chroot context to the main context due to the usage of separate 57 | processes running the contexts. This means that something similar to the 58 | following won't work:: 59 | 60 | from pychroot import Chroot 61 | 62 | with Chroot('/path/to/chroot'): 63 | answer = 42 64 | print(answer) 65 | 66 | In this case, a NameError exception will be raised unless the variable *answer* 67 | was previously defined. This will probably be fixed to some extent in a future 68 | release. 69 | 70 | Requirements 71 | ============ 72 | 73 | pychroot is quite Linux specific due to the use of namespaces via the 74 | `snakeoil`_ library which also require proper kernel support. Specifically, the 75 | following kernel config options are required to be enabled for full namespace 76 | support:: 77 | 78 | CONFIG_NAMESPACES=y 79 | CONFIG_UTS_NS=y 80 | CONFIG_IPC_NS=y 81 | CONFIG_USER_NS=y 82 | CONFIG_PID_NS=y 83 | CONFIG_NET_NS=y 84 | 85 | Installing 86 | ========== 87 | 88 | Installing latest pypi release:: 89 | 90 | pip install pychroot 91 | 92 | Installing from git:: 93 | 94 | pip install https://github.com/pkgcore/pychroot/archive/master.tar.gz 95 | 96 | Installing from a tarball:: 97 | 98 | python setup.py install 99 | 100 | Tests 101 | ===== 102 | 103 | A standalone test runner is integrated in setup.py; to run, just execute:: 104 | 105 | python setup.py test 106 | 107 | In addition, a tox config is provided so the testsuite can be run in a 108 | virtualenv setup against all supported python versions. To run tests for all 109 | environments just execute **tox** in the root directory of a repo or unpacked 110 | tarball. Otherwise, for a specific python version execute something similar to 111 | the following:: 112 | 113 | tox -e py39 114 | 115 | 116 | .. _`snakeoil`: https://github.com/pkgcore/snakeoil 117 | .. _mock: https://pypi.python.org/pypi/mock 118 | 119 | .. |pypi| image:: https://img.shields.io/pypi/v/pychroot.svg 120 | :target: https://pypi.python.org/pypi/pychroot 121 | .. |test| image:: https://github.com/pkgcore/pychroot/workflows/test/badge.svg 122 | :target: https://github.com/pkgcore/pychroot/actions?query=workflow%3A%22test%22 123 | .. |coverage| image:: https://codecov.io/gh/pkgcore/pychroot/branch/master/graph/badge.svg 124 | :target: https://codecov.io/gh/pkgcore/pychroot 125 | -------------------------------------------------------------------------------- /bin/pychroot: -------------------------------------------------------------------------------- 1 | ../src/pychroot/scripts/__init__.py -------------------------------------------------------------------------------- /doc/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | PREFIX = /usr/local 10 | MAN_PREFIX = $(PREFIX)/share/man 11 | 12 | # User-friendly check for sphinx-build 13 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 14 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 15 | endif 16 | 17 | # Internal variables. 18 | PAPEROPT_a4 = -D latex_paper_size=a4 19 | PAPEROPT_letter = -D latex_paper_size=letter 20 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | # the i18n builder cannot share the environment and doctrees with the others 22 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 23 | 24 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 25 | 26 | help: 27 | @echo "Please use \`make ' where is one of" 28 | @echo " html to make standalone HTML files" 29 | @echo " dirhtml to make HTML files named index.html in directories" 30 | @echo " singlehtml to make a single large HTML file" 31 | @echo " pickle to make pickle files" 32 | @echo " json to make JSON files" 33 | @echo " htmlhelp to make HTML files and a HTML help project" 34 | @echo " qthelp to make HTML files and a qthelp project" 35 | @echo " devhelp to make HTML files and a Devhelp project" 36 | @echo " epub to make an epub" 37 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 38 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 39 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 40 | @echo " text to make text files" 41 | @echo " man to make manual pages" 42 | @echo " texinfo to make Texinfo files" 43 | @echo " info to make Texinfo files and run them through makeinfo" 44 | @echo " gettext to make PO message catalogs" 45 | @echo " changes to make an overview of all changed/added/deprecated items" 46 | @echo " xml to make Docutils-native XML files" 47 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 48 | @echo " linkcheck to check all external links for integrity" 49 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 50 | 51 | clean: 52 | -rm -rf $(BUILDDIR) api generated 53 | 54 | html: 55 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 56 | @echo 57 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 58 | 59 | dirhtml: 60 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 61 | @echo 62 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 63 | 64 | singlehtml: 65 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 66 | @echo 67 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 68 | 69 | pickle: 70 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 71 | @echo 72 | @echo "Build finished; now you can process the pickle files." 73 | 74 | json: 75 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 76 | @echo 77 | @echo "Build finished; now you can process the JSON files." 78 | 79 | htmlhelp: 80 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 81 | @echo 82 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 83 | ".hhp project file in $(BUILDDIR)/htmlhelp." 84 | 85 | qthelp: 86 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 87 | @echo 88 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 89 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 90 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/pychroot.qhcp" 91 | @echo "To view the help file:" 92 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/pychroot.qhc" 93 | 94 | devhelp: 95 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 96 | @echo 97 | @echo "Build finished." 98 | @echo "To view the help file:" 99 | @echo "# mkdir -p $$HOME/.local/share/devhelp/pychroot" 100 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/pychroot" 101 | @echo "# devhelp" 102 | 103 | epub: 104 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 105 | @echo 106 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 107 | 108 | latex: 109 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 110 | @echo 111 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 112 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 113 | "(use \`make latexpdf' here to do that automatically)." 114 | 115 | latexpdf: 116 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 117 | @echo "Running LaTeX files through pdflatex..." 118 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 119 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 120 | 121 | latexpdfja: 122 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 123 | @echo "Running LaTeX files through platex and dvipdfmx..." 124 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 125 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 126 | 127 | text: 128 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 129 | @echo 130 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 131 | 132 | man: 133 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 134 | @echo 135 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 136 | 137 | install_man: 138 | @for file in $(BUILDDIR)/man/*; do \ 139 | install -d -m 755 "$(DESTDIR)$(MAN_PREFIX)/man$$(echo $$file | sed 's/.*\.//')"; \ 140 | install -m 644 "$${file}" "$(DESTDIR)$(MAN_PREFIX)/man$$(echo $$file | sed 's/.*\.//')"; \ 141 | done 142 | 143 | texinfo: 144 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 145 | @echo 146 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 147 | @echo "Run \`make' in that directory to run these through makeinfo" \ 148 | "(use \`make info' here to do that automatically)." 149 | 150 | info: 151 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 152 | @echo "Running Texinfo files through makeinfo..." 153 | make -C $(BUILDDIR)/texinfo info 154 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 155 | 156 | gettext: 157 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 158 | @echo 159 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 160 | 161 | changes: 162 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 163 | @echo 164 | @echo "The overview file is in $(BUILDDIR)/changes." 165 | 166 | linkcheck: 167 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 168 | @echo 169 | @echo "Link check complete; look for any errors in the above output " \ 170 | "or in $(BUILDDIR)/linkcheck/output.txt." 171 | 172 | doctest: 173 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 174 | @echo "Testing of doctests in the sources finished, look at the " \ 175 | "results in $(BUILDDIR)/doctest/output.txt." 176 | 177 | xml: 178 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 179 | @echo 180 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 181 | 182 | pseudoxml: 183 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 184 | @echo 185 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 186 | -------------------------------------------------------------------------------- /doc/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # pychroot documentation build configuration file, created by 5 | # sphinx-quickstart on Thu Apr 9 00:50:08 2015. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | from __future__ import unicode_literals 17 | 18 | from importlib import import_module 19 | import os 20 | import sys 21 | 22 | # If extensions (or modules to document with autodoc) are in another directory, 23 | # add these directories to sys.path here. If the directory is relative to the 24 | # documentation root, use os.path.abspath to make it absolute, like shown here. 25 | libdir = os.path.abspath(os.path.join('..', 'build', 'lib')) 26 | if os.path.exists(libdir): 27 | sys.path.insert(0, libdir) 28 | sys.path.insert(1, os.path.abspath(os.path.join('..', 'src'))) 29 | 30 | from pychroot import __version__ 31 | 32 | 33 | # -- General configuration ------------------------------------------------ 34 | 35 | # If your documentation needs a minimal Sphinx version, state it here. 36 | #needs_sphinx = '1.0' 37 | 38 | # Add any Sphinx extension module names here, as strings. They can be 39 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 40 | # ones. 41 | extensions = [ 42 | 'sphinx.ext.autodoc', 43 | 'sphinx.ext.autosummary', 44 | 'sphinx.ext.doctest', 45 | 'sphinx.ext.extlinks', 46 | 'sphinx.ext.intersphinx', 47 | 'sphinx.ext.todo', 48 | 'sphinx.ext.coverage', 49 | 'sphinx.ext.ifconfig', 50 | 'sphinx.ext.viewcode', 51 | ] 52 | 53 | # Add any paths that contain templates here, relative to this directory. 54 | #templates_path = ['_templates'] 55 | 56 | # The suffix of source filenames. 57 | source_suffix = '.rst' 58 | 59 | # The encoding of source files. 60 | #source_encoding = 'utf-8-sig' 61 | 62 | # The master toctree document. 63 | master_doc = 'index' 64 | 65 | # General information about the project. 66 | project = 'pychroot' 67 | authors_list = ['Tim Harder'] 68 | authors = ', '.join(authors_list) 69 | copyright = '2014-2018, pychroot contributors' 70 | 71 | # The version info for the project you're documenting, acts as replacement for 72 | # |version| and |release|, also used in various other places throughout the 73 | # built documents. 74 | # 75 | # The short X.Y version. 76 | version = __version__ 77 | # The full version, including alpha/beta/rc tags. 78 | release = 'master' 79 | 80 | # The language for content autogenerated by Sphinx. Refer to documentation 81 | # for a list of supported languages. 82 | #language = None 83 | 84 | # There are two options for replacing |today|: either, you set today to some 85 | # non-false value, then it is used: 86 | #today = '' 87 | # Else, today_fmt is used as the format for a strftime call. 88 | #today_fmt = '%B %d, %Y' 89 | 90 | # List of patterns, relative to source directory, that match files and 91 | # directories to ignore when looking for source files. 92 | exclude_patterns = ['_build'] 93 | 94 | # The reST default role (used for this markup: `text`) to use for all 95 | # documents. 96 | #default_role = None 97 | 98 | # If true, '()' will be appended to :func: etc. cross-reference text. 99 | #add_function_parentheses = True 100 | 101 | # If true, the current module name will be prepended to all description 102 | # unit titles (such as .. function::). 103 | #add_module_names = True 104 | 105 | # If true, sectionauthor and moduleauthor directives will be shown in the 106 | # output. They are ignored by default. 107 | #show_authors = False 108 | 109 | # The name of the Pygments (syntax highlighting) style to use. 110 | pygments_style = 'sphinx' 111 | 112 | # A list of ignored prefixes for module index sorting. 113 | #modindex_common_prefix = [] 114 | 115 | # If true, keep warnings as "system message" paragraphs in the built documents. 116 | #keep_warnings = False 117 | 118 | 119 | # -- Options for HTML output ---------------------------------------------- 120 | 121 | # The theme to use for HTML and HTML Help pages. See the documentation for 122 | # a list of builtin themes. 123 | html_theme = 'default' 124 | 125 | # Theme options are theme-specific and customize the look and feel of a theme 126 | # further. For a list of options available for each theme, see the 127 | # documentation. 128 | #html_theme_options = {} 129 | 130 | # Add any paths that contain custom themes here, relative to this directory. 131 | #html_theme_path = [] 132 | 133 | # The name for this set of Sphinx documents. If None, it defaults to 134 | # " v documentation". 135 | #html_title = None 136 | 137 | # A shorter title for the navigation bar. Default is the same as html_title. 138 | #html_short_title = None 139 | 140 | # The name of an image file (relative to this directory) to place at the top 141 | # of the sidebar. 142 | #html_logo = None 143 | 144 | # The name of an image file (within the static path) to use as favicon of the 145 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 146 | # pixels large. 147 | #html_favicon = None 148 | 149 | # Add any paths that contain custom static files (such as style sheets) here, 150 | # relative to this directory. They are copied after the builtin static files, 151 | # so a file named "default.css" will overwrite the builtin "default.css". 152 | #html_static_path = ['_static'] 153 | 154 | # Add any extra paths that contain custom files (such as robots.txt or 155 | # .htaccess) here, relative to this directory. These files are copied 156 | # directly to the root of the documentation. 157 | #html_extra_path = [] 158 | 159 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 160 | # using the given strftime format. 161 | #html_last_updated_fmt = '%b %d, %Y' 162 | 163 | # If true, SmartyPants will be used to convert quotes and dashes to 164 | # typographically correct entities. 165 | #html_use_smartypants = True 166 | 167 | # Custom sidebar templates, maps document names to template names. 168 | #html_sidebars = {} 169 | 170 | # Additional templates that should be rendered to pages, maps page names to 171 | # template names. 172 | #html_additional_pages = {} 173 | 174 | # If false, no module index is generated. 175 | #html_domain_indices = True 176 | 177 | # If false, no index is generated. 178 | #html_use_index = True 179 | 180 | # If true, the index is split into individual pages for each letter. 181 | #html_split_index = False 182 | 183 | # If true, links to the reST sources are added to the pages. 184 | #html_show_sourcelink = True 185 | 186 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 187 | #html_show_sphinx = True 188 | 189 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 190 | #html_show_copyright = True 191 | 192 | # If true, an OpenSearch description file will be output, and all pages will 193 | # contain a tag referring to it. The value of this option must be the 194 | # base URL from which the finished HTML is served. 195 | #html_use_opensearch = '' 196 | 197 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 198 | #html_file_suffix = None 199 | 200 | # Output file base name for HTML help builder. 201 | htmlhelp_basename = 'pychrootdoc' 202 | 203 | 204 | # -- Options for LaTeX output --------------------------------------------- 205 | 206 | latex_elements = { 207 | # The paper size ('letterpaper' or 'a4paper'). 208 | #'papersize': 'letterpaper', 209 | 210 | # The font size ('10pt', '11pt' or '12pt'). 211 | #'pointsize': '10pt', 212 | 213 | # Additional stuff for the LaTeX preamble. 214 | #'preamble': '', 215 | } 216 | 217 | # Grouping the document tree into LaTeX files. List of tuples 218 | # (source start file, target name, title, 219 | # author, documentclass [howto, manual, or own class]). 220 | latex_documents = [ 221 | ('index', 'pychroot.tex', 'pychroot Documentation', 222 | 'Tim Harder', 'manual'), 223 | ] 224 | 225 | # The name of an image file (relative to this directory) to place at the top of 226 | # the title page. 227 | #latex_logo = None 228 | 229 | # For "manual" documents, if this is true, then toplevel headings are parts, 230 | # not chapters. 231 | #latex_use_parts = False 232 | 233 | # If true, show page references after internal links. 234 | #latex_show_pagerefs = False 235 | 236 | # If true, show URL addresses after external links. 237 | #latex_show_urls = False 238 | 239 | # Documents to append as an appendix to all manuals. 240 | #latex_appendices = [] 241 | 242 | # If false, no module index is generated. 243 | #latex_domain_indices = True 244 | 245 | 246 | # -- Options for manual page output --------------------------------------- 247 | 248 | bin_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'bin') 249 | scripts = os.listdir(bin_path) 250 | 251 | generated_man_pages = [ 252 | ('%s.scripts.' % (project,) + s.replace('-', '_'), s) for s in scripts 253 | ] 254 | 255 | # One entry per manual page. List of tuples 256 | # (source start file, name, description, authors, manual section). 257 | man_pages = [ 258 | ('man/%s' % script, script, import_module(module).__doc__.strip().split('\n', 1)[0], authors_list, 1) 259 | for module, script in generated_man_pages 260 | ] 261 | 262 | # If true, show URL addresses after external links. 263 | #man_show_urls = False 264 | 265 | # -- Options for Texinfo output ------------------------------------------- 266 | 267 | # Grouping the document tree into Texinfo files. List of tuples 268 | # (source start file, target name, title, author, 269 | # dir menu entry, description, category) 270 | texinfo_documents = [ 271 | ('index', 'pychroot', 'pychroot Documentation', 272 | authors, 'pychroot', 'One line description of project.', 273 | 'Miscellaneous'), 274 | ] 275 | 276 | # Documents to append as an appendix to all manuals. 277 | #texinfo_appendices = [] 278 | 279 | # If false, no module index is generated. 280 | #texinfo_domain_indices = True 281 | 282 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 283 | #texinfo_show_urls = 'footnote' 284 | 285 | # If true, do not generate a @detailmenu in the "Top" node's menu. 286 | #texinfo_no_detailmenu = False 287 | 288 | 289 | # -- Options for Epub output ---------------------------------------------- 290 | 291 | # Bibliographic Dublin Core info. 292 | epub_title = project 293 | epub_author = authors 294 | epub_publisher = authors 295 | epub_copyright = copyright 296 | 297 | # The basename for the epub file. It defaults to the project name. 298 | #epub_basename = 'pychroot' 299 | 300 | # The HTML theme for the epub output. Since the default themes are not optimized 301 | # for small screen space, using the same theme for HTML and epub output is 302 | # usually not wise. This defaults to 'epub', a theme designed to save visual 303 | # space. 304 | #epub_theme = 'epub' 305 | 306 | # The language of the text. It defaults to the language option 307 | # or en if the language is not set. 308 | #epub_language = '' 309 | 310 | # The scheme of the identifier. Typical schemes are ISBN or URL. 311 | #epub_scheme = '' 312 | 313 | # The unique identifier of the text. This can be a ISBN number 314 | # or the project homepage. 315 | #epub_identifier = '' 316 | 317 | # A unique identification for the text. 318 | #epub_uid = '' 319 | 320 | # A tuple containing the cover image and cover page html template filenames. 321 | #epub_cover = () 322 | 323 | # A sequence of (type, uri, title) tuples for the guide element of content.opf. 324 | #epub_guide = () 325 | 326 | # HTML files that should be inserted before the pages created by sphinx. 327 | # The format is a list of tuples containing the path and title. 328 | #epub_pre_files = [] 329 | 330 | # HTML files shat should be inserted after the pages created by sphinx. 331 | # The format is a list of tuples containing the path and title. 332 | #epub_post_files = [] 333 | 334 | # A list of files that should not be packed into the epub file. 335 | epub_exclude_files = ['search.html'] 336 | 337 | # The depth of the table of contents in toc.ncx. 338 | #epub_tocdepth = 3 339 | 340 | # Allow duplicate toc entries. 341 | #epub_tocdup = True 342 | 343 | # Choose between 'default' and 'includehidden'. 344 | #epub_tocscope = 'default' 345 | 346 | # Fix unsupported image types using the PIL. 347 | #epub_fix_images = False 348 | 349 | # Scale large images. 350 | #epub_max_image_width = 0 351 | 352 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 353 | #epub_show_urls = 'inline' 354 | 355 | # If false, no index is generated. 356 | #epub_use_index = True 357 | -------------------------------------------------------------------------------- /doc/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to pychroot's documentation! 2 | ==================================== 3 | 4 | Contents: 5 | 6 | .. toctree:: 7 | :maxdepth: 4 8 | 9 | api/pychroot 10 | 11 | Indices and tables 12 | ================== 13 | 14 | * :ref:`genindex` 15 | * :ref:`modindex` 16 | * :ref:`search` 17 | -------------------------------------------------------------------------------- /doc/man/pychroot.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | pychroot 3 | ======== 4 | 5 | .. include:: ../generated/pychroot/_synopsis.rst 6 | .. include:: ../generated/pychroot/_description.rst 7 | .. include:: ../generated/pychroot/_options.rst 8 | 9 | Example Usage 10 | ============= 11 | 12 | Pass through the host system's ssh agent in order to support regular ssh auth 13 | for a specific user in the chroot:: 14 | 15 | pychroot -R /home/user --ro $SSH_AUTH_SOCK --ro /etc/passwd --ro /etc/shadow /path/to/chroot /bin/bash 16 | 17 | Use the chroot environment while retaining access to the host file system:: 18 | 19 | pychroot --skip-chdir /path/to/chroot /bin/bash 20 | 21 | Set the chroot environment hostname to 'chroot' and the domain to 'foo.org':: 22 | 23 | pychroot --hostname chroot.foo.org /path/to/chroot /bin/bash 24 | 25 | Reporting Bugs 26 | ============== 27 | 28 | Please submit an issue via github: 29 | 30 | https://github.com/pkgcore/pychroot/issues 31 | 32 | See Also 33 | ======== 34 | 35 | chroot(1) 36 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "wheel", "setuptools", 4 | "snakeoil @ https://github.com/pkgcore/snakeoil/archive/master.tar.gz#egg=snakeoil", 5 | ] 6 | build-backend = "setuptools.build_meta" 7 | 8 | [tool.pytest.ini_options] 9 | minversion = "6.0" 10 | addopts = "-ra -q" 11 | testpaths = ["tests"] 12 | -------------------------------------------------------------------------------- /requirements/ci.txt: -------------------------------------------------------------------------------- 1 | pytest-cov 2 | -------------------------------------------------------------------------------- /requirements/dev.txt: -------------------------------------------------------------------------------- 1 | cython 2 | snakeoil @ https://github.com/pkgcore/snakeoil/archive/master.tar.gz#egg=snakeoil 3 | -------------------------------------------------------------------------------- /requirements/dist.txt: -------------------------------------------------------------------------------- 1 | # deps for building sdist/wheels for pypi 2 | -r install.txt 3 | -r docs.txt 4 | wheel 5 | -------------------------------------------------------------------------------- /requirements/docs.txt: -------------------------------------------------------------------------------- 1 | sphinx 2 | -------------------------------------------------------------------------------- /requirements/install.txt: -------------------------------------------------------------------------------- 1 | snakeoil~=0.9.3 2 | -------------------------------------------------------------------------------- /requirements/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["wheel", "setuptools", "snakeoil~=0.9.3"] 3 | build-backend = "setuptools.build_meta" 4 | -------------------------------------------------------------------------------- /requirements/test.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | -------------------------------------------------------------------------------- /requirements/tox.txt: -------------------------------------------------------------------------------- 1 | -r dev.txt 2 | -r test.txt 3 | -r ci.txt 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [build_sphinx] 2 | source-dir = doc 3 | build-dir = build/sphinx 4 | all_files = 1 5 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from setuptools import setup 4 | 5 | from snakeoil.dist import distutils_extensions as pkgdist 6 | pkgdist_setup, pkgdist_cmds = pkgdist.setup() 7 | 8 | 9 | setup(**dict( 10 | pkgdist_setup, 11 | description='a python library and cli tool that simplify chroot handling', 12 | author='Tim Harder', 13 | author_email='radhermit@gmail.com', 14 | url='https://github.com/pkgcore/pychroot', 15 | license='BSD', 16 | platforms='Posix', 17 | classifiers=[ 18 | 'Intended Audience :: Developers', 19 | 'License :: OSI Approved :: BSD License', 20 | 'Programming Language :: Python :: 3.8', 21 | 'Programming Language :: Python :: 3.9', 22 | ], 23 | )) 24 | -------------------------------------------------------------------------------- /src/pychroot/__init__.py: -------------------------------------------------------------------------------- 1 | """pychroot is a library that simplifies chroot handling""" 2 | 3 | __title__ = 'pychroot' 4 | __version__ = '0.10.4' 5 | 6 | from .base import Chroot 7 | -------------------------------------------------------------------------------- /src/pychroot/base.py: -------------------------------------------------------------------------------- 1 | import errno 2 | import os 3 | 4 | from snakeoil.contexts import SplitExec 5 | from snakeoil.mappings import ImmutableDict 6 | from snakeoil.process.namespaces import simple_unshare 7 | 8 | from .exceptions import ChrootError, ChrootMountError 9 | from .utils import bind, getlogger, dictbool 10 | 11 | 12 | class Chroot(SplitExec): 13 | 14 | """Context manager that provides chroot support. 15 | 16 | This is done by forking, doing some magic on the stack so the contents are 17 | not executed in the parent, and executing the context in the forked child. 18 | Exceptions are pickled and passed through to the parent. 19 | 20 | :param path: The path to the image to chroot into. 21 | :type path: str 22 | :param log: A log object to use for logging. 23 | :type log: logging.Logger 24 | :param mountpoints: A dictionary defining the mountpoints to use. These can 25 | override any of the defaults or add extra mountpoints. Set to None to 26 | disable all default mountpoints. 27 | :type mountpoints: dict 28 | :param hostname: The hostname for the chroot, defaults to the system 29 | hostname. In order to set the chroot domain name as well specify an 30 | FQDN instead of a singular hostname. 31 | :type hostname: str 32 | """ 33 | 34 | default_mounts = ImmutableDict({ 35 | '/dev': {'recursive': True}, 36 | 'proc:/proc': {}, 37 | 'sysfs:/sys': {}, 38 | 'tmpfs:/dev/shm': {}, 39 | '/etc/resolv.conf': {}, 40 | }) 41 | 42 | def __init__(self, path, /, *, log=None, mountpoints=(), hostname=None, skip_chdir=False): 43 | super().__init__() 44 | self.log = getlogger(log, __name__) 45 | self.path = os.path.abspath(path) 46 | self.hostname = hostname 47 | self.skip_chdir = skip_chdir 48 | 49 | if mountpoints is None: 50 | self.mountpoints = {} 51 | else: 52 | self.mountpoints = dict(self.default_mounts) 53 | self.mountpoints.update(mountpoints) 54 | 55 | if not os.path.isdir(self.path): 56 | raise ChrootError(f'cannot change root directory to {path!r}', errno.ENOTDIR) 57 | 58 | # flag mount points that require creation and removal 59 | for k, source, chrmount, opts in self.mounts: 60 | src = source 61 | # expand mountpoints that are environment variables 62 | if source.startswith('$'): 63 | src = os.getenv(source[1:], source) 64 | if src == source: 65 | if 'optional' in opts: 66 | self.log.debug( 67 | 'Skipping optional and nonexistent mountpoint ' 68 | 'due to undefined host environment variable: %s', source) 69 | del self.mountpoints[k] 70 | continue 71 | else: 72 | raise ChrootMountError( 73 | f'cannot mount undefined environment variable: {source}') 74 | self.log.debug('Expanding mountpoint %r to %r', source, src) 75 | self.mountpoints[src] = opts 76 | del self.mountpoints[k] 77 | k = src 78 | if '$' in chrmount: 79 | chrmount = os.path.join(self.path, src.lstrip('/')) 80 | 81 | if 'optional' not in opts and not os.path.exists(chrmount): 82 | self.mountpoints[k]['create'] = True 83 | 84 | @property 85 | def mounts(self): 86 | for k, options in list(self.mountpoints.items()): 87 | source, _, dest = k.partition(':') 88 | if not dest: 89 | dest = source 90 | dest = os.path.join(self.path, dest.lstrip('/')) 91 | yield k, source, dest, options 92 | 93 | def _child_setup(self): 94 | kwargs = {} 95 | if os.getuid() != 0: 96 | # Enable a user namespace if we're not root. Note that this also 97 | # requires a network namespace in order to mount sysfs and use 98 | # network devices; however, we currently only provide a basic 99 | # loopback interface if iproute2 is installed in the chroot so 100 | # regular connections out of the namespaced environment won't work 101 | # by default. 102 | kwargs.update({'user': True, 'net': True}) 103 | 104 | simple_unshare(pid=True, hostname=self.hostname, **kwargs) 105 | self._mount() 106 | os.chroot(self.path) 107 | if not self.skip_chdir: 108 | os.chdir('/') 109 | 110 | def _cleanup(self): 111 | # remove mount points that were dynamically created 112 | for _, _, chrmount, opts in self.mounts: 113 | if 'create' not in opts: 114 | continue 115 | self.log.debug('Removing dynamically created mountpoint: %s', chrmount) 116 | try: 117 | if not os.path.isdir(chrmount): 118 | os.remove(chrmount) 119 | chrmount = os.path.dirname(chrmount) 120 | os.removedirs(chrmount) 121 | except OSError as e: 122 | # ignore missing mountpoints, non-empty directories, and permission errors 123 | if e.errno not in (errno.ENOTEMPTY, errno.EACCES, errno.ENOENT): 124 | raise ChrootMountError( 125 | f'failed to remove chroot mount point {chrmount!r}', 126 | getattr(e, 'errno', None)) 127 | 128 | def _mount(self): 129 | """Do the bind mounts for this chroot object. 130 | 131 | This _must_ be run after creating a new mount namespace. 132 | """ 133 | for _, source, chrmount, opts in self.mounts: 134 | if dictbool(opts, 'optional') and not os.path.exists(source): 135 | self.log.debug('Skipping optional and nonexistent mountpoint: %s', source) 136 | continue 137 | bind(src=source, dest=chrmount, chroot=self.path, log=self.log, **opts) 138 | -------------------------------------------------------------------------------- /src/pychroot/exceptions.py: -------------------------------------------------------------------------------- 1 | """Various chroot-related exception classes""" 2 | 3 | import os 4 | 5 | 6 | class ChrootError(Exception): 7 | """Exception raised when there is an error setting up a chroot.""" 8 | 9 | def __init__(self, msg, errno=None): 10 | self.msg = msg 11 | self.errno = errno 12 | 13 | def __str__(self): 14 | error = [self.msg] 15 | if self.errno is not None: 16 | error.append(os.strerror(self.errno)) 17 | return ': '.join(error) 18 | 19 | 20 | class ChrootMountError(ChrootError): 21 | """Exception raised when there is an error setting up chroot bind mounts.""" 22 | -------------------------------------------------------------------------------- /src/pychroot/scripts/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """Wrapper for running commandline scripts.""" 4 | 5 | from importlib import import_module 6 | import os 7 | import sys 8 | 9 | 10 | def run(script_name): 11 | """Run a given script module.""" 12 | try: 13 | from snakeoil.cli.tool import Tool 14 | script_module = '.'.join( 15 | os.path.realpath(__file__).split(os.path.sep)[-3:-1] + 16 | [script_name.replace('-', '_')]) 17 | script = import_module(script_module) 18 | except ImportError as e: 19 | sys.stderr.write(f'Failed importing: {e}!\n') 20 | py_version = '.'.join(map(str, sys.version_info[:3])) 21 | sys.stderr.write( 22 | 'Verify that pychroot and its deps are properly installed ' 23 | f'and/or PYTHONPATH is set correctly for python {py_version}.\n') 24 | # show traceback in debug mode or for unhandled exceptions 25 | if '--debug' in sys.argv[1:] or not all((e.__cause__, e.__context__)): 26 | sys.stderr.write('\n') 27 | raise 28 | sys.stderr.write('Add --debug to the commandline for a traceback.\n') 29 | sys.exit(1) 30 | 31 | tool = Tool(script.argparser) 32 | sys.exit(tool()) 33 | 34 | 35 | if __name__ == '__main__': 36 | # We're in a git repo or tarball so add the src dir to the system path. 37 | # Note that this assumes a certain module layout. 38 | src_dir = os.path.realpath(__file__).rsplit(os.path.sep, 3)[0] 39 | sys.path.insert(0, src_dir) 40 | run(os.path.basename(__file__)) 41 | -------------------------------------------------------------------------------- /src/pychroot/scripts/pychroot.py: -------------------------------------------------------------------------------- 1 | """an extended chroot equivalent 2 | 3 | pychroot is an extended **chroot(1)** equivalent that also provides support for 4 | automatically handling bind mounts. By default, the proc and sysfs filesystems 5 | are mounted to their respective /proc and /sys locations inside the chroot as 6 | well as bind mounting /dev and /etc/resolv.conf from the host system. 7 | 8 | In addition to the defaults, the user is able to specify custom bind mounts. 9 | For example, the following command will recursively bind mount the user's home 10 | directory at the same location inside the chroot directory:: 11 | 12 | pychroot -R /home/user ~/chroot 13 | 14 | This allows a user to easily set up a custom chroot environment without having 15 | to resort to scripted mount handling or other methods. 16 | """ 17 | 18 | import argparse 19 | from functools import partial 20 | import os 21 | import subprocess 22 | 23 | from snakeoil.cli import arghparse 24 | 25 | from ..base import Chroot 26 | from ..exceptions import ChrootError 27 | 28 | 29 | def bindmount(s, recursive=False, readonly=False): 30 | """Argparse argument type for bind mount variants.""" 31 | opts = {'recursive': recursive, 'readonly': readonly} 32 | return {s: opts} 33 | 34 | 35 | class mountpoints(argparse.Action): 36 | """Argparse action that adds specified mountpoint to be mounted.""" 37 | 38 | def __call__(self, parser, namespace, values, option_string=None): 39 | if not getattr(namespace, 'mountpoints', False): 40 | namespace.mountpoints = {} 41 | namespace.mountpoints.update(values) 42 | 43 | 44 | argparser = arghparse.ArgumentParser( 45 | color=False, debug=False, quiet=False, verbose=False, 46 | description=__doc__, script=(__file__, __name__)) 47 | argparser.add_argument('path', help='path to newroot') 48 | argparser.add_argument( 49 | 'command', nargs=argparse.REMAINDER, help='optional command to run', 50 | docs=""" 51 | Optional command to run. 52 | 53 | Similar to chroot(1), if unspecified this defaults to $SHELL from the 54 | host environment and if that's unset it executes /bin/sh. 55 | """) 56 | 57 | chroot_options = argparser.add_argument_group('chroot options') 58 | chroot_options.add_argument( 59 | '--no-mounts', action='store_true', 60 | help='disable the default bind mounts', 61 | docs=""" 62 | Disable the default bind mounts which can be used to obtain a standard 63 | chroot environment that you'd expect when using chroot(1). 64 | """) 65 | chroot_options.add_argument( 66 | '--hostname', type=str, help='specify the chroot hostname', 67 | docs=""" 68 | Specify the chroot hostname. In order to set the domain name as well, 69 | pass an FQDN instead of a singular hostname. 70 | """) 71 | chroot_options.add_argument( 72 | '--skip-chdir', action='store_true', 73 | help="do not change working directory to '/'", 74 | docs=""" 75 | Do not change the current working directory to '/'. 76 | 77 | Unlike chroot(1), this currently doesn't limit you to only using it 78 | when the new root isn't '/'. In other words, you can use a new chroot 79 | environment on the current host system rootfs with one caveat: any 80 | absolute paths will use the new rootfs. 81 | """) 82 | chroot_options.add_argument( 83 | '-B', '--bind', type=bindmount, action=mountpoints, 84 | metavar='SRC[:DEST]', help='specify custom bind mount', 85 | docs=""" 86 | Specify a custom bind mount. 87 | 88 | In order to mount the same source to multiple destinations, use the 89 | SRC:DEST syntax. For example, the following will bind mount '/srv/data' 90 | to /srv/data and /home/user/data in the chroot:: 91 | 92 | pychroot -B /srv/data -B /srv/data:/home/user/data /path/to/chroot 93 | """) 94 | chroot_options.add_argument( 95 | '-R', '--rbind', type=partial(bindmount, recursive=True), 96 | action=mountpoints, metavar='SRC[:DEST]', 97 | help='specify custom recursive bind mount') 98 | chroot_options.add_argument( 99 | '--ro', '--readonly', type=partial(bindmount, readonly=True), 100 | action=mountpoints, metavar='SRC[:DEST]', 101 | help='specify custom readonly bind mount', 102 | docs=""" 103 | Specify a custom readonly bind mount. 104 | 105 | Readonly, recursive bind mounts aren't currently supported on Linux so 106 | this has to be a standalone option for now. Once they are, support for 107 | them and other mount attributes will be added as an extension to the 108 | mount point argument syntax. 109 | """) 110 | 111 | 112 | @argparser.bind_final_check 113 | def _validate_args(parser, namespace): 114 | if not namespace.command: 115 | namespace.command = [os.getenv('SHELL', '/bin/sh'), '-i'] 116 | 117 | if namespace.no_mounts: 118 | namespace.mountpoints = None 119 | elif not hasattr(namespace, 'mountpoints'): 120 | namespace.mountpoints = {} 121 | 122 | 123 | @argparser.bind_main_func 124 | def main(options, out, err): 125 | try: 126 | chroot = Chroot( 127 | options.path, mountpoints=options.mountpoints, 128 | hostname=options.hostname, skip_chdir=options.skip_chdir) 129 | with chroot: 130 | p = subprocess.run(options.command) 131 | raise SystemExit(p.returncode) 132 | except FileNotFoundError as e: 133 | cmd = options.command[0] 134 | argparser.error(f'failed to run command {cmd!r}: {e}', status=1) 135 | except ChrootError as e: 136 | argparser.error(str(e), status=1) 137 | 138 | return chroot.exit_status 139 | -------------------------------------------------------------------------------- /src/pychroot/utils.py: -------------------------------------------------------------------------------- 1 | """Various chroot-related utilities mostly dealing with bind mounting.""" 2 | 3 | import errno 4 | from functools import reduce # pylint: disable=redefined-builtin 5 | import logging 6 | import operator 7 | import os 8 | 9 | from snakeoil.fileutils import touch 10 | from snakeoil.osutils.mount import mount, MS_BIND, MS_REC, MS_REMOUNT, MS_RDONLY 11 | 12 | from .exceptions import ChrootMountError 13 | 14 | 15 | def dictbool(dct, key): 16 | """Check if a key exists and is True in a dictionary. 17 | 18 | :param dct: The dictionary to check. 19 | :type dct: dict 20 | :param key: The key to check 21 | :type key: any 22 | """ 23 | return key in dct and isinstance(dct[key], bool) and dct[key] 24 | 25 | 26 | def getlogger(log, name): 27 | """Gets a logger given a logger and a package. 28 | 29 | Will return the given logger if the name is not generated from 30 | the current package, otherwise generate a logger based on __name__. 31 | 32 | :param log: Logger to start with. 33 | :type log: logging.Logger 34 | :param name: The __name__ of the caller. 35 | :type name: str 36 | """ 37 | return ( 38 | log if isinstance(log, logging.Logger) 39 | and not log.name.startswith(name.partition('.')[0]) 40 | else logging.getLogger(name)) 41 | 42 | 43 | def bind(src, dest, chroot, create=False, log=None, readonly=False, 44 | recursive=False, **_kwargs): 45 | """Set up a bind mount. 46 | 47 | :param src: The source location to mount. 48 | :type src: str 49 | :param dest: The destination to mount on. 50 | :type dest: str 51 | :param chroot: The chroot base path. 52 | :type chroot: str 53 | :param create: Whether to create the destination. 54 | :type create: bool 55 | :param log: A logger to use for logging. 56 | :type log: logging.Logger 57 | :param readonly: Whether to remount read-only. 58 | :type readonly: bool 59 | :param recursive: Whether to use a recursive bind mount. 60 | :type recursive: bool 61 | """ 62 | log = getlogger(log, __name__) 63 | fstypes = ('proc', 'sysfs', 'tmpfs') 64 | mount_flags = [] 65 | mount_options = [] 66 | 67 | if src not in fstypes: 68 | src = os.path.normpath(src) 69 | if os.path.islink(dest): 70 | dest = os.path.join(chroot, os.path.realpath(dest).lstrip('/')) 71 | if not os.path.exists(dest): 72 | create = True 73 | else: 74 | dest = os.path.normpath(dest) 75 | 76 | if create: 77 | try: 78 | if not os.path.isdir(src) and src not in fstypes: 79 | os.makedirs(os.path.dirname(dest)) 80 | else: 81 | os.makedirs(dest) 82 | except OSError as e: 83 | if e.errno != errno.EEXIST: 84 | raise 85 | 86 | if not os.path.isdir(src) and src not in fstypes: 87 | try: 88 | touch(dest) 89 | except OSError as e: 90 | raise ChrootMountError( 91 | f'cannot bind mount to {dest!r}', getattr(e, 'errno', None)) 92 | 93 | if not os.path.exists(src) and src not in fstypes: 94 | raise ChrootMountError(f'cannot bind mount from {src!r}', errno.ENOENT) 95 | elif not os.path.exists(dest): 96 | raise ChrootMountError(f'cannot bind mount to {dest!r}', errno.ENOENT) 97 | 98 | if src in fstypes: 99 | fstype = src 100 | log.debug(' mounting %r filesystem on %r', src, dest) 101 | else: 102 | fstype = None 103 | mount_flags.append(MS_BIND) 104 | if recursive: 105 | mount_flags.append(MS_REC) 106 | log.debug(' bind mounting %r on %r', src, dest) 107 | 108 | try: 109 | mount(source=src, target=dest, fstype=fstype, 110 | flags=reduce(operator.or_, mount_flags, 0), 111 | data=','.join(mount_options)) 112 | if readonly: 113 | mount_flags.extend([MS_REMOUNT, MS_RDONLY]) 114 | mount(source=src, target=dest, fstype=fstype, 115 | flags=reduce(operator.or_, mount_flags, 0), 116 | data=','.join(mount_options)) 117 | except OSError as e: 118 | raise ChrootMountError( 119 | f'failed mounting: mount -t {fstype} {src} {dest}', e.errno) 120 | -------------------------------------------------------------------------------- /tests/test_chroot.py: -------------------------------------------------------------------------------- 1 | import socket 2 | from itertools import chain, cycle 3 | from unittest import mock 4 | 5 | from pytest import raises 6 | 7 | from pychroot.base import Chroot 8 | from pychroot.exceptions import ChrootError, ChrootMountError 9 | 10 | 11 | class TestChroot: 12 | 13 | def test_mount(self): 14 | # testing Chroot.mount() 15 | with mock.patch('pychroot.base.bind') as bind, \ 16 | mock.patch('os.path.exists') as exists, \ 17 | mock.patch('pychroot.base.dictbool') as dictbool, \ 18 | mock.patch('pychroot.base.simple_unshare'): 19 | 20 | chroot = Chroot('/') 21 | bind.side_effect = None 22 | exists.return_value = False 23 | dictbool.return_value = True 24 | chroot._mount() 25 | assert not bind.called 26 | 27 | def test_chroot(self): 28 | with mock.patch('os.fork') as fork, \ 29 | mock.patch('os.chroot'), \ 30 | mock.patch('os.chdir') as chdir, \ 31 | mock.patch('os.remove') as remove, \ 32 | mock.patch('os._exit'), \ 33 | mock.patch('os.path.exists') as exists, \ 34 | mock.patch('os.waitpid', return_value=(0, 0)), \ 35 | mock.patch('pychroot.utils.mount'), \ 36 | mock.patch('pychroot.base.simple_unshare'): 37 | 38 | # bad path 39 | exists.return_value = False 40 | with raises(ChrootError): 41 | Chroot('/nonexistent/path') 42 | exists.return_value = True 43 | 44 | # $FAKEVAR not defined in environment 45 | with raises(ChrootMountError): 46 | Chroot('/', mountpoints={'$FAKEVAR': {}}) 47 | 48 | # no mountpoints 49 | chroot = Chroot('/', mountpoints=None) 50 | assert chroot.mountpoints == {} 51 | assert list(chroot.mounts) == [] 52 | 53 | # optional, undefined variable mounts get dropped 54 | chroot = Chroot('/', mountpoints={ 55 | '$FAKEVAR': {'optional': True}, 56 | '/home/user': {}}) 57 | assert '$FAKEVAR' not in chroot.mounts 58 | assert len(list(chroot.mounts)) - len(chroot.default_mounts) == 1 59 | 60 | with mock.patch('os.getenv', return_value='/fake/src/path'): 61 | chroot = Chroot('/', mountpoints={'$FAKEVAR': {}}) 62 | assert '/fake/src/path' in chroot.mountpoints 63 | 64 | exists.side_effect = chain([True], cycle([False])) 65 | with mock.patch('os.getenv', return_value='/fake/src/path'): 66 | chroot = Chroot('/', mountpoints={'$FAKEVAR:/fake/dest/path': {}}) 67 | assert chroot.mountpoints['/fake/src/path'].get('create', False) 68 | exists.side_effect = None 69 | exists.return_value = True 70 | 71 | # test parent process 72 | fork.return_value = 10 73 | 74 | # test UTS namespace 75 | chroot = Chroot('/', hostname='hostname-test') 76 | with chroot: 77 | assert socket.gethostname() == 'hostname-test' 78 | 79 | # test child process 80 | fork.return_value = 0 81 | 82 | chroot = Chroot('/') 83 | with chroot: 84 | pass 85 | 86 | # make sure the default mount points aren't altered 87 | # when passing custom mount points 88 | default_mounts = dict(Chroot.default_mounts) 89 | chroot = Chroot('/', mountpoints={'tmpfs:/tmp': {}}) 90 | assert default_mounts == chroot.default_mounts 91 | -------------------------------------------------------------------------------- /tests/test_chroot_utils.py: -------------------------------------------------------------------------------- 1 | import errno 2 | import logging 3 | import os 4 | from unittest import mock 5 | 6 | from pytest import raises 7 | from snakeoil.osutils.mount import MS_BIND, MS_REC, MS_REMOUNT, MS_RDONLY 8 | 9 | from pychroot.utils import dictbool, getlogger, bind 10 | from pychroot.exceptions import ChrootMountError 11 | 12 | 13 | def test_dictbool(): 14 | assert dictbool({'a': True}, 'a') 15 | assert not dictbool({'a': True}, 'b') 16 | assert not dictbool({'a': False}, 'a') 17 | 18 | 19 | def test_getlogger(): 20 | log = getlogger(logging.Logger, __name__) 21 | assert type(log) == logging.Logger 22 | 23 | 24 | def test_bind(tmp_path): 25 | with raises(ChrootMountError): 26 | bind('/nonexistent/src/path', '/randomdir', '/chroot/path') 27 | with raises(ChrootMountError): 28 | bind('tmpfs', '/nonexistent/dest/path', '/chroot/path') 29 | with mock.patch('pychroot.utils.mount', side_effect=OSError): 30 | with raises(ChrootMountError): 31 | bind('proc', '/root', '/chroot/path') 32 | 33 | # create 34 | with mock.patch('pychroot.utils.mount') as mount, \ 35 | mock.patch('os.path.isdir') as isdir, \ 36 | mock.patch('os.makedirs') as makedirs: 37 | src = tmp_path / 'pychroot-src' 38 | src.mkdir() 39 | src = str(src) 40 | chroot = tmp_path / 'pychroot-chroot' 41 | chroot.mkdir() 42 | chroot = str(chroot) 43 | dest = os.path.join(chroot, 'dest') 44 | destfile = os.path.join(chroot, 'destfile') 45 | os.mkdir(dest) 46 | 47 | isdir.return_value = True 48 | bind(src, dest, chroot, create=True) 49 | makedirs.assert_called_once_with(dest) 50 | 51 | makedirs.reset_mock() 52 | 53 | ## mounting on top of a symlink 54 | # symlink points to an existing path 55 | os.symlink('/dest', os.path.join(chroot, 'existing')) 56 | bind(src, os.path.join(chroot, 'existing'), chroot, create=False) 57 | assert not makedirs.called 58 | 59 | makedirs.reset_mock() 60 | 61 | # broken symlink 62 | # usually this would raise ChrootMountError but we're catching the makedirs call 63 | with raises(ChrootMountError): 64 | os.symlink('/nonexistent', os.path.join(chroot, 'broken')) 65 | bind(src, os.path.join(chroot, 'broken'), chroot, create=False) 66 | makedirs.assert_called_once_with(os.path.join(chroot, 'nonexistent')) 67 | 68 | makedirs.reset_mock() 69 | 70 | e = OSError('fake exception') 71 | e.errno = errno.EIO 72 | makedirs.side_effect = e 73 | with raises(OSError): 74 | bind(src, dest, chroot, create=True) 75 | 76 | makedirs.reset_mock() 77 | makedirs.side_effect = None 78 | 79 | # bind an individual file 80 | isdir.return_value = False 81 | bind(src, destfile, chroot, create=True) 82 | makedirs.assert_called_once_with(chroot) 83 | 84 | mount.reset_mock() 85 | 86 | # recursive mount 87 | isdir.return_value = True 88 | bind(src, dest, chroot, create=True, recursive=True) 89 | mount.assert_called_once_with( 90 | source=src, target=dest, fstype=None, 91 | flags=(MS_BIND | MS_REC), data='') 92 | 93 | mount.reset_mock() 94 | 95 | # readonly mount 96 | isdir.return_value = True 97 | bind(src, dest, chroot, create=True, readonly=True) 98 | call1 = mock.call( 99 | source=src, target=dest, fstype=None, flags=(MS_BIND), data='') 100 | call2 = mock.call( 101 | source=src, target=dest, fstype=None, 102 | flags=(MS_BIND | MS_REMOUNT | MS_RDONLY), data='') 103 | mount.assert_has_calls([call1, call2]) 104 | 105 | #with raises(ChrootMountError): 106 | #bind('/', '/root', readonly=True) 107 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | import shlex 2 | from unittest.mock import patch 3 | 4 | from snakeoil.cli.tool import Tool 5 | 6 | from pychroot.scripts.pychroot import argparser 7 | 8 | 9 | def test_arg_parsing(): 10 | """Various argparse checks.""" 11 | # no mounts 12 | opts = argparser.parse_args('--no-mounts fakedir'.split()) 13 | assert opts.mountpoints is None 14 | 15 | # single newroot arg with $SHELL from env 16 | with patch('os.getenv', return_value='shell'): 17 | opts = argparser.parse_args(['dir']) 18 | assert opts.path == 'dir' 19 | assert opts.command == ['shell', '-i'] 20 | assert opts.mountpoints == {} 21 | 22 | # default shell when $SHELL isn't defined in the env 23 | with patch.dict('os.environ', {}, clear=True): 24 | opts = argparser.parse_args(['dir']) 25 | assert opts.command == ['/bin/sh', '-i'] 26 | 27 | # complex args 28 | opts = argparser.parse_args(shlex.split( 29 | '-R /home -B /tmp --ro /var dir cmd arg "arg1 arg2"')) 30 | assert opts.path == 'dir' 31 | assert opts.command == ['cmd', 'arg', 'arg1 arg2'] 32 | assert opts.path == 'dir' 33 | assert opts.mountpoints == { 34 | '/home': {'recursive': True, 'readonly': False}, 35 | '/tmp': {'recursive': False, 'readonly': False}, 36 | '/var': {'recursive': False, 'readonly': True}, 37 | } 38 | 39 | 40 | def test_cli(capfd, tmp_path): 41 | """Various command line interaction checks.""" 42 | pychroot = Tool(argparser) 43 | 44 | # no args 45 | ret = pychroot([]) 46 | assert ret == 2 47 | out, err = capfd.readouterr() 48 | assert err.startswith("pychroot: error: ") 49 | 50 | # nonexistent directory 51 | ret = pychroot(['nonexistent']) 52 | assert ret == 1 53 | out, err = capfd.readouterr() 54 | assert err == ( 55 | "pychroot: error: cannot change root directory " 56 | "to 'nonexistent': Not a directory\n") 57 | 58 | with patch('pychroot.scripts.pychroot.Chroot'), \ 59 | patch('os.getenv', return_value='/bin/sh'), \ 60 | patch('subprocess.run') as run: 61 | chroot = str(tmp_path) 62 | 63 | # exec arg testing 64 | pychroot([chroot]) 65 | shell = '/bin/sh' 66 | run.assert_called_once_with([shell, '-i']) 67 | run.reset_mock() 68 | 69 | pychroot([chroot, 'ls -R /']) 70 | run.assert_called_once_with(['ls -R /']) 71 | run.reset_mock() 72 | 73 | e = FileNotFoundError("command doesn't exist") 74 | run.side_effect = e 75 | pychroot([chroot, 'nonexistent']) 76 | out, err = capfd.readouterr() 77 | assert err == f"pychroot: error: failed to run command 'nonexistent': {e}\n" 78 | run.reset_mock() 79 | -------------------------------------------------------------------------------- /tests/test_script.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | from unittest.mock import patch 3 | 4 | from pytest import raises 5 | 6 | from pychroot import __title__ as project 7 | from pychroot.scripts import run 8 | 9 | 10 | def test_script_run(capfd): 11 | """Test regular code path for running scripts.""" 12 | script = partial(run, project) 13 | 14 | with patch(f'{project}.scripts.import_module') as import_module: 15 | import_exception = ImportError("baz module doesn't exist") 16 | import_exception.__cause__ = Exception('cause of ImportError') 17 | import_exception.__context__ = Exception('context of ImportError') 18 | import_module.side_effect = import_exception 19 | # explicitly handled ImportErrors don't show a backtrace 20 | with patch('sys.argv', [project]): 21 | with raises(SystemExit) as excinfo: 22 | script() 23 | assert excinfo.value.code == 1 24 | out, err = capfd.readouterr() 25 | err = err.strip().split('\n') 26 | assert len(err) == 3 27 | assert err[0] == "Failed importing: baz module doesn't exist!" 28 | assert err[1].startswith(f"Verify that {project} and its deps") 29 | assert err[2] == "Add --debug to the commandline for a traceback." 30 | import_module.reset_mock() 31 | 32 | import_module.side_effect = ImportError("baz module doesn't exist") 33 | # import errors show backtrace for unhandled exceptions or when --debug is passed 34 | for args in ([], ['--debug']): 35 | with patch('sys.argv', [project] + args): 36 | with raises(ImportError): 37 | script() 38 | out, err = capfd.readouterr() 39 | err = err.strip().split('\n') 40 | assert len(err) == 2 41 | assert err[0] == "Failed importing: baz module doesn't exist!" 42 | assert err[1].startswith(f"Verify that {project} and its deps") 43 | import_module.reset_mock() 44 | 45 | # no args 46 | with patch('sys.argv', [project]): 47 | with raises(SystemExit) as excinfo: 48 | script() 49 | assert excinfo.value.code == 2 50 | out, err = capfd.readouterr() 51 | assert err.startswith(f"{project}: error: ") 52 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py38, py39 3 | [testenv] 4 | # force latest virtualenv/pip 5 | download = true 6 | deps = -rrequirements/tox.txt 7 | commands = 8 | pytest --cov {posargs:-v} 9 | 10 | # build docs 11 | [testenv:docs] 12 | skip_install = true 13 | deps = 14 | -rrequirements/dev.txt 15 | -rrequirements/docs.txt 16 | commands = 17 | python setup.py build_docs 18 | 19 | # build dist files 20 | [testenv:dist] 21 | skip_install = true 22 | deps = -rrequirements/dist.txt 23 | commands = 24 | python setup.py sdist 25 | python setup.py bdist_wheel 26 | --------------------------------------------------------------------------------